Repository: fastly/cli Branch: main Commit: 8ec2b5696be6 Files: 1492 Total size: 5.3 MB Directory structure: gitextract_2_s0yx1h/ ├── .fastly/ │ ├── config.toml │ └── help/ │ ├── README.md │ ├── cli-auth.mdx │ └── ecp-feature.mdx ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── dependabot_changelog_update.yml │ ├── merge_to_main.yml │ ├── pr_test.yml │ ├── publish_release.yml │ └── tag_to_draft_release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .tmpl/ │ ├── create.go │ ├── delete.go │ ├── describe.go │ ├── doc.go │ ├── doc_parent.go │ ├── list.go │ ├── root.go │ ├── root_parent.go │ ├── test.go │ └── update.go ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── DOCUMENTATION.md ├── Dockerfile-node ├── Dockerfile-rust ├── ISSUES.md ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── SECURITY.md ├── TESTING.md ├── cmd/ │ └── fastly/ │ └── main.go ├── deb-copyright ├── go.mod ├── go.sum ├── npm/ │ └── @fastly/ │ └── cli/ │ ├── .gitignore │ ├── fastly.js │ ├── index.d.ts │ ├── index.js │ ├── package-helpers.js │ ├── package.json │ └── update.js ├── pkg/ │ ├── api/ │ │ ├── doc.go │ │ ├── interface.go │ │ └── undocumented/ │ │ └── undocumented.go │ ├── app/ │ │ ├── disable_token_flag_test.go │ │ ├── doc.go │ │ ├── expiry_warning_test.go │ │ ├── metadata.json │ │ ├── run.go │ │ ├── run_test.go │ │ ├── usage.go │ │ └── usage_auth_help_test.go │ ├── argparser/ │ │ ├── cmd.go │ │ ├── cmd_test.go │ │ ├── common.go │ │ ├── doc.go │ │ ├── fixtures/ │ │ │ └── content_test.txt │ │ ├── flags.go │ │ └── flags_test.go │ ├── auth/ │ │ ├── auth.go │ │ └── doc.go │ ├── check/ │ │ └── check.go │ ├── commands/ │ │ ├── alias/ │ │ │ ├── acl/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── aclentry/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── alerts/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── history.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── backend/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── dictionary/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── dictionaryentry/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── doc.go │ │ │ ├── healthcheck/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── imageoptimizerdefaults/ │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── logging/ │ │ │ │ ├── azureblob/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── bigquery/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── cloudfiles/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── datadog/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── digitalocean/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── doc.go │ │ │ │ ├── elasticsearch/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── ftp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── gcs/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── googlepubsub/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── grafanacloudlogs/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── heroku/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── honeycomb/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── https/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── kafka/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── kinesis/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── loggly/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── logshuttle/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── newrelic/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── newrelicotlp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── openstack/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── papertrail/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── root.go │ │ │ │ ├── s3/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── scalyr/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── sftp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── splunk/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── sumologic/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ └── syslog/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── purge/ │ │ │ │ ├── doc.go │ │ │ │ └── purge.go │ │ │ ├── ratelimit/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── resourcelink/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── serviceauth/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── serviceversion/ │ │ │ │ ├── activate.go │ │ │ │ ├── clone.go │ │ │ │ ├── deactivate.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── lock.go │ │ │ │ ├── root.go │ │ │ │ ├── stage.go │ │ │ │ ├── unstage.go │ │ │ │ └── update.go │ │ │ └── vcl/ │ │ │ ├── condition/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── custom/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── root.go │ │ │ └── snippet/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── update.go │ │ ├── apisecurity/ │ │ │ ├── discoveredoperations/ │ │ │ │ ├── discoveredoperations_test.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── doc.go │ │ │ ├── operations/ │ │ │ │ ├── addtags.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── operations_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── root.go │ │ │ └── tags/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── doc.go │ │ │ ├── get.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ ├── tags_test.go │ │ │ └── update.go │ │ ├── auth/ │ │ │ ├── add.go │ │ │ ├── delete.go │ │ │ ├── expiry.go │ │ │ ├── expiry_test.go │ │ │ ├── list.go │ │ │ ├── login.go │ │ │ ├── metadata.go │ │ │ ├── metadata_test.go │ │ │ ├── revoke.go │ │ │ ├── revoke_test.go │ │ │ ├── root.go │ │ │ ├── show.go │ │ │ ├── sso.go │ │ │ ├── sso_test.go │ │ │ ├── token.go │ │ │ ├── token_test.go │ │ │ ├── token_tty_unix_test.go │ │ │ └── use.go │ │ ├── authtoken/ │ │ │ ├── authtoken_test.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── testdata/ │ │ │ └── tokens │ │ ├── commands.go │ │ ├── commands_test.go │ │ ├── compute/ │ │ │ ├── build.go │ │ │ ├── build_test.go │ │ │ ├── compute_mocks_test.go │ │ │ ├── compute_test.go │ │ │ ├── computeacl/ │ │ │ │ ├── computeacl_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── listacls.go │ │ │ │ ├── listentries.go │ │ │ │ ├── lookup.go │ │ │ │ ├── root.go │ │ │ │ ├── testdata/ │ │ │ │ │ └── entries.json │ │ │ │ └── update.go │ │ │ ├── deploy.go │ │ │ ├── deploy_test.go │ │ │ ├── dir.go │ │ │ ├── doc.go │ │ │ ├── hashfiles.go │ │ │ ├── init.go │ │ │ ├── init_test.go │ │ │ ├── language.go │ │ │ ├── language_assemblyscript.go │ │ │ ├── language_cpp.go │ │ │ ├── language_go.go │ │ │ ├── language_javascript.go │ │ │ ├── language_javascript_test.go │ │ │ ├── language_other.go │ │ │ ├── language_rust.go │ │ │ ├── language_toolchain.go │ │ │ ├── metadata.go │ │ │ ├── metadata_test.go │ │ │ ├── pack.go │ │ │ ├── pack_test.go │ │ │ ├── publish.go │ │ │ ├── pushpin.conf.template │ │ │ ├── root.go │ │ │ ├── secrets.go │ │ │ ├── serve.go │ │ │ ├── serve_test.go │ │ │ ├── serve_unix.go │ │ │ ├── serve_windows.go │ │ │ ├── setup/ │ │ │ │ ├── backend.go │ │ │ │ ├── config_store.go │ │ │ │ ├── doc.go │ │ │ │ ├── domain.go │ │ │ │ ├── interface.go │ │ │ │ ├── kv_store.go │ │ │ │ ├── kv_store_test.go │ │ │ │ ├── loggers.go │ │ │ │ └── secret_store.go │ │ │ ├── testdata/ │ │ │ │ ├── build/ │ │ │ │ │ ├── cpp/ │ │ │ │ │ │ └── main.cpp │ │ │ │ │ ├── go/ │ │ │ │ │ │ ├── go.mod │ │ │ │ │ │ └── main.go │ │ │ │ │ ├── javascript/ │ │ │ │ │ │ ├── package.json │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── rust/ │ │ │ │ │ ├── Cargo.lock │ │ │ │ │ ├── Cargo.toml │ │ │ │ │ ├── fastly.toml │ │ │ │ │ └── src/ │ │ │ │ │ └── main.rs │ │ │ │ ├── init/ │ │ │ │ │ ├── fastly-invalid-missing-version.toml │ │ │ │ │ ├── fastly-invalid-section-version.toml │ │ │ │ │ ├── fastly-invalid-unrecognised.toml │ │ │ │ │ ├── fastly-invalid-version-exceeded.toml │ │ │ │ │ ├── fastly-missing-spec-url.toml │ │ │ │ │ ├── fastly-valid-integer.toml │ │ │ │ │ ├── fastly-valid-semver.toml │ │ │ │ │ └── fastly-viceroy-update.toml │ │ │ │ ├── kv_store_example.json │ │ │ │ ├── main.wasm │ │ │ │ ├── metadata/ │ │ │ │ │ └── config.toml │ │ │ │ └── pack/ │ │ │ │ └── main.wasm │ │ │ ├── update.go │ │ │ ├── update_test.go │ │ │ ├── validate.go │ │ │ └── validate_test.go │ │ ├── config/ │ │ │ ├── config_test.go │ │ │ ├── doc.go │ │ │ ├── root.go │ │ │ └── testdata/ │ │ │ └── config.toml │ │ ├── configstore/ │ │ │ ├── configstore_test.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── helper_test.go │ │ │ ├── list.go │ │ │ ├── list_services.go │ │ │ ├── root.go │ │ │ └── update.go │ │ ├── configstoreentry/ │ │ │ ├── configstoreentry_test.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── errors.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── update.go │ │ ├── dashboard/ │ │ │ ├── create.go │ │ │ ├── dashboard_test.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── item/ │ │ │ │ ├── common.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── item_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── list.go │ │ │ ├── printer/ │ │ │ │ └── print.go │ │ │ ├── root.go │ │ │ └── update.go │ │ ├── doc.go │ │ ├── domain/ │ │ │ ├── common.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── domain_test.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── update.go │ │ ├── install/ │ │ │ ├── doc.go │ │ │ └── root.go │ │ ├── ip/ │ │ │ ├── doc.go │ │ │ ├── ip_test.go │ │ │ └── root.go │ │ ├── kvstore/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── kvstore_test.go │ │ │ ├── list.go │ │ │ └── root.go │ │ ├── kvstoreentry/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── get.go │ │ │ ├── hidden.go │ │ │ ├── kvstoreentry_test.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── testdata/ │ │ │ ├── data.json │ │ │ └── example/ │ │ │ ├── .hiddenfile │ │ │ └── foo.txt │ │ ├── logtail/ │ │ │ ├── doc.go │ │ │ ├── root.go │ │ │ ├── tail_test.go │ │ │ └── testdata/ │ │ │ └── response.json │ │ ├── ngwaf/ │ │ │ ├── countrylist/ │ │ │ │ ├── countrylist_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── customsignal/ │ │ │ │ ├── create.go │ │ │ │ ├── customsignal_test.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── doc.go │ │ │ ├── iplist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── iplist_test.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── ngwaflist/ │ │ │ │ ├── api.go │ │ │ │ └── doc.go │ │ │ ├── root.go │ │ │ ├── rule/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── rule_test.go │ │ │ │ ├── testdata/ │ │ │ │ │ ├── test_complex_rule.json │ │ │ │ │ └── test_rule.json │ │ │ │ └── update.go │ │ │ ├── signallist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── signallist_test.go │ │ │ │ └── update.go │ │ │ ├── stringlist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── stringlist_test.go │ │ │ │ └── update.go │ │ │ ├── wildcardlist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── update.go │ │ │ │ └── wildcardlist_test.go │ │ │ └── workspace/ │ │ │ ├── alert/ │ │ │ │ ├── datadog/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── datadog_test.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── doc.go │ │ │ │ ├── jira/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── jira_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── mailinglist/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── mailinglist_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── microsoftteams/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── microsoftteams_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── opsgenie/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── opsgenie_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── pagerduty/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── pagerduty_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── root.go │ │ │ │ ├── slack/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── get.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── slack_test.go │ │ │ │ │ └── update.go │ │ │ │ └── webhook/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get-signing-key.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── rotate-signing-key.go │ │ │ │ ├── update.go │ │ │ │ └── webhook_test.go │ │ │ ├── countrylist/ │ │ │ │ ├── countrylist_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── create.go │ │ │ ├── customsignal/ │ │ │ │ ├── create.go │ │ │ │ ├── customsignal_test.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── delete.go │ │ │ ├── doc.go │ │ │ ├── get.go │ │ │ ├── iplist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── iplist_test.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── list.go │ │ │ ├── redaction/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── list.go │ │ │ │ ├── redaction_test.go │ │ │ │ ├── retrieve.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── root.go │ │ │ ├── rule/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── rule_test.go │ │ │ │ ├── testdata/ │ │ │ │ │ ├── test_complex_rule.json │ │ │ │ │ └── test_rule.json │ │ │ │ └── update.go │ │ │ ├── signallist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── signallist_test.go │ │ │ │ └── update.go │ │ │ ├── stringlist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── stringlist_test.go │ │ │ │ └── update.go │ │ │ ├── threshold/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── threshold_test.go │ │ │ │ └── update.go │ │ │ ├── update.go │ │ │ ├── virtualpatch/ │ │ │ │ ├── list.go │ │ │ │ ├── retrieve.go │ │ │ │ ├── root.go │ │ │ │ ├── update.go │ │ │ │ └── virtualpatch_test.go │ │ │ ├── wildcardlist/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── update.go │ │ │ │ └── wildcardlist_test.go │ │ │ └── workspace_test.go │ │ ├── objectstorage/ │ │ │ ├── accesskeys/ │ │ │ │ ├── accesskeys_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── list.go │ │ │ │ └── root.go │ │ │ ├── doc.go │ │ │ └── root.go │ │ ├── pop/ │ │ │ ├── doc.go │ │ │ ├── pop_test.go │ │ │ └── root.go │ │ ├── products/ │ │ │ ├── doc.go │ │ │ ├── products_test.go │ │ │ └── root.go │ │ ├── profile/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── profile_test.go │ │ │ ├── root.go │ │ │ ├── switch.go │ │ │ ├── testdata/ │ │ │ │ └── config.toml │ │ │ ├── token.go │ │ │ └── update.go │ │ ├── secretstore/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── flags.go │ │ │ ├── helper_test.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── secretstore_test.go │ │ ├── secretstoreentry/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── flags.go │ │ │ ├── helper_test.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ └── secretstoreentry_test.go │ │ ├── service/ │ │ │ ├── acl/ │ │ │ │ ├── acl_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── aclentry/ │ │ │ │ ├── aclentry_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── testdata/ │ │ │ │ │ └── batch.json │ │ │ │ └── update.go │ │ │ ├── alert/ │ │ │ │ ├── alert_test.go │ │ │ │ ├── common.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── list_history.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── auth/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── service_test.go │ │ │ │ ├── testdata/ │ │ │ │ │ ├── fastly-no-serviceid.toml │ │ │ │ │ └── fastly-valid.toml │ │ │ │ └── update.go │ │ │ ├── backend/ │ │ │ │ ├── backend_test.go │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── dictionary/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── dictionary_test.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── dictionaryentry/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── dictionaryitem_test.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── doc.go │ │ │ ├── domain/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── domain_test.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── update.go │ │ │ │ └── validate.go │ │ │ ├── healthcheck/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── healthcheck_test.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── imageoptimizerdefaults/ │ │ │ │ ├── doc.go │ │ │ │ ├── get.go │ │ │ │ ├── imageoptimizer_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── list.go │ │ │ ├── logging/ │ │ │ │ ├── azureblob/ │ │ │ │ │ ├── azureblob_integration_test.go │ │ │ │ │ ├── azureblob_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── bigquery/ │ │ │ │ │ ├── bigquery_integration_test.go │ │ │ │ │ ├── bigquery_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── cloudfiles/ │ │ │ │ │ ├── cloudfiles_integration_test.go │ │ │ │ │ ├── cloudfiles_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── datadog/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── datadog_integration_test.go │ │ │ │ │ ├── datadog_test.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── debug/ │ │ │ │ │ ├── debug_test.go │ │ │ │ │ ├── doc.go │ │ │ │ │ └── root.go │ │ │ │ ├── digitalocean/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── digitalocean_integration_test.go │ │ │ │ │ ├── digitalocean_test.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── doc.go │ │ │ │ ├── elasticsearch/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── elasticsearch_integration_test.go │ │ │ │ │ ├── elasticsearch_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── ftp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── ftp_integration_test.go │ │ │ │ │ ├── ftp_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── gcs/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── gcs_integration_test.go │ │ │ │ │ ├── gcs_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── googlepubsub/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── googlepubsub_integration_test.go │ │ │ │ │ ├── googlepubsub_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── grafanacloudlogs/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── grafanacloud_logs_integration_test.go │ │ │ │ │ ├── grafanacloudlogs_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── heroku/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── heroku_integration_test.go │ │ │ │ │ ├── heroku_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── honeycomb/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── honeycomb_integration_test.go │ │ │ │ │ ├── honeycomb_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── https/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── https_integration_test.go │ │ │ │ │ ├── https_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── kafka/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── kafka_integration_test.go │ │ │ │ │ ├── kafka_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── kinesis/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── kinesis_integration_test.go │ │ │ │ │ ├── kinesis_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── logflags/ │ │ │ │ │ ├── doc.go │ │ │ │ │ └── flags.go │ │ │ │ ├── loggly/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── loggly_integration_test.go │ │ │ │ │ ├── loggly_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── logshuttle/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── logshuttle_integration_test.go │ │ │ │ │ ├── logshuttle_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── newrelic/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── newrelic_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── newrelicotlp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── newrelicotlp_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── openstack/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── openstack_integration_test.go │ │ │ │ │ ├── openstack_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── papertrail/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── papertrail_integration_test.go │ │ │ │ │ ├── papertrail_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── root.go │ │ │ │ ├── s3/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── s3_integration_test.go │ │ │ │ │ ├── s3_test.go │ │ │ │ │ └── update.go │ │ │ │ ├── scalyr/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── scalyr_integration_test.go │ │ │ │ │ ├── scalyr_test.go │ │ │ │ │ └── update.go │ │ │ │ ├── sftp/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── sftp_integration_test.go │ │ │ │ │ ├── sftp_test.go │ │ │ │ │ └── update.go │ │ │ │ ├── splunk/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── splunk_integration_test.go │ │ │ │ │ ├── splunk_test.go │ │ │ │ │ └── update.go │ │ │ │ ├── sumologic/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── sumologic_integration_test.go │ │ │ │ │ ├── sumologic_test.go │ │ │ │ │ └── update.go │ │ │ │ └── syslog/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ ├── syslog_integration_test.go │ │ │ │ ├── syslog_test.go │ │ │ │ └── update.go │ │ │ ├── purge/ │ │ │ │ ├── doc.go │ │ │ │ ├── purge.go │ │ │ │ ├── purge_test.go │ │ │ │ └── testdata/ │ │ │ │ └── keys │ │ │ ├── ratelimit/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── ratelimit_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── resourcelink/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── resourcelink_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── root.go │ │ │ ├── search.go │ │ │ ├── service_test.go │ │ │ ├── testdata/ │ │ │ │ ├── fastly-no-serviceid.toml │ │ │ │ └── fastly-valid.toml │ │ │ ├── update.go │ │ │ ├── vcl/ │ │ │ │ ├── condition/ │ │ │ │ │ ├── condition_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── custom/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── custom_test.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── testdata/ │ │ │ │ │ │ └── example.vcl │ │ │ │ │ └── update.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── root.go │ │ │ │ ├── snippet/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── snippet_test.go │ │ │ │ │ ├── testdata/ │ │ │ │ │ │ └── snippet.vcl │ │ │ │ │ └── update.go │ │ │ │ └── vcl_test.go │ │ │ └── version/ │ │ │ ├── activate.go │ │ │ ├── clone.go │ │ │ ├── deactivate.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── lock.go │ │ │ ├── root.go │ │ │ ├── serviceversion_test.go │ │ │ ├── stage.go │ │ │ ├── unstage.go │ │ │ ├── update.go │ │ │ └── validate.go │ │ ├── shellcomplete/ │ │ │ ├── doc.go │ │ │ └── root.go │ │ ├── sso/ │ │ │ ├── doc.go │ │ │ ├── root.go │ │ │ └── sso_test.go │ │ ├── stats/ │ │ │ ├── aggregate.go │ │ │ ├── aggregate_test.go │ │ │ ├── doc.go │ │ │ ├── domain_inspector.go │ │ │ ├── domain_inspector_test.go │ │ │ ├── historical.go │ │ │ ├── historical_test.go │ │ │ ├── obj.go │ │ │ ├── origin_inspector.go │ │ │ ├── origin_inspector_test.go │ │ │ ├── realtime.go │ │ │ ├── realtime_test.go │ │ │ ├── regions.go │ │ │ ├── regions_test.go │ │ │ ├── root.go │ │ │ ├── template.go │ │ │ ├── usage.go │ │ │ └── usage_test.go │ │ ├── tls/ │ │ │ ├── config/ │ │ │ │ ├── config_test.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ ├── custom/ │ │ │ │ ├── activation/ │ │ │ │ │ ├── activation_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── update.go │ │ │ │ ├── certificate/ │ │ │ │ │ ├── certificate_test.go │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── root.go │ │ │ │ │ ├── testdata/ │ │ │ │ │ │ └── certificate.crt │ │ │ │ │ └── update.go │ │ │ │ ├── doc.go │ │ │ │ ├── domain/ │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── domain_test.go │ │ │ │ │ ├── list.go │ │ │ │ │ └── root.go │ │ │ │ ├── privatekey/ │ │ │ │ │ ├── create.go │ │ │ │ │ ├── delete.go │ │ │ │ │ ├── describe.go │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── list.go │ │ │ │ │ ├── privatekey_test.go │ │ │ │ │ ├── root.go │ │ │ │ │ └── testdata/ │ │ │ │ │ └── testkey.pem │ │ │ │ └── root.go │ │ │ ├── platform/ │ │ │ │ ├── create.go │ │ │ │ ├── delete.go │ │ │ │ ├── describe.go │ │ │ │ ├── doc.go │ │ │ │ ├── list.go │ │ │ │ ├── platform_test.go │ │ │ │ ├── root.go │ │ │ │ └── update.go │ │ │ └── subscription/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ ├── subscription_test.go │ │ │ └── update.go │ │ ├── tools/ │ │ │ ├── doc.go │ │ │ ├── domain/ │ │ │ │ ├── doc.go │ │ │ │ ├── root.go │ │ │ │ ├── status.go │ │ │ │ ├── status_test.go │ │ │ │ ├── suggest.go │ │ │ │ └── suggest_test.go │ │ │ └── root.go │ │ ├── update/ │ │ │ ├── check.go │ │ │ ├── check_test.go │ │ │ ├── doc.go │ │ │ └── root.go │ │ ├── user/ │ │ │ ├── create.go │ │ │ ├── delete.go │ │ │ ├── describe.go │ │ │ ├── doc.go │ │ │ ├── list.go │ │ │ ├── root.go │ │ │ ├── update.go │ │ │ └── user_test.go │ │ ├── version/ │ │ │ ├── doc.go │ │ │ ├── root.go │ │ │ └── version_test.go │ │ └── whoami/ │ │ ├── doc.go │ │ ├── root.go │ │ └── whoami_test.go │ ├── config/ │ │ ├── auth.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── doc.go │ │ ├── migrate_auth.go │ │ ├── migrate_auth_test.go │ │ └── testdata/ │ │ ├── config-current.toml │ │ ├── config-incompatible-config-version.toml │ │ ├── config-invalid.toml │ │ ├── config-legacy.toml │ │ ├── config-outdated-cli-version.toml │ │ ├── config.toml │ │ └── static/ │ │ ├── config-invalid.toml │ │ └── config.toml │ ├── debug/ │ │ ├── debug.go │ │ └── doc.go │ ├── env/ │ │ ├── doc.go │ │ ├── env.go │ │ └── env_test.go │ ├── errors/ │ │ ├── deduce.go │ │ ├── deduce_test.go │ │ ├── doc.go │ │ ├── errors.go │ │ ├── exit_error.go │ │ ├── log.go │ │ ├── log_test.go │ │ ├── process.go │ │ ├── remediation_error.go │ │ ├── remediation_test.go │ │ └── testdata/ │ │ ├── errors-expected-rotation.log │ │ └── errors-expected.log │ ├── exec/ │ │ ├── doc.go │ │ └── exec.go │ ├── file/ │ │ ├── archive.go │ │ └── doc.go │ ├── filesystem/ │ │ ├── directory.go │ │ ├── doc.go │ │ └── home.go │ ├── fmt/ │ │ ├── doc.go │ │ └── fmt.go │ ├── github/ │ │ ├── doc.go │ │ ├── github.go │ │ └── github_test.go │ ├── global/ │ │ ├── doc.go │ │ ├── global.go │ │ └── global_test.go │ ├── internal/ │ │ └── beacon/ │ │ ├── beacon.go │ │ ├── beacon_test.go │ │ └── doc.go │ ├── lookup/ │ │ ├── doc.go │ │ └── lookup.go │ ├── manifest/ │ │ ├── data.go │ │ ├── doc.go │ │ ├── file.go │ │ ├── flags.go │ │ ├── local_server.go │ │ ├── local_server_test.go │ │ ├── manifest.go │ │ ├── manifest_test.go │ │ ├── setup.go │ │ ├── testdata/ │ │ │ ├── fastly-invalid-missing-version.toml │ │ │ ├── fastly-invalid-unrecognised.toml │ │ │ ├── fastly-invalid-version-exceeded.toml │ │ │ ├── fastly-missing-spec-url.toml │ │ │ ├── fastly-valid-integer.toml │ │ │ ├── fastly-valid-semver.toml │ │ │ ├── fastly-viceroy-update.toml │ │ │ └── fastly-warning-dictionaries.toml │ │ └── version.go │ ├── mock/ │ │ ├── api.go │ │ ├── client.go │ │ ├── config_file.go │ │ ├── doc.go │ │ └── versioner.go │ ├── revision/ │ │ ├── revision.go │ │ └── revision_test.go │ ├── runtime/ │ │ ├── doc.go │ │ └── runtime.go │ ├── sync/ │ │ ├── doc.go │ │ └── sync.go │ ├── testutil/ │ │ ├── api.go │ │ ├── args.go │ │ ├── assert.go │ │ ├── client.go │ │ ├── doc.go │ │ ├── env.go │ │ ├── file.go │ │ ├── json.go │ │ ├── log.go │ │ ├── must.go │ │ ├── paginator.go │ │ ├── scenarios.go │ │ ├── string.go │ │ └── time.go │ ├── text/ │ │ ├── accesskey.go │ │ ├── alerts.go │ │ ├── backend.go │ │ ├── color.go │ │ ├── computeacl.go │ │ ├── configstore.go │ │ ├── customsignal.go │ │ ├── dictionary.go │ │ ├── dictionaryitem.go │ │ ├── dictionaryitem_test.go │ │ ├── doc.go │ │ ├── healthcheck.go │ │ ├── kvstore.go │ │ ├── lines.go │ │ ├── lines_test.go │ │ ├── list.go │ │ ├── redaction.go │ │ ├── resource.go │ │ ├── rule.go │ │ ├── sanitize.go │ │ ├── sanitize_test.go │ │ ├── secretstore.go │ │ ├── service.go │ │ ├── service_test.go │ │ ├── spinner.go │ │ ├── stats.go │ │ ├── table.go │ │ ├── tag.go │ │ ├── text.go │ │ ├── text_test.go │ │ ├── threshold.go │ │ ├── virtualpatch.go │ │ └── workspace.go │ ├── threadsafe/ │ │ ├── doc.go │ │ └── threadsafe.go │ ├── time/ │ │ ├── doc.go │ │ └── time.go │ ├── undo/ │ │ ├── doc.go │ │ └── undo.go │ └── useragent/ │ ├── doc.go │ └── useragent.go ├── scripts/ │ ├── config.sh │ ├── documentation.sh │ ├── go-test-cache/ │ │ ├── go.mod │ │ └── main.go │ ├── scaffold-category.sh │ ├── scaffold-update-interfaces.sh │ ├── scaffold.sh │ └── tags.sh └── tools/ ├── go.mod └── go.sum ================================================ FILE CONTENTS ================================================ ================================================ FILE: .fastly/config.toml ================================================ 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] # * Rust 1.91.0 has a bug where it produces broken wasm packages which crash # when handling requests; the bug is fixed in Rust 1.91.1. # * Rust 1.93.0 has a bug on wasm32-wasip2 where it leaks file descriptors in # `std::fs::File`; the bug is fixed in Rust 1.93.1. toolchain_constraint = ">= 1.78 != 1.91.0 != 1.93.0" wasm_wasi_target = "wasm32-wasip1" [language.cpp] toolchain_constraint = ">= 14.0.0" wasm_wasi_target = "wasm32-wasip1" [wasm-tools] ttl = "24h" [viceroy] ttl = "24h" ================================================ FILE: .fastly/help/README.md ================================================ # 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. ``` ================================================ FILE: .fastly/help/cli-auth.mdx ================================================ --- id: cli-auth title: Authenticate with the Fastly CLI template: help --- ## Quick start Pick whichever method suits your workflow: ``` fastly auth login # paste an API token interactively fastly auth login --sso --token # authenticate via browser-based SSO ``` Both store a default token so subsequent commands authenticate automatically. ## Token sources and precedence The CLI resolves a token in this order (first match wins): 1. `--token` flag (a raw token string, or the name of a stored auth token). Not available when `FASTLY_DISABLE_AUTH_COMMAND` is set. 2. `FASTLY_API_TOKEN` environment variable 3. `profile` field in your project's `fastly.toml` 4. Default auth token from the CLI config file ## Stored tokens You can store multiple named tokens and switch between them: ``` fastly auth add staging --api-token $STAGING_TOKEN fastly auth list fastly auth use staging fastly auth show staging fastly auth delete staging ``` ## Non-interactive usage In CI/CD or scripts where interactive prompts are not available, supply a token via the flag or environment variable: ``` fastly service list --token $MY_TOKEN FASTLY_API_TOKEN=... fastly service list ``` ## Managed environments If `FASTLY_DISABLE_AUTH_COMMAND` is set, both the `fastly auth` command tree and the `--token` global flag are disabled. Authentication is expected to be handled externally via `FASTLY_API_TOKEN` or pre-configured stored tokens. Background SSO refresh flows are unaffected. ## Generating a token Create an API token at: https://manage.fastly.com/account/personal/tokens ================================================ FILE: .fastly/help/ecp-feature.mdx ================================================ --- 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. ================================================ FILE: .github/CODEOWNERS ================================================ * @fastly/developer-tools ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Tell us about a bug to help us improve title: '' labels: bug assignees: '' --- **Version** Please paste the output of `fastly version` here. **What happened** Please describe the command you ran, what you expected to happen, and what happened instead. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ # Disable blank issues for non-maintainers. blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- 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. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### Change summary 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 written new tests for your core changes, as applicable? * [ ] Have you successfully run tests with your changes locally? ### User Impact ### Are there any considerations that need to be addressed for release? ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" allow: - dependency-type: "all" groups: go-dependencies: patterns: - "*" - package-ecosystem: "gomod" directory: "/tools" schedule: interval: "weekly" allow: - dependency-type: "all" labels: - "tools" groups: go-dependencies: patterns: - "*" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" labels: - "github_actions" groups: gha-dependencies: patterns: - "*" ================================================ FILE: .github/workflows/dependabot_changelog_update.yml ================================================ name: Generate changelog entry for Dependabot on: pull_request: types: - opened - synchronize - reopened permissions: contents: read pull-requests: write jobs: dependabot-changelog-update: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: - name: Check labels id: check-labels uses: actions/github-script@v9 with: script: | const labels = context.payload.pull_request.labels.map(l => l.name); const skip = labels.includes('tools') || labels.includes('github_actions'); if (skip) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: ['Skip-Changelog'] }); } return !skip; result-encoding: string - name: Generate a GitHub token if: steps.check-labels.outputs.result == 'true' id: github-token uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: "cli" - name: Checkout code if: steps.check-labels.outputs.result == 'true' uses: actions/checkout@v6 with: token: ${{ steps.github-token.outputs.token }} repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} - name: Generate changelog entry if: steps.check-labels.outputs.result == 'true' uses: dangoslen/dependabot-changelog-helper@v4 with: activationLabels: dependencies changelogPath: './CHANGELOG.md' entryPrefix: 'build(deps): ' - name: Commit changelog entry if: steps.check-labels.outputs.result == 'true' uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "docs(CHANGELOG.md): add dependency bump from dependabot" ================================================ FILE: .github/workflows/merge_to_main.yml ================================================ name: Build CLI Binaries on: pull_request: branches: - "main" types: [closed] permissions: contents: read 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@v6 - name: "Install Node" uses: actions/setup-node@v6 with: node-version: 18 - name: "Install Rust" uses: dtolnay/rust-toolchain@stable # to install tq via `make config` - name: Install Go uses: actions/setup-go@v6 with: go-version: 1.25.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@v7 with: name: fastly-cli-build-${{ matrix.platform }}-${{ github.sha }} path: fastly ================================================ FILE: .github/workflows/pr_test.yml ================================================ name: Test on: pull_request: types: - opened - synchronize - reopened - labeled - unlabeled branches: - main permissions: contents: read # Stop any in-flight CI jobs when a new commit is pushed. concurrency: group: ${{ github.ref_name }} cancel-in-progress: true env: GO_VERSION: 1.25.x GOLANGCI_LINT_VERSION: v2.4 WASI_SDK_VERSION: 25 WASI_SDK_FULL_VERSION: "25.0" 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@v6 - name: "Install Rust" uses: dtolnay/rust-toolchain@stable - name: "Generate static app config" run: make config - name: "Config Artifact" uses: actions/upload-artifact@v7 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@v6 - name: "Install Rust" uses: dtolnay/rust-toolchain@stable - name: Install Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: "Install dependencies" run: make mod-download shell: bash - name: "Config Artifact" uses: actions/download-artifact@v8 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@v9 with: version: ${{ env.GOLANGCI_LINT_VERSION }} only-new-issues: true test: needs: [config] strategy: matrix: tinygo-version: [0.31.2] go-version: [1.25.x] node-version: [18] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} steps: - name: "Checkout code" uses: actions/checkout@v6 - name: "Install Go" uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} # IMPORTANT: Disable caching to prevent cache restore errors later. cache: false - uses: acifani/setup-tinygo@v3 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@v6 with: node-version: ${{ matrix.node-version }} - name: "Install WASI SDK" run: | wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${{ env.WASI_SDK_VERSION }}/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux.tar.gz tar xzf wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux.tar.gz echo "$(pwd)/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux/bin" >> $GITHUB_PATH shell: bash - name: "Config Artifact" uses: actions/download-artifact@v8 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 test-release: if: contains(github.head_ref, 'release') needs: [config] strategy: matrix: tinygo-version: [0.31.2] go-version: [1.25.x] node-version: [18] platform: [macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - name: "Checkout code" uses: actions/checkout@v6 - name: "Install Go" uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} # IMPORTANT: Disable caching to prevent cache restore errors later. cache: false - uses: acifani/setup-tinygo@v3 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@v6 with: node-version: ${{ matrix.node-version }} - name: "Install WASI SDK (Ubuntu)" if: matrix.platform == 'ubuntu-latest' run: | wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${{ env.WASI_SDK_VERSION }}/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux.tar.gz tar xzf wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux.tar.gz echo "$(pwd)/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-linux/bin" >> $GITHUB_PATH shell: bash - name: "Install WASI SDK (macOS)" if: matrix.platform == 'macos-latest' run: | wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${{ env.WASI_SDK_VERSION }}/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-arm64-macos.tar.gz tar xzf wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-arm64-macos.tar.gz echo "$(pwd)/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-arm64-macos/bin" >> $GITHUB_PATH shell: bash - name: "Install WASI SDK (Windows)" if: matrix.platform == 'windows-latest' run: | Invoke-WebRequest -Uri "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${{ env.WASI_SDK_VERSION }}/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-windows.tar.gz" -OutFile "wasi-sdk.tar.gz" tar -xzf wasi-sdk.tar.gz echo "$PWD/wasi-sdk-${{ env.WASI_SDK_FULL_VERSION }}-x86_64-windows/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append shell: pwsh - name: "Config Artifact" uses: actions/download-artifact@v8 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@v6 - name: Build docker images run: | for dockerFile in Dockerfile*; do docker build -f $dockerFile . ; done tools-build: name: "goreleaser tools build" if: contains(github.event.pull_request.labels.*.name, 'tools') needs: [config] runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v6 - name: "Install Go" uses: actions/setup-go@v6 with: go-version-file: tools/go.mod - name: "Config Artifact" uses: actions/download-artifact@v8 with: name: config-artifact-${{ github.sha }} - name: "Move Config" run: mv config.toml pkg/config/config.toml - name: "Test goreleaser tools build" run: go tool -modfile=tools/go.mod goreleaser build --single-target --snapshot --skip=post-hooks --skip=validate golangci-latest: name: lint-latest (informational) needs: [config] runs-on: ubuntu-latest continue-on-error: true steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download config artifact uses: actions/download-artifact@v8 with: name: config-artifact-${{ github.sha }} path: pkg/config - name: Verify embedded config exists run: | test -f pkg/config/config.toml || { echo "missing pkg/config/config.toml"; ls -la pkg/config; exit 1; } - name: Setup Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Run golangci-lint@latest id: lint uses: golangci/golangci-lint-action@v9 with: version: latest only-new-issues: true continue-on-error: true - name: Report lint summary run: | if [ "${{ steps.lint.outcome }}" == "success" ]; then echo "✅ golangci-lint@latest passed." >> $GITHUB_STEP_SUMMARY else echo "⚠️ golangci-lint@latest failed (informational only)." >> $GITHUB_STEP_SUMMARY fi ================================================ FILE: .github/workflows/publish_release.yml ================================================ name: NPM Release on: workflow_dispatch: release: types: - published permissions: id-token: write contents: read packages: write jobs: npm_release: runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v6 - name: "Fetch unshallow repo" run: git fetch --prune --unshallow - name: Set up Node.js uses: actions/setup-node@v6 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 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 ================================================ FILE: .github/workflows/tag_to_draft_release.yml ================================================ name: Draft Release from Tag on: workflow_dispatch: push: tags: - 'v*' permissions: contents: read jobs: goreleaser: runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v6 - name: "Fetch unshallow repo" run: git fetch --prune --unshallow - name: "Install Go" uses: actions/setup-go@v6 with: go-version: '1.26.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@v7 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 }} ================================================ FILE: .gitignore ================================================ # Fastly binary **/fastly # But allow fastly main package !cmd/fastly RELEASE_CHANGELOG.md # Fastly package format files **/fastly.toml !pkg/commands/compute/testdata/build/rust/fastly.toml **/Cargo.toml !pkg/commands/compute/testdata/build/rust/Cargo.toml **/Cargo.lock !pkg/commands/compute/testdata/build/rust/Cargo.lock **/*.tar.gz !pkg/github/testdata/*.tar.gz !pkg/commands/compute/testdata/deploy/pkg/package.tar.gz **/bin **/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~* *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Ignore IDEs .idea # Ignore Vim # https://github.com/github/gitignore/blob/41ec05833ae00be887bab36fceaee63611e86189/Global/Vim.gitignore [._]*.s[a-v][a-z] [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Ignore OS files .DS_Store # Ignore binaries dist/ 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-*/ ================================================ FILE: .golangci.yml ================================================ 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$ ================================================ FILE: .goreleaser.yml ================================================ # 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=v{{ .Version }}" - -X "github.com/fastly/cli/pkg/revision.GitCommit={{ .ShortCommit }}" - -X "github.com/fastly/cli/pkg/revision.Environment=release" env: - CGO_ENABLED=0 id: macos goos: [darwin] goarch: [amd64, arm64] - <<: *build_defaults env: - CGO_ENABLED=0 id: linux goos: [linux] goarch: ["386", amd64, arm64] - <<: *build_defaults env: - CGO_ENABLED=0 id: windows goos: [windows] goarch: ["386", amd64, arm64] - <<: *build_defaults env: - CGO_ENABLED=0 id: generate-usage goos: [linux] goarch: [amd64] binary: 'fastly-usage' # we rename the binary to prevent an error caused by the earlier 'linux/amd64' step # which already creates a 'fastly' binary in '/usr/local/bin'. hooks: post: - cmd: "scripts/documentation.sh {{ .Path }}" # https://goreleaser.com/customization/archive/ archives: - id: nix ids: [macos, linux] <<: &archive_defaults name_template: "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" files: - none* wrap_in_directory: false formats: [tar.gz] - id: windows-tar ids: [windows] <<: *archive_defaults wrap_in_directory: false formats: [tar.gz] - id: windows-zip ids: [windows] <<: *archive_defaults wrap_in_directory: false formats: [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] repository: owner: fastly name: homebrew-tap skip_upload: auto description: A CLI for interacting with the Fastly platform homepage: https://github.com/fastly/cli directory: Formula custom_block: | head do url "https://github.com/fastly/cli.git" depends_on "go" end install: |- system "make" if build.head? bin.install "fastly" (bash_completion/"fastly.sh").write `#{bin}/fastly --completion-script-bash` (zsh_completion/"_fastly").write `#{bin}/fastly --completion-script-zsh` 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 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: version_template: "{{ .Tag }}-next" # https://goreleaser.com/customization/changelog/ changelog: 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 ================================================ FILE: .tmpl/create.go ================================================ 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 } ================================================ FILE: .tmpl/delete.go ================================================ 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 } ================================================ FILE: .tmpl/describe.go ================================================ 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) } } ================================================ FILE: .tmpl/doc.go ================================================ // Package ${CLI_PACKAGE} contains commands to <...>. package ${CLI_PACKAGE} ================================================ FILE: .tmpl/doc_parent.go ================================================ // Package ${CLI_CATEGORY} contains commands for <...>. package ${CLI_CATEGORY} ================================================ FILE: .tmpl/list.go ================================================ 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() } ================================================ FILE: .tmpl/root.go ================================================ 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") } ================================================ FILE: .tmpl/root_parent.go ================================================ 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") } ================================================ FILE: .tmpl/test.go ================================================ 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 } ================================================ FILE: .tmpl/update.go ================================================ 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 } ================================================ FILE: CHANGELOG.md ================================================ # CHANGELOG ## [Unreleased] ### Breaking: ### Bug Fixes: ### Enhancements: ### Dependencies: - build(deps): `github.com/bodgit/sevenzip` from 1.6.1 to 1.6.2 ([#1795](https://github.com/fastly/cli/pull/1795)) - build(deps): `github.com/minio/minlz` from 1.0.1 to 1.1.1 ([#1795](https://github.com/fastly/cli/pull/1795)) - build(deps): `github.com/nwaples/rardecode/v2` from 2.2.0 to 2.2.2 ([#1795](https://github.com/fastly/cli/pull/1795)) - build(deps): `go4.org` from 0.0.0-20230225012048-214862532bf5 to 0.0.0-20260112195520-a5071408f32f ([#1795](https://github.com/fastly/cli/pull/1795)) - build(deps): `golang.org/x/net` from 0.53.0 to 0.54.0 ([#1795](https://github.com/fastly/cli/pull/1795)) ## [v15.1.0](https://github.com/fastly/cli/releases/tag/v15.1.0) (2026-05-13) ### Bug Fixes: - fix(auth): honor deprecated `--profile`/`-o` when resolving the API token; an unknown profile name is now a hard error instead of a silent fallback to the default token ([#1792](https://github.com/fastly/cli/pull/1792)) - fix(text): send deprecation warnings to stderr instead of stdout ([#1782](https://github.com/fastly/cli/pull/1782)) ### Enhancements: - feat(compute): add file field support for setup.kv_stores bulk import ([#1784](https://github.com/fastly/cli/pull/1784)) - feat(compute): add support for cpp for compute ([#1773](https://github.com/fastly/cli/pull/1773)) ### Dependencies: - refactor(deps): migrate from `mholt/archiver/v3` to `mholt/archives` v0.1.5 ([#1787](https://github.com/fastly/cli/pull/1787)) - build(deps): `golang.org/x/sys` from 0.43.0 to 0.44.0 ([#1785](https://github.com/fastly/cli/pull/1785)) - build(deps): `golang.org/x/term` from 0.42.0 to 0.43.0 ([#1785](https://github.com/fastly/cli/pull/1785)) - build(deps): `golang.org/x/crypto` from 0.50.0 to 0.51.0 ([#1785](https://github.com/fastly/cli/pull/1785)) - build(deps): `golang.org/x/mod` from 0.35.0 to 0.36.0 ([#1785](https://github.com/fastly/cli/pull/1785)) - build(deps): `golang.org/x/text` from 0.36.0 to 0.37.0 ([#1785](https://github.com/fastly/cli/pull/1785)) ## [v15.0.0](https://github.com/fastly/cli/releases/tag/v15.0.0) (2026-05-08) ### Breaking: - breaking(ngwaf/workspace): change flag name to match API spec ([#1768](https://github.com/fastly/cli/pull/1768])) ### Bug Fixes: - fix(compute/deploy): remove compute trial activation code because trials no longer exist ([#1730](https://github.com/fastly/cli/pull/1730)) - fix(auth): SSO token expiration status now reflects the actual API token lifetime (~12 hours) instead of the internal JWT refresh token (~30 minutes), preventing spurious warnings and premature re-authentication [#1728](https://github.com/fastly/cli/pull/1728) - fix(argparser): skip ListVersions API call for numeric versions [#1774](https://github.com/fastly/cli/pull/1774) ### Enhancements: - feat(service/backend): add support for the `max_use` and `max_lifetime` parameters ([#1779](https://github.com/fastly/cli/pull/1779)) ### Dependencies: - build(deps): `golang.org/x/term` from 0.41.0 to 0.42.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `golang.org/x/crypto` from 0.49.0 to 0.50.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `golang.org/x/mod` from 0.34.0 to 0.35.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `golang.org/x/net` from 0.52.0 to 0.53.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `golang.org/x/text` from 0.35.0 to 0.36.0 ([#1726](https://github.com/fastly/cli/pull/1726)) - build(deps): `acifani/setup-tinygo` from 2 to 3 ([#1729](https://github.com/fastly/cli/pull/1729)) - build(deps): `github.com/mattn/go-isatty` from 0.0.21 to 0.0.22 ([#1735](https://github.com/fastly/cli/pull/1735)) - build(deps): `github.com/hashicorp/cap` from 0.12.0 to 0.13.0 ([#1771](https://github.com/fastly/cli/pull/1771)) - build(deps): `github.com/Masterminds/semver/v3` from 3.4.0 to 3.5.0 ([#1775](https://github.com/fastly/cli/pull/1775)) - build(deps): `github.com/fsnotify/fsnotify` from 1.9.0 to 1.10.1 ([#1775](https://github.com/fastly/cli/pull/1775)) - build(deps): `github.com/klauspost/compress` from 1.18.5 to 1.18.6 ([#1775](https://github.com/fastly/cli/pull/1775)) - build(deps): `github.com/fastly/go-fastly/v15` from 14.2.0 to 15.0.1([#1778](https://github.com/fastly/terraform-provider-fastly/pull/1778)) ## [v14.3.1](https://github.com/fastly/cli/releases/tag/v14.3.1) (2026-04-13) ### Bug Fixes: - fix(publish_release): add back perms for publishing to npm [#1724](https://github.com/fastly/cli/pull/1724) ## [v14.3.0](https://github.com/fastly/cli/releases/tag/v14.3.0) (2026-04-10) ### Bug Fixes: - fix(vcl/condition): `--comment` flag in `condition update` now correctly sets the comment instead of overwriting the statement [#1714](https://github.com/fastly/cli/pull/1714) - fix(manifest): `env_file` parsing no longer rejects values containing `=` characters (e.g. `KEY=val=ue`) [#1715](https://github.com/fastly/cli/pull/1715) ### Enhancements: - feat(auth): add `auth revoke` subcommand for revoking API tokens via `--current`, `--name`, `--token-value`, `--id`, or `--file` (bulk) [#1717](https://github.com/fastly/cli/pull/1717) - feat(service/logging/debug): add support for logging endpoint error streaming via the `service logging debug` subcommand [#1721](https://github.com/fastly/cli/pull/1721) - feat(stats): accept `--json` / `-j` as an alias for `--format=json` on all stats and help subcommands, matching the flag style used by the rest of the CLI [#1719](https://github.com/fastly/cli/pull/1719) ### Dependencies: - build(deps): `github.com/andybalholm/brotli` from 1.2.0 to 1.2.1 ([#1716](https://github.com/fastly/cli/pull/1716)) - build(deps): `github.com/go-jose/go-jose/v3` from 3.0.4 to 3.0.5 ([#1716](https://github.com/fastly/cli/pull/1716)) - build(deps): `github.com/mattn/go-runewidth` from 0.0.21 to 0.0.22 ([#1716](https://github.com/fastly/cli/pull/1716)) - build(deps): `github.com/mattn/go-isatty` from 0.0.20 to 0.0.21 ([#1720](https://github.com/fastly/cli/pull/1720)) - build(deps): `golang.org/x/sys` from 0.42.0 to 0.43.0 ([#1720](https://github.com/fastly/cli/pull/1720)) - build(deps): `github.com/coreos/go-oidc/v3` from 3.17.0 to 3.18.0 ([#1720](https://github.com/fastly/cli/pull/1720)) - build(deps): `github.com/mattn/go-runewidth` from 0.0.22 to 0.0.23 ([#1720](https://github.com/fastly/cli/pull/1720)) - build(deps): `github.com/fastly/go-fastly/v14` from 13.1.2 to 14.2.0 ([#1722](https://github.com/fastly/cli/pull/1722)) ## [v14.2.0](https://github.com/fastly/cli/releases/tag/v14.2.0) (2026-03-24) ### Bug Fixes: - fix(auth): `fastly profile`, `fastly sso` and `fastly auth-token` commands now correctly respect the `--quiet` flag [#1710](https://github.com/fastly/cli/pull/1710) ### Enhancements: - feat(vcl/snippet): add support for the '--content' flag, allowing for the raw output of VCL. [#1706](https://github.com/fastly/cli/pull/1706) ### Dependencies: - build(deps): `github.com/fatih/color` from 1.18.0 to 1.19.0 ([#1707](https://github.com/fastly/cli/pull/1707)) - build(deps): `github.com/klauspost/compress` from 1.18.4 to 1.18.5 ([#1707](https://github.com/fastly/cli/pull/1707)) ## [v14.1.1](https://github.com/fastly/cli/releases/tag/v14.1.1) (2026-03-18) ### Bug Fixes: - fix(compute): `compute pack`, `compute validate`, and `install` no longer require authentication. [#1701](https://github.com/fastly/cli/pull/1701) ## [v14.1.0](https://github.com/fastly/cli/releases/tag/v14.1.0) (2026-03-17) ### Bug Fixes: - fix(stats): `stats historical` now returns write errors instead of silently swallowing them [#1678](https://github.com/fastly/cli/pull/1678) ### Deprecations: - deprecated(auth): `fastly profile`, `fastly sso`, and `fastly auth-token` command trees are deprecated and will be removed in a future release. Use `fastly auth` subcommands instead. [#1676](https://github.com/fastly/cli/pull/1676) - deprecated(auth): `--profile` and `--enable-sso` global flags are deprecated. Use `--token ` to select a stored auth token by name, or `fastly auth login --sso --token ` for SSO. [#1676](https://github.com/fastly/cli/pull/1676) ### Enhancements: - feat(auth): add `auth token` subcommand to output the active API token for use in shell substitutions (e.g. `$(fastly auth token)`). - feat(auth): `auth login --sso` now requires `--token ` to explicitly name the stored token. This prevents accidentally overwriting tokens in multi-user SSO workflows. [#1676](https://github.com/fastly/cli/pull/1676) - feat(auth): add `FASTLY_DISABLE_AUTH_COMMAND` env var to hide the `fastly auth` command tree from help, completions, and invocation. [#1676](https://github.com/fastly/cli/pull/1676) - feat(auth): when `FASTLY_DISABLE_AUTH_COMMAND` is set, the `--token`/`-t` global flag is also disabled. Use `FASTLY_API_TOKEN` or stored config tokens instead. [#1676](https://github.com/fastly/cli/pull/1676) - feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) - feat(apisecurity/discoveredoperations): add support for 'list' and 'update' support for 'API discovery'. [#1689](https://github.com/fastly/cli/pull/1689) - feat(apisecurity/operations): add CRUD support for 'API Inventory' operations. [#1689](https://github.com/fastly/cli/pull/1689) - feat(apisecurity/tags): add API Security Operations tag support ([#1688](https://github.com/fastly/cli/pull/1688)) - feat(service/version): add support for service validation. [#1695](https://github.com/fastly/cli/pull/1695) - feat(compute/build): Block version 1.93.0 of Rust to avoid a wasm32-wasip2 bug. ([#1653](https://github.com/fastly/cli/pull/1653)) - feat(service/vcl): escape control characters when displaying VCL content for cleaner terminal output ([#1637](https://github.com/fastly/cli/pull/1637)) ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) - build(deps): `actions/upload-artifact` from 6 to 7 ([#1675](https://github.com/fastly/cli/pull/1675)) - build(deps): `actions/download-artifact` from 7 to 8 ([#1675](https://github.com/fastly/cli/pull/1675)) - build(deps): `golang.org/x/sys` from 0.41.0 to 0.42.0 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `github.com/mattn/go-runewidth` from 0.0.20 to 0.0.21 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `github.com/pierrec/lz4/v4` from 4.1.25 to 4.1.26 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `golang.org/x/oauth2` from 0.35.0 to 0.36.0 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `golang.org/x/sync` from 0.19.0 to 0.20.0 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `github.com/fastly/go-fastly/v13` from 13.0.0 to 13.0.1 ([#1679](https://github.com/fastly/cli/pull/1679)) - build(deps): `golang.org/x/term` from 0.40.0 to 0.41.0 ([#1687](https://github.com/fastly/cli/pull/1687)) - build(deps): `golang.org/x/mod` from 0.33.0 to 0.34.0 ([#1687](https://github.com/fastly/cli/pull/1687)) - build(deps): `golang.org/x/text` from 0.34.0 to 0.35.0 ([#1687](https://github.com/fastly/cli/pull/1687)) - build(deps): `github.com/fastly/go-fastly/v13` from 13.0.1 to 13.1.0 ([#1687](https://github.com/fastly/cli/pull/1687)) - build(deps): `golang.org/x/crypto` from 0.48.0 to 0.49.0 ([#1693](https://github.com/fastly/cli/pull/1693)) - build(deps): `golang.org/x/net` from 0.51.0 to 0.52.0 ([#1693](https://github.com/fastly/cli/pull/1693)) - build(deps): `github.com/fastly/go-fastly/v13` from 13.1.0 to 13.1.1 ([#1693](https://github.com/fastly/cli/pull/1693)) - build(deps): `github.com/fastly/go-fastly/v13` from 13.1.1 to 13.1.2 ([#1696](https://github.com/fastly/cli/pull/1696)) - build(deps): `actions/create-github-app-token` from 2 to 3 ([#1692](https://github.com/fastly/cli/pull/1692)) ## [v14.0.4](https://github.com/fastly/cli/releases/tag/v14.0.4) (2026-02-26) ### Documentation: - fix(changelog): change code blocks to be all on one line [#1670](https://github.com/fastly/cli/pull/1670) ## [v14.0.3](https://github.com/fastly/cli/releases/tag/v14.0.3) (2026-02-25) ### Bug Fixes: - fix(npm): add contents write perms [#1667](https://github.com/fastly/cli/pull/1667) ## [v14.0.2](https://github.com/fastly/cli/releases/tag/v14.0.2) (2026-02-25) ### Bug Fixes: - fix(npm): add write perms [#1665](https://github.com/fastly/cli/pull/1665) ## [v14.0.1](https://github.com/fastly/cli/releases/tag/v14.0.1) (2026-02-25) ### Bug Fixes: - fix(npm): Include repository info in package.json of subpackages required for trusted publishing [#1663](https://github.com/fastly/cli/pull/1663) ## [v14.0.0](https://github.com/fastly/cli/releases/tag/v14.0.0) (2026-02-25) ## BREAKING CHANGES This release of the Fastly CLI includes a significant reorganization of the commands which are used to manage the configuration of Fastly services (both Delivery and Compute services). Specifically, each of the command families listed below have been changed from `fastly create/delete/describe/list/update` to `fastly service create/delete/describe/list/update`. For nearly all of these command families, the previous commands are still available but are not listed in the `fastly help` output. In addition, invocations of the previous commands will generate a deprecation message, which includes the new command that should be used instead. The `fastly domain` family of commands are the lone exception; those commands exist in both the old and new forms, but the top-level commands are used to manage 'versionless' domains (a new feature of the Fastly platform, and those commands were previously named `fastly domain-v1 create/delete/describe/list/update`), while the service-level commands are used to manage 'classic' domains. As a result, you will need to update any scripts or workflows which used the `fastly domain create/delete/describe/list/update` commands to use the `fastly service domain create/delete/describe/list/update` commands instead. The command families which have been reorganized and are available in both the old and new forms are: * acl * aclentry * alert * backend * dictionary * dictionary-entry * healthcheck * imageoptimizerdefaults * logging * purge * rate-limit * resource-link * service-auth * service-version * vcl ### Breaking: - breaking(domain) - service-version oriented `domain` commands have been moved under the `service domain` command. Versionless `domain-v1` commands have been moved to the `domain` command ([#1615](https://github.com/fastly/cli/pull/1615)) ### Deprecations: - deprecated(auth): `fastly profile`, `fastly sso`, and `fastly auth-token` command trees are deprecated and will be removed in a future release. Use `fastly auth` subcommands instead. - deprecated(auth): `--profile` and `--enable-sso` global flags are deprecated. Use `--token ` to select a stored auth token by name, or `fastly auth login --sso --token ` for SSO. ### Enhancements: - feat(auth): `auth login --sso` now requires `--token ` to explicitly name the stored token. This prevents accidentally overwriting tokens in multi-user SSO workflows. - feat(auth): add `FASTLY_DISABLE_AUTH_COMMAND` env var to hide the `fastly auth` command tree from help, completions, and invocation. - feat(auth): when `FASTLY_DISABLE_AUTH_COMMAND` is set, the `--token`/`-t` global flag is also disabled. Use `FASTLY_API_TOKEN` or stored config tokens instead. - feat(ngwaf/rules): Upgrade go-fastly to v13.0.0 and allow ngwaf rules to accept multival conditions ([#1655](https://github.com/fastly/cli/pull/1655)) - feat(rust): Allow testing with prerelease Rust versions ([#1604](https://github.com/fastly/cli/pull/1604)) - feat(compute/hashfiles): remove hashsum subcommand ([#1608](https://github.com/fastly/cli/pull/1608)) - feat(ngwaf/rules): add support for CRUD operations for NGWAF rules ([#1605](https://github.com/fastly/cli/pull/1605)) - feat(compute/deploy): added the `--no-default-domain` flag to allow for the skipping of automatic domain creation when deploying a Compute service([#1610](https://github.com/fastly/cli/pull/1610)) - refactor(argparser/flags.go): add flag conversion utilities for converting string flags to bools and checking ascending and descending flags ([#1611](https://github.com/fastly/cli/pull/1611)) - feat(service/purge): Add 'service purge' command as replacement for 'purge', with an unlisted and deprecated alias of 'purge'. ([#1612](https://github.com/fastly/cli/pull/1612)) - feat(service/version): Add 'service version ...' commands as replacements for 'service-version ...', with unlisted and deprecated aliases of 'service-version ...'. ([#1614](https://github.com/fastly/cli/pull/1614)) - feat(service/vcl): moved the `vcl` command under the `service` command, with an unlisted and deprecated alias of `vcl` ([#1616](https://github.com/fastly/cli/pull/1616)) - feat(service/healthcheck): moved the `healthcheck` command under the `service` command, with an unlisted and deprecated alias of `healthcheck` ([#1619](https://github.com/fastly/cli/pull/1619)) - feat(service/backend): moved the `backend` command under the `service` command, with an unlisted and deprecated alias of `backend` ([#1621](https://github.com/fastly/cli/pull/1621)) - feat(service/acl): moved the `acl` and `aclentry` commands under the `service` command, with unlisted and deprecated aliases of `acl` and `aclentry` ([#1621](https://github.com/fastly/cli/pull/1624)) - feat(version): If the latest version is at least one major version higher than the current version, provide links to the release notes for the major version(s) so the user can review them before upgrading. ([#1623](https://github.com/fastly/cli/pull/1623)) - feat(service/imageoptimizerdefaults): moved the `imageoptimizerdefaults` commands under the `service` command, with an unlisted and deprecated alias of `imageoptimizerdefaults` ([#1627](https://github.com/fastly/cli/pull/1627)) - feat(service/alert): moved the `alerts` command to the `service alert` command, with an unlisted and deprecated alias of `alerts` ([#1616](https://github.com/fastly/cli/pull/1626)) - feat(service/dictionary-entry): moved the `dictionary-entry` commands under the `service` command, with an unlisted and deprecated alias of `dictionary-entry` ([#1628](https://github.com/fastly/cli/pull/1628)) - feat(service/dictionary): moved the `dictionary` command under the `service` command, with an unlisted and deprecated alias of `dictionary` ([#1621](https://github.com/fastly/cli/pull/1630)) - feat(service/ratelimit): moved the `rate-limit` commands under the `service` command, with an unlisted and deprecated alias of `rate-limit` ([#1632](https://github.com/fastly/cli/pull/1632)) - feat(compute/build): Remove Rust version restriction, allowing 1.93.0 and later versions to be used. ([#1633](https://github.com/fastly/cli/pull/1633)) - feat(service/resourcelink): moved the `resource-link` commands under the `service` command, with an unlisted and deprecated alias of `resource-link` ([#1635](https://github.com/fastly/cli/pull/1635)) - feat(service/logging): moved the `logging` commands under the `service` command, with an unlisted and deprecated alias of `logging` ([#1642](https://github.com/fastly/cli/pull/1642)) - feat(service/auth): moved the `service-auth` commands under the `service` command and renamed to `auth`, with an unlisted and deprecated alias of `service-auth` ([#1643](https://github.com/fastly/cli/pull/1643)) - feat(compute/build): improved error messaging for JavaScript builds with pre-flight toolchain verification including Bun runtime support ([#1640](https://github.com/fastly/cli/pull/1640)) ### Bug fixes: - fix(docker): Use base image toolchain instead of reinstalling stable, which could pull in an unvalidated Rust version. - fix(compute/serve): ensure hostname has a port number when building pushpin routes ([#1631](https://github.com/fastly/cli/pull/1631)) - fix(manifest): Correct setup.Defined to include checks for ObjectStores and SecretStores ([#1639](https://github.com/fastly/cli/pull/1639)) ### Dependencies: - build(deps): `golang` from 1.24 to 1.25 ([#1651](https://github.com/fastly/cli/pull/1651)) - build(deps): `actions/upload-artifact` from 5 to 6 ([#1603](https://github.com/fastly/cli/pull/1603)) - build(deps): `actions/download-artifact` from 6 to 7 ([#1603](https://github.com/fastly/cli/pull/1603)) - build(deps): `golang.org/x/term` from 0.37.0 to 0.38.0 ([#1602](https://github.com/fastly/cli/pull/1602)) - build(deps): `golang.org/x/crypto` from 0.45.0 to 0.46.0 ([#1602](https://github.com/fastly/cli/pull/1602)) - build(deps): `golang.org/x/mod` from 0.30.0 to 0.31.0 ([#1602](https://github.com/fastly/cli/pull/1602)) - build(deps): `golang.org/x/net` from 0.47.0 to 0.48.0 ([#1602](https://github.com/fastly/cli/pull/1602)) - build(deps): `golang.org/x/text` from 0.31.0 to 0.32.0 ([#1602](https://github.com/fastly/cli/pull/1602)) - build(deps): `github.com/pierrec/lz4/v4` from 4.1.22 to 4.1.23 ([#1606](https://github.com/fastly/cli/pull/1606)) - build(deps): `github.com/google/go-querystring` from 1.1.0 to 1.2.0 ([#1607](https://github.com/fastly/cli/pull/1607)) - build(deps): `golang.org/x/sys` from 0.39.0 to 0.40.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `golang.org/x/term` from 0.38.0 to 0.39.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `golang.org/x/crypto` from 0.46.0 to 0.47.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `golang.org/x/mod` from 0.31.0 to 0.32.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `golang.org/x/net` from 0.48.0 to 0.49.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `golang.org/x/text` from 0.32.0 to 0.33.0 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `github.com/fastly/go-fastly/v13` from 12.1.0 to 12.1.1 ([#1613](https://github.com/fastly/cli/pull/1613)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.3.0 to 2.3.1 ([#1625](https://github.com/fastly/cli/pull/1625)) - build(deps): `github.com/klauspost/compress` from 1.18.2 to 1.18.3 ([#1625](https://github.com/fastly/cli/pull/1625)) - build(deps): `github.com/pierrec/lz4/v4` from 4.1.23 to 4.1.25 ([#1625](https://github.com/fastly/cli/pull/1625)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.3.1 to 2.4.0 ([#1634](https://github.com/fastly/cli/pull/1634)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.4.0 to 2.5.0 ([#1647](https://github.com/fastly/cli/pull/1647)) - build(deps): `golang.org/x/sys` from 0.40.0 to 0.41.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/term` from 0.39.0 to 0.40.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/crypto` from 0.47.0 to 0.48.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/mod` from 0.32.0 to 0.33.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.5.0 to 2.6.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `github.com/klauspost/compress` from 1.18.3 to 1.18.4 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/net` from 0.49.0 to 0.50.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/oauth2` from 0.34.0 to 0.35.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `golang.org/x/text` from 0.33.0 to 0.34.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.6.0 to 2.7.0 ([#1657](https://github.com/fastly/cli/pull/1657)) - build(deps): `golang.org/x/text` from 0.33.0 to 0.34.0 ([#1652](https://github.com/fastly/cli/pull/1652)) - build(deps): `github.com/mattn/go-runewidth` from 0.0.19 to 0.0.20 ([#1659](https://github.com/fastly/cli/pull/1659)) - build(deps): `goreleaser/goreleaser-action` from 6 to 7 ([#1660](https://github.com/fastly/cli/pull/1660)) ## [v13.3.0](https://github.com/fastly/cli/releases/tag/v13.3.0) (2025-12-11) ### Enhancements: - feat(toml/rust): add support for Rust 1.9.2 ([#1599](https://github.com/fastly/cli/pull/1599)) ## [v13.2.0](https://github.com/fastly/cli/releases/tag/v13.2.0) (2025-12-10) ### Enhancements: - feat(commands/ngwaf/workspaces): add support for update operation for NGWAF workspaces ([#1578](https://github.com/fastly/cli/pull/1578)) - feat(commands/ngwaf/lists): add support for CRUD operations for NGWAF Lists at account and workspace levels ([#1582](https://github.com/fastly/cli/pull/1582)) - feat(commands/ngwaf/workspaces/alerts): add support for operations for NGWAF alerts ([#1589](https://github.com/fastly/cli/pull/1589)) - feat(commands/ngwaf/customsignals): add support for CRUD operations for NGWAF Custom Signals ([#1592](https://github.com/fastly/cli/pull/1592)) - feat(commands/ngwaf/threshold): add support for CRUD operations for NGWAF Thresholds ([#1595](https://github.com/fastly/cli/pull/1595)) ### Bug fixes: - fix(commands/ngwaf/virtualpatch): ensured a check was in place for the 'update' command that disallowed the --json and --verbose flag to be ran at the same time. ([#1596](https://github.com/fastly/cli/pull/1596)) - fix(commands/ngwaf/redaction): ensured a check was in place for the 'create' and 'update' commands that disallowed the --json and --verbose flag to be ran at the same time. ([#1596](https://github.com/fastly/cli/pull/1596)) ### Dependencies: - build(deps): `golang.org/x/crypto` from 0.43.0 to 0.45.0 ([#1584](https://github.com/fastly/cli/pull/1584)) - build(deps): `actions/checkout` from 5 to 6 ([#1587](https://github.com/fastly/cli/pull/1587)) - build(deps): `golang.org/x/mod` from 0.29.0 to 0.30.0 ([#1588](https://github.com/fastly/cli/pull/1588)) - build(deps): `github.com/coreos/go-oidc/v3` from 3.16.0 to 3.17.0 ([#1588](https://github.com/fastly/cli/pull/1588)) - build(deps): `github.com/klauspost/compress` from 1.18.1 to 1.18.2 ([#1593](https://github.com/fastly/cli/pull/1593)) - build(deps): `golang.org/x/sys` from 0.38.0 to 0.39.0 ([#1594](https://github.com/fastly/cli/pull/1594)) - build(deps): `github.com/hashicorp/cap` from 0.11.0 to 0.12.0 ([#1594](https://github.com/fastly/cli/pull/1594)) - build(deps): `golang.org/x/oauth2` from 0.33.0 to 0.34.0 ([#1594](https://github.com/fastly/cli/pull/1594)) - build(deps): `golang.org/x/sync` from 0.18.0 to 0.19.0 ([#1594](https://github.com/fastly/cli/pull/1594)) ## [v13.1.0](https://github.com/fastly/cli/releases/tag/v13.1.0) (2025-11-12) ### Enhancements: - feat(service-version): Add JSON support to service-version clone command ([#1550](https://github.com/fastly/cli/pull/1550)) - feat(compute/build): Allow usage of Rust 1.91.1 and later patch releases ([#1576](https://github.com/fastly/cli/pull/1576)) - feat(commands/ngwaf/workspaces): add support for CRUD operations for NGWAF workspaces ([#1570](https://github.com/fastly/cli/pull/1570)) - feat(commands/ngwaf/virtualpatch): add support for CRUD operations for NGWAF virtual patches ([#1579](https://github.com/fastly/cli/pull/1579)) - feat(commands/ngwaf/redaction): add support for CRUD operations for NGWAF redactions ([#1581](https://github.com/fastly/cli/pull/1581)) ### Dependencies: - build(deps): `golangci/golangci-lint-action` from 8 to 9 ([#1575](https://github.com/fastly/cli/pull/1575)) - build(deps): `golang.org/x/sys` from 0.37.0 to 0.38.0 ([#1574](https://github.com/fastly/cli/pull/1574)) - build(deps): `golang.org/x/oauth2` from 0.32.0 to 0.33.0 ([#1574](https://github.com/fastly/cli/pull/1574)) - build(deps): `golang.org/x/sync` from 0.17.0 to 0.18.0 ([#1574](https://github.com/fastly/cli/pull/1574)) ## [v13.0.0](https://github.com/fastly/cli/releases/tag/v13.0.0) (2025-10-30) ### Breaking: - breaking(tls-custom): correct 'tls-custom activation enable' command parameters to reflect expected input from API ([#1562](https://github.com/fastly/cli/pull/1562)) - breaking(compute/build): Block version 1.91.0 of Rust as it produces broken WASM packages. ([#1571](https://github.com/fastly/cli/pull/1571)) ### Enhancements: - feat(compute/serve): set sig_iss and sig_key to allow client code to test Grip-Sig signing ([#1569](https://github.com/fastly/cli/pull/1569)) - build(dockerfile-rust): add wasm tools to the rust docker container ([#1552](https://github.com/fastly/cli/pull/1552)) - feat(env): add detection for workspace ID ([#1560](https://github.com/fastly/cli/pull/1560)) ### Bug fixes: - fix(compute): clarify fastly.toml error message when file not found ([#1556](https://github.com/fastly/cli/pull/1556)) - fix(purge/key): ensures that single-key purges will work even if the key contains URL-unsafe characters ([#1566](https://github.com/fastly/cli/pull/1566)) ### Dependencies: - build(deps): `github.com/hashicorp/cap` from 0.10.0 to 0.11.0 ([#1546](https://github.com/fastly/cli/pull/1546)) - build(deps): `github.com/coreos/go-oidc/v3` from 3.15.0 to 3.16.0 ([#1546](https://github.com/fastly/cli/pull/1546)) - build(deps): `stefanzweifel/git-auto-commit-action` from 6 to 7 ([#1549](https://github.com/fastly/cli/pull/1549)) - build(deps): `golang.org/x/sys` from 0.36.0 to 0.37.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/term` from 0.35.0 to 0.36.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/crypto` from 0.42.0 to 0.43.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/mod` from 0.28.0 to 0.29.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/net` from 0.44.0 to 0.45.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/oauth2` from 0.31.0 to 0.32.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `golang.org/x/text` from 0.29.0 to 0.30.0 ([#1548](https://github.com/fastly/cli/pull/1548)) - build(deps): `actions/setup-node` from 5 to 6 ([#1559](https://github.com/fastly/cli/pull/1559)) - build(deps): `actions/download-artifact` from 4 to 5 ([#1559](https://github.com/fastly/cli/pull/1559)) - build(deps): `github.com/klauspost/compress` from 1.18.0 to 1.18.1 ([#1558](https://github.com/fastly/cli/pull/1558)) - build(deps): `golang.org/x/net` from 0.45.0 to 0.46.0 ([#1558](https://github.com/fastly/cli/pull/1558)) - build(deps): `github.com/clipperhouse/uax29/v2` from 2.2.0 to 2.3.0 ([#1564](https://github.com/fastly/cli/pull/1564)) - build(deps): `actions/upload-artifact` from 4 to 5 ([#1565](https://github.com/fastly/cli/pull/1565)) - build(deps): `actions/download-artifact` from 5 to 6 ([#1565](https://github.com/fastly/cli/pull/1565)) ## [v12.1.0](https://github.com/fastly/cli/releases/tag/v12.1.0) (2025-09-30) ### Breaking: ### Enhancements: - feat(manifest): Enable loading Secret Store configuration through environment variables ([#1540](https://github.com/fastly/cli/pull/1540)) - feat(products): Add enable/disable support for API Discovery ([#1543](https://github.com/fastly/cli/pull/1543)) ### Bug fixes: ### Dependencies: - build(deps): `golang.org/x/net` from 0.43.0 to 0.44.0 ([#1538](https://github.com/fastly/cli/pull/1538)) - build(deps): `github.com/fastly/go-fastly/v11` from 11.3.1 to 12.0.0 ([#1541](https://github.com/fastly/cli/pull/1541)) - build(deps): `github.com/mattn/go-runewidth` from 0.0.16 to 0.0.19 ([#1542](https://github.com/fastly/cli/pull/1542)) - build(deps): `github.com/fastly/go-fastly/v11` from 11.3.1 to 12.0.0 ([#1541](https://github.com/fastly/cli/pull/1541)) ## [v12.0.0](https://github.com/fastly/cli/releases/tag/v12.0.0) (2025-09-10) ### Breaking: - breaking(kvstoreentry): The 'describe' command now returns only key attributes (ie: generation, metadata) instead of a given key's value ([#1529](https://github.com/fastly/cli/pull/1529)) ### Enhancements: - feat(logging): Add support for 'CompressionCodec' and 'GzipLevel' attribute to the HTTPS endpoint. - feat(kvstoreentry): Add support for the 'prefix' parameter for List operations ([#1526](https://github.com/fastly/cli/pull/1526)) - feat(kvstoreentry): Add support for the add, append, prepend, metadata, if_generation_match, and background_fetch 'create' command operations ([#1529](https://github.com/fastly/cli/pull/1529)) - feat(kvstoreentry): Add support for the if_generation_match and metadata 'describe' command operations ([#1529](https://github.com/fastly/cli/pull/1529)) - feat(kvstoreentry): Add support for the if_generation_match and force 'delete' command operations ([#1529](https://github.com/fastly/cli/pull/1529)) - feat(kvstoreentry): Add the 'get' command operation which obtains the value of the item ([#1529](https://github.com/fastly/cli/pull/1529)) - feat(logging/https): Add support for 'Period' attribute. ([#1537](https://github.com/fastly/cli/pull/1537)) ### Bug fixes: - fix(manifest): Ensure pushpin section is persisted during manifest file update ([#1535](https://github.com/fastly/cli/pull/1535)) ### Dependencies: - build(deps): `github.com/ulikunitz/xz` from 0.5.12 to 0.5.13 ([#1524](https://github.com/fastly/cli/pull/1524)) - build(deps): `github.com/stretchr/testify` from 1.10.0 to 1.11.0 ([#1527](https://github.com/fastly/cli/pull/1527)) - build(deps): `github.com/ulikunitz/xz` from 0.5.13 to 0.5.14 ([#1528](https://github.com/fastly/cli/pull/1528)) - build(deps): `github.com/stretchr/testify` from 1.11.0 to 1.11.1 ([#1530](https://github.com/fastly/cli/pull/1530)) - build(deps): `github.com/ulikunitz/xz` from 0.5.14 to 0.5.15 ([#1530](https://github.com/fastly/cli/pull/1530)) - build(deps): `actions/setup-node` from 4 to 5 ([#1534](https://github.com/fastly/cli/pull/1534)) - build(deps): `actions/setup-go` from 5 to 6 ([#1534](https://github.com/fastly/cli/pull/1534)) - build(deps): `golang.org/x/sys` from 0.35.0 to 0.36.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/term` from 0.34.0 to 0.35.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `github.com/fastly/go-fastly/v11` from 11.3.0 to 11.3.1 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/crypto` from 0.41.0 to 0.42.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/mod` from 0.27.0 to 0.28.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/oauth2` from 0.30.0 to 0.31.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/sync` from 0.16.0 to 0.17.0 ([#1533](https://github.com/fastly/cli/pull/1533)) - build(deps): `golang.org/x/text` from 0.28.0 to 0.29.0 ([#1533](https://github.com/fastly/cli/pull/1533)) ## [v11.5.0](https://github.com/fastly/cli/releases/tag/v11.5.0) (2025-08-20) ### Enhancements: - feat(vcl): Allow showing of generated VCL for a service version [#1498](https://github.com/fastly/cli/pull/1498) - feat(compute/serve): Add experimental "enable Pushpin" mode ([#1509](https://github.com/fastly/cli/pull/1509), [#1520](https://github.com/fastly/cli/pull/1520)) - feat(object-storage): improve access-keys list output ([#1513](https://github.com/fastly/cli/pull/1513)) - refactor(domainv1,tools): use updated go-fastly domainmanagement imports and types ([#1517](https://github.com/fastly/cli/pull/1517)) - feat(imageoptimizerdefaults): Support for retrieving and updating Image Optimizer defaults for a given VCL service ([#1518](https://github.com/fastly/cli/pull/1518)) ### Dependencies: - build(deps): `github.com/fastly/go-fastly/v11` from 10 to 11 ([#1514](https://github.com/fastly/cli/pull/1514)) - build(deps): `golang.org/x/sys` from 0.33.0 to 0.34.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/term` from 0.32.0 to 0.33.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/crypto` from 0.39.0 to 0.40.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/mod` from 0.25.0 to 0.26.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/net` from 0.41.0 to 0.42.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/sync` from 0.15.0 to 0.16.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `golang.org/x/text` from 0.26.0 to 0.27.0 ([#1508](https://github.com/fastly/cli/pull/1508)) - build(deps): `github.com/coreos/go-oidc/v3` from 3.14.1 to 3.15.0 ([#1510](https://github.com/fastly/cli/pull/1510)) - build(deps): `golang.org/x/sys` from 0.34.0 to 0.35.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `golang.org/x/term` from 0.33.0 to 0.34.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `github.com/hashicorp/cap` from 0.9.0 to 0.10.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `golang.org/x/crypto` from 0.40.0 to 0.41.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `golang.org/x/mod` from 0.26.0 to 0.27.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `golang.org/x/net` from 0.42.0 to 0.43.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `golang.org/x/text` from 0.27.0 to 0.28.0 ([#1516](https://github.com/fastly/cli/pull/1516)) - build(deps): `actions/checkout` from 4 to 5 ([#1515](https://github.com/fastly/cli/pull/1515)) - build(deps): `actions/download-artifact` from 4 to 5 ([#1515](https://github.com/fastly/cli/pull/1515)) ## [v11.4.0](https://github.com/fastly/cli/releases/tag/v11.4.0) (2025-07-09) ### Enhancements: - feat(env): Add environment variable for extending the UserAgent string. ([#1502](https://github.com/fastly/cli/pull/1502)) ### Bug fixes: - fix(sso): Ensure that OPTIONS requests sent by browsers do not break SSO authentication. ([#1496](https://github.com/fastly/cli/pull/1496)) ### Dependencies: - build(deps): `github.com/fastly/go-fastly/v10` from 10.3.0 to 10.4.0 ([#1499](https://github.com/fastly/cli/pull/1499)) - build(deps): `stefanzweifel/git-auto-commit-action` from 5 to 6 ([#1497](https://github.com/fastly/cli/pull/1497)) - build(deps): `github.com/fastly/go-fastly/v10` from 10.4.0 to 10.5.0 ([#1501](https://github.com/fastly/cli/pull/1501)) - build(deps): `github.com/andybalholm/brotli` from 1.1.1 to 1.2.0 ([#1501](https://github.com/fastly/cli/pull/1501)) - build(deps): `github.com/Masterminds/semver/v3` from 3.3.1 to 3.4.0 ([#1503](https://github.com/fastly/cli/pull/1503)) - build(deps): `github.com/fastly/go-fastly/v10` from 10.5.0 to 10.5.1 ([#1504](https://github.com/fastly/cli/pull/1504)) ## [v11.3.0](https://github.com/fastly/cli/releases/tag/v11.3.0) (2025-06-11) ### Enhancements: - feat(config-store): Allow for dynamic limits on Config Store entry lengths ([#1485](https://github.com/fastly/cli/pull/1485)) - feat(backend): Add support for 'prefer IPv6' attribute. ([#1487](https://github.com/fastly/cli/pull/1487)) - feat(tools/domain): add `suggest` and `status` domain tools endpoints ([#1482](https://github.com/fastly/cli/pull/1482)) - feat(logging): Add support for 'processing region' attribute. ([#1491](https://github.com/fastly/cli/pull/1491)) - feat(domains): add `description` to `domainv1` endpoints ([#1483](https://github.com/fastly/cli/pull/1483)) ### Bug fixes: - fix(sso): Don't display the token after authentication. ([#1490](https://github.com/fastly/cli/pull/1490)) - fix(service-version): Stop hiding the 'stage' and 'unstage' commands. ([#1492](https://github.com/fastly/cli/pull/1492)) ### Dependencies: - build(deps): `github.com/fastly/go-fastly/v10` from 10.0.1 to 10.1.0 ([#1476](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)) - build(deps): `github.com/fastly/go-fastly/v10` from 10.2.0 to 10.3.0 ([#1488](https://github.com/fastly/cli/pull/1488)) - build(deps): `golang.org/x/mod` from 0.24.0 to 0.25.0 ([#1488](https://github.com/fastly/cli/pull/1488)) - build(deps): `golang.org/x/sync` from 0.14.0 to 0.15.0 ([#1488](https://github.com/fastly/cli/pull/1488)) - build(deps): `golang.org/x/text` from 0.25.0 to 0.26.0 ([#1488](https://github.com/fastly/cli/pull/1488)) - build(deps): `golang.org/x/crypto` from 0.38.0 to 0.39.0 ([#1489](https://github.com/fastly/cli/pull/1489)) - build(deps): `golang.org/x/net` from 0.40.0 to 0.41.0 ([#1489](https://github.com/fastly/cli/pull/1489)) ## [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/533) - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.27.2...v0.28.0) **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) **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) ## [v0.27.2](https://github.com/fastly/cli/releases/tag/v0.27.2) (2021-04-21) [Full Changelog](https://github.com/fastly/cli/compare/v0.27.1...v0.27.2) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.27.0...v0.27.1) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.26.3...v0.27.0) **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) **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) ## [v0.26.3](https://github.com/fastly/cli/releases/tag/v0.26.3) (2021-03-26) [Full Changelog](https://github.com/fastly/cli/compare/v0.26.2...v0.26.3) **Enhancements:** - 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) ## [v0.26.2](https://github.com/fastly/cli/releases/tag/v0.26.2) (2021-03-22) [Full Changelog](https://github.com/fastly/cli/compare/v0.26.1...v0.26.2) **Enhancements:** - 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) ## [v0.26.1](https://github.com/fastly/cli/releases/tag/v0.26.1) (2021-03-19) [Full Changelog](https://github.com/fastly/cli/compare/v0.26.0...v0.26.1) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.25.2...v0.26.0) **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) **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) ## [v0.25.2](https://github.com/fastly/cli/releases/tag/v0.25.2) (2021-03-16) [Full Changelog](https://github.com/fastly/cli/compare/v0.25.1...v0.25.2) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.25.0...v0.25.1) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.24.2...v0.25.0) **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) **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) ## [v0.24.2](https://github.com/fastly/cli/releases/tag/v0.24.2) (2021-02-15) [Full Changelog](https://github.com/fastly/cli/compare/v0.24.1...v0.24.2) **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) ## [v0.24.1](https://github.com/fastly/cli/releases/tag/v0.24.1) (2021-02-03) [Full Changelog](https://github.com/fastly/cli/compare/v0.24.0...v0.24.1) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.23.0...v0.24.0) **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) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.22.0...v0.23.0) **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) **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) ## [v0.22.0](https://github.com/fastly/cli/releases/tag/v0.22.0) (2021-01-07) [Full Changelog](https://github.com/fastly/cli/compare/v0.21.2...v0.22.0) **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) ## [v0.21.2](https://github.com/fastly/cli/releases/tag/v0.21.2) (2021-01-06) [Full Changelog](https://github.com/fastly/cli/compare/v0.21.1...v0.21.2) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.21.0...v0.21.1) **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) ## [v0.21.0](https://github.com/fastly/cli/releases/tag/v0.21.0) (2020-12-14) [Full Changelog](https://github.com/fastly/cli/compare/v0.20.0...v0.21.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.19.0...v0.20.0) **Enhancements:** - 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) ## [v0.19.0](https://github.com/fastly/cli/releases/tag/v0.19.0) (2020-11-19) [Full Changelog](https://github.com/fastly/cli/compare/v0.18.1...v0.19.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.18.0...v0.18.1) **Enhancements:** - 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) ## [v0.18.0](https://github.com/fastly/cli/releases/tag/v0.18.0) (2020-10-27) [Full Changelog](https://github.com/fastly/cli/compare/v0.17.0...v0.18.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.16.1...v0.17.0) **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) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.16.0...v0.16.1) **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) ## [v0.16.0](https://github.com/fastly/cli/releases/tag/v0.16.0) (2020-07-09) [Full Changelog](https://github.com/fastly/cli/compare/v0.15.0...v0.16.0) **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) ## [v0.15.0](https://github.com/fastly/cli/releases/tag/v0.15.0) (2020-06-29) [Full Changelog](https://github.com/fastly/cli/compare/v0.14.0...v0.15.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.13.0...v0.14.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.12.0...v0.13.0) **Enhancements:** - 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) **Closed issues:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.11.0...v0.12.0) **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) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.10.0...v0.11.0) **Enhancements:** - 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) ## [v0.10.0](https://github.com/fastly/cli/releases/tag/v0.10.0) (2020-05-28) [Full Changelog](https://github.com/fastly/cli/compare/v0.9.0...v0.10.0) **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) ## [v0.9.0](https://github.com/fastly/cli/releases/tag/v0.9.0) (2020-05-21) [Full Changelog](https://github.com/fastly/cli/compare/v0.8.0...v0.9.0) **Breaking changes:** - 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) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.7.1...v0.8.0) **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) **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) **Closed issues:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.7.0...v0.7.1) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.6.0...v0.7.0) **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) **Closed issues:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.5.0...v0.6.0) **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) **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) ## [v0.5.0](https://github.com/fastly/cli/releases/tag/v0.5.0) (2020-04-08) [Full Changelog](https://github.com/fastly/cli/compare/v0.4.1...v0.5.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.4.0...v0.4.1) **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) ## [v0.4.0](https://github.com/fastly/cli/releases/tag/v0.4.0) (2020-03-20) [Full Changelog](https://github.com/fastly/cli/compare/v0.3.0...v0.4.0) **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) **Bug fixes:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.2.0...v0.3.0) **Enhancements:** - 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) [Full Changelog](https://github.com/fastly/cli/compare/v0.1.0...v0.2.0) **Enhancements:** - 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) ## [v0.1.0](https://github.com/fastly/cli/releases/tag/v0.1.0) (2020-02-05) [Full Changelog](https://github.com/fastly/cli/compare/5a8d21b6b1973abe7a27f985856d910f4396ce95...v0.1.0) Initial release :tada: \* _This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)_ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing We're happy to receive feature requests and PRs. If your change is nontrivial, 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. 2. Create an `upstream` remote. ```bash $ git remote add upstream git@github.com:fastly/cli.git ``` 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:! ================================================ FILE: DEVELOPMENT.md ================================================ ## 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 they 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 ``` ================================================ FILE: DOCUMENTATION.md ================================================ ## 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/cli/backend/create/#examples). These examples and API references are defined in [`pkg/app/metadata.json`](./pkg/app/metadata.json). ================================================ FILE: Dockerfile-node ================================================ 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" ================================================ FILE: Dockerfile-rust ================================================ FROM rust:latest LABEL maintainer="Fastly OSS " ENV RUSTUP_TOOLCHAIN=$RUST_VERSION RUN rustup target add wasm32-wasip1 \ && apt-get update && apt-get install -y curl jq && apt-get -y clean && rm -rf /var/lib/apt/lists/* \ && cargo install wasm-tools --locked \ && 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" ================================================ FILE: ISSUES.md ================================================

CLI Issues

Best practices for submitting an issue to the Fastly CLI repository.

## Issue Type: Bug Issues related to the CLI behavior not working as intended. - The CLI crashes or exits with an unexpected error - A command produces incorrect output or wrong results - Commands or flags don't work as documented **Example:** "When I run `fastly service list --json`, malformed JSON is produced." ## Issue Type: Feature Request Issues related to suggesting improvements to the CLI: - New commands or subcommands based on existing Fastly APIs - Improved error messages or user experience - Adding support for a third party integration **Example:** "Add a `fastly service version validate` command, which already exists in the Fastly API." ## Fastly Support CLI behavior specific to your environment or service / account should be routed to the Fastly support team @ support.fastly.com or support@fastly.com. - A feature is missing from your account / service - Partial content is returned that you may not have access to with your current Fastly account role - My site is not loading after a configuration change **Example:** When running `fastly service vcl snippet create`, an error is thrown that the provided VCL is not valid ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015 Seth Vargo Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ .PHONY: clean SHELL := /usr/bin/env bash -o pipefail ## Set the shell to use for finding Go files (default: /bin/bash) # 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 # Tooling versions GOLANGCI_LINT_VERSION = v2.4.0 BIN_DIR := $(CURDIR)/bin GOLANGCI_LINT := $(BIN_DIR)/golangci-lint # 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: $(GO_FILES) ## Build executables using goreleaser $(GO_BIN) tool -modfile=tools/go.mod goreleaser build ${GORELEASER_ARGS} # Useful for attaching a debugger such as https://github.com/go-delve/delve debug: @$(GO_BIN) build -gcflags="all=-N -l" $(GO_ARGS) -o "fastly" ./cmd/fastly .PHONY: config config: @$(CONFIG_SCRIPT) .PHONY: all all: config mod-download tidy fmt lint semgrep test build install ## Run EVERYTHING! ## 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_BIN) mod tidy # Run formatter. .PHONY: fmt fmt: $(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: install-linter check-linter-version ## Run golangci-lint @echo "==> Running golangci-lint" @$(GOLANGCI_LINT) run --verbose # Run tests .PHONY: test 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: 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/go.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) .PHONY: install-linter install-linter: ## Installs golangci-lint via go install @echo "==> Installing golangci-lint $(GOLANGCI_LINT_VERSION)" @mkdir -p $(BIN_DIR) @GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) .PHONY: check-linter-version check-linter-version: ## Verifies installed golangci-lint version matches expected @echo "==> Checking golangci-lint version" @EXPECTED="$(GOLANGCI_LINT_VERSION)"; \ EXPECTED=$${EXPECTED#v}; \ INSTALLED=$$($(GOLANGCI_LINT) version --short); \ if [ "$$INSTALLED" != "$$EXPECTED" ]; then \ echo "Expected golangci-lint v$$EXPECTED but found $$INSTALLED"; \ exit 1; \ fi .PHONY: clean-bin clean-bin: ## Removes locally installed binaries @echo "==> Cleaning ./bin directory" @rm -rf $(BIN_DIR) ================================================ FILE: README.md ================================================

Fastly CLI

A CLI for interacting with the Fastly platform.

Documentation Latest release Apache 2.0 License Go Report Card

## Quick links - [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) - [Authentication](https://fastly.help/cli/cli-auth) (`fastly auth login`) - [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) - [Issues](https://github.com/fastly/cli/blob/main/ISSUES.md) - [Documentation](https://github.com/fastly/cli/blob/main/DOCUMENTATION.md) ## Environment Variables | Variable | Description | |----------|-------------| | `FASTLY_API_TOKEN` | Fastly API token used for authentication. | | `FASTLY_DISABLE_AUTH_COMMAND` | When set (any non-empty value), all authentication-related commands (`auth`, `auth-token`, `sso`, `profile`, `whoami`) and the `--token`/`-t` global flag are disabled. Authentication is handled via `FASTLY_API_TOKEN` or pre-configured stored tokens. Background SSO flows are unaffected. | ## 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](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. 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 ================================================ FILE: RELEASE.md ================================================ # 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_to_draft_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 [Trusted Publishing](https://docs.npmjs.com/trusted-publishers). 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. ================================================ FILE: SECURITY.md ================================================ ## Report a security issue The fastly/cli project team welcomes security reports and is committed to providing prompt attention to security issues. Security issues should be reported privately via [Fastly’s security issue reporting process](https://www.fastly.com/security/report-security-issue). ## Security advisories Remediation of security vulnerabilities is prioritized by the project team. The project team endeavors to coordinate remediation with third-party stakeholders, and is committed to transparency in the disclosure process. The fastly/cli team announces security issues in release notes as well as Github Security Advisories on a best-effort basis. Note that communications related to security issues in Fastly-maintained OSS as described here are distinct from [Fastly Security Advisories](https://www.fastly.com/security-advisories). ================================================ FILE: TESTING.md ================================================ ## Testing To run the test suite: ```sh make test ``` Note that by default the tests are run using `go test` with the following configuration: ``` -race ./{cmd,pkg}/... ``` 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 TEST_ARGS="-run <...> " ``` **Example**: ```sh 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. The available environment variables are: - `TEST_COMPUTE_INIT`: runs `TestInit`. - `TEST_COMPUTE_BUILD`: runs `TestBuildRust`, `TestBuildJavaScript`, `TestBuildGo`. - `TEST_COMPUTE_BUILD_RUST`: runs `TestBuildRust`. - `TEST_COMPUTE_BUILD_JAVASCRIPT`: runs `TestBuildJavaScript`. - `TEST_COMPUTE_DEPLOY`: runs `TestDeploy`. **Example**: ```sh 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-wasip1 --toolchain ` will resolve any failing integration tests you may be running locally. To 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. ================================================ FILE: cmd/fastly/main.go ================================================ // Package main is the entry point for the Fastly CLI. package main import ( "os" "github.com/fastly/cli/pkg/app" fsterr "github.com/fastly/cli/pkg/errors" ) func main() { if err := app.Run(os.Args, os.Stdin); err != nil { if skipExit := fsterr.Process(err, os.Args, os.Stdout); skipExit { return } os.Exit(1) } } ================================================ FILE: deb-copyright ================================================ 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'. ================================================ FILE: go.mod ================================================ module github.com/fastly/cli go 1.25.0 toolchain go1.25.7 require ( github.com/Masterminds/semver/v3 v3.5.0 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-20240428194347-eebcea082ee0 github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible github.com/fatih/color v1.19.0 github.com/fsnotify/fsnotify v1.10.1 github.com/google/go-cmp v0.7.0 github.com/mattn/go-isatty v0.0.22 // indirect 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-20250811210735-e5fe3b51442e golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 ) require ( github.com/hashicorp/cap v0.13.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.51.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 golang.org/x/mod v0.36.0 ) require ( github.com/STARRY-S/zip v0.2.3 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.2 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dnaeon/go-vcr v1.2.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.1.1 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/testify v1.11.1 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) require ( github.com/andybalholm/brotli v1.2.1 // indirect github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/google/go-querystring v1.2.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.6 // 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.23 // indirect github.com/peterhellberg/link v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( 4d63.com/optional v0.2.0 github.com/creack/pty v1.1.24 github.com/fastly/go-fastly/v15 v15.0.1 github.com/mholt/archives v0.1.5 github.com/mitchellh/go-ps v1.0.0 ) ================================================ FILE: go.sum ================================================ 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.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= 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.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34= github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/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.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/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-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/v15 v15.0.1 h1:RSUttbnx6iiBHgeNF6suPSdno8eURtv/JfWI0QRHDQM= github.com/fastly/go-fastly/v15 v15.0.1/go.mod h1:hR7lXnPPI57fKIVtTTmrGrKnfE0GnW5mCT0drGYdjn0= 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.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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.6.0/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.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= 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.13.0 h1:bzLS1er9am6hOiw//TEjmwZ3t975iFfRfvXY6VRLKEw= github.com/hashicorp/cap v0.13.0/go.mod h1:Kbu5owAOJzQ/HH4Ba/76wsIXN2tRiJgToJKBO2MAEFE= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/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/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM= github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 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/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU= github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= 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.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.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 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/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/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/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/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 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.1/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 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= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-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.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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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= ================================================ FILE: npm/@fastly/cli/.gitignore ================================================ LICENSE README.md SECURITY.md ================================================ FILE: npm/@fastly/cli/fastly.js ================================================ #!/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; } } ================================================ FILE: npm/@fastly/cli/index.d.ts ================================================ declare module '@fastly/cli' { const location: string; export default location; } ================================================ FILE: npm/@fastly/cli/index.js ================================================ 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; ================================================ FILE: npm/@fastly/cli/package-helpers.js ================================================ import { platform, arch } from "node:process"; export function pkgForCurrentPlatform() { return `@fastly/cli-${platform}-${arch}`; } ================================================ FILE: npm/@fastly/cli/package.json ================================================ { "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" } ================================================ FILE: npm/@fastly/cli/update.js ================================================ #!/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", 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", preferUnplugged: false, os: [os], cpu: [cpu], }, null, 4 ); } ================================================ FILE: pkg/api/doc.go ================================================ // Package api provides abstractions for talking to the Fastly API. package api ================================================ FILE: pkg/api/interface.go ================================================ package api import ( "context" "crypto/ed25519" "net/http" "github.com/fastly/go-fastly/v15/fastly" ) // HTTPClient models a concrete http.Client. It's a consumer contract for some // commands which need to make direct HTTP requests to the API, because the // official Fastly client library lacks certain endpoints, so we call the API // directly. type HTTPClient interface { Do(*http.Request) (*http.Response, error) } // Interface models the methods of the Fastly API client that we use. // It exists to allow for easier testing, in combination with Mock. type Interface interface { AllIPs(context.Context) (v4, v6 fastly.IPAddrs, err error) AllDatacenters(context.Context) (datacenters []fastly.Datacenter, err error) CreateService(context.Context, *fastly.CreateServiceInput) (*fastly.Service, error) GetServices(context.Context, *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] ListServices(context.Context, *fastly.ListServicesInput) ([]*fastly.Service, error) GetService(context.Context, *fastly.GetServiceInput) (*fastly.Service, error) GetServiceDetails(context.Context, *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) UpdateService(context.Context, *fastly.UpdateServiceInput) (*fastly.Service, error) DeleteService(context.Context, *fastly.DeleteServiceInput) error SearchService(context.Context, *fastly.SearchServiceInput) (*fastly.Service, error) CloneVersion(context.Context, *fastly.CloneVersionInput) (*fastly.Version, error) ListVersions(context.Context, *fastly.ListVersionsInput) ([]*fastly.Version, error) GetVersion(context.Context, *fastly.GetVersionInput) (*fastly.Version, error) UpdateVersion(context.Context, *fastly.UpdateVersionInput) (*fastly.Version, error) ActivateVersion(context.Context, *fastly.ActivateVersionInput) (*fastly.Version, error) DeactivateVersion(context.Context, *fastly.DeactivateVersionInput) (*fastly.Version, error) LockVersion(context.Context, *fastly.LockVersionInput) (*fastly.Version, error) LatestVersion(context.Context, *fastly.LatestVersionInput) (*fastly.Version, error) ValidateVersion(context.Context, *fastly.ValidateVersionInput) (bool, string, error) CreateDomain(context.Context, *fastly.CreateDomainInput) (*fastly.Domain, error) ListDomains(context.Context, *fastly.ListDomainsInput) ([]*fastly.Domain, error) GetDomain(context.Context, *fastly.GetDomainInput) (*fastly.Domain, error) UpdateDomain(context.Context, *fastly.UpdateDomainInput) (*fastly.Domain, error) DeleteDomain(context.Context, *fastly.DeleteDomainInput) error ValidateDomain(context.Context, *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) ValidateAllDomains(context.Context, *fastly.ValidateAllDomainsInput) ([]*fastly.DomainValidationResult, error) GetImageOptimizerDefaultSettings(context.Context, *fastly.GetImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) UpdateImageOptimizerDefaultSettings(context.Context, *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) CreateBackend(context.Context, *fastly.CreateBackendInput) (*fastly.Backend, error) ListBackends(context.Context, *fastly.ListBackendsInput) ([]*fastly.Backend, error) GetBackend(context.Context, *fastly.GetBackendInput) (*fastly.Backend, error) UpdateBackend(context.Context, *fastly.UpdateBackendInput) (*fastly.Backend, error) DeleteBackend(context.Context, *fastly.DeleteBackendInput) error CreateHealthCheck(context.Context, *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) ListHealthChecks(context.Context, *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) GetHealthCheck(context.Context, *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) UpdateHealthCheck(context.Context, *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) DeleteHealthCheck(context.Context, *fastly.DeleteHealthCheckInput) error GetPackage(context.Context, *fastly.GetPackageInput) (*fastly.Package, error) UpdatePackage(context.Context, *fastly.UpdatePackageInput) (*fastly.Package, error) CreateDictionary(context.Context, *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) GetDictionary(context.Context, *fastly.GetDictionaryInput) (*fastly.Dictionary, error) DeleteDictionary(context.Context, *fastly.DeleteDictionaryInput) error ListDictionaries(context.Context, *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) UpdateDictionary(context.Context, *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) GetDictionaryItems(context.Context, *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] ListDictionaryItems(context.Context, *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) GetDictionaryItem(context.Context, *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) CreateDictionaryItem(context.Context, *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) UpdateDictionaryItem(context.Context, *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) DeleteDictionaryItem(context.Context, *fastly.DeleteDictionaryItemInput) error BatchModifyDictionaryItems(context.Context, *fastly.BatchModifyDictionaryItemsInput) error GetDictionaryInfo(context.Context, *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) CreateBigQuery(context.Context, *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) ListBigQueries(context.Context, *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) GetBigQuery(context.Context, *fastly.GetBigQueryInput) (*fastly.BigQuery, error) UpdateBigQuery(context.Context, *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) DeleteBigQuery(context.Context, *fastly.DeleteBigQueryInput) error CreateS3(context.Context, *fastly.CreateS3Input) (*fastly.S3, error) ListS3s(context.Context, *fastly.ListS3sInput) ([]*fastly.S3, error) GetS3(context.Context, *fastly.GetS3Input) (*fastly.S3, error) UpdateS3(context.Context, *fastly.UpdateS3Input) (*fastly.S3, error) DeleteS3(context.Context, *fastly.DeleteS3Input) error CreateKinesis(context.Context, *fastly.CreateKinesisInput) (*fastly.Kinesis, error) ListKinesis(context.Context, *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) GetKinesis(context.Context, *fastly.GetKinesisInput) (*fastly.Kinesis, error) UpdateKinesis(context.Context, *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) DeleteKinesis(context.Context, *fastly.DeleteKinesisInput) error CreateSyslog(context.Context, *fastly.CreateSyslogInput) (*fastly.Syslog, error) ListSyslogs(context.Context, *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) GetSyslog(context.Context, *fastly.GetSyslogInput) (*fastly.Syslog, error) UpdateSyslog(context.Context, *fastly.UpdateSyslogInput) (*fastly.Syslog, error) DeleteSyslog(context.Context, *fastly.DeleteSyslogInput) error CreateLogentries(context.Context, *fastly.CreateLogentriesInput) (*fastly.Logentries, error) ListLogentries(context.Context, *fastly.ListLogentriesInput) ([]*fastly.Logentries, error) GetLogentries(context.Context, *fastly.GetLogentriesInput) (*fastly.Logentries, error) UpdateLogentries(context.Context, *fastly.UpdateLogentriesInput) (*fastly.Logentries, error) DeleteLogentries(context.Context, *fastly.DeleteLogentriesInput) error CreatePapertrail(context.Context, *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) ListPapertrails(context.Context, *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) GetPapertrail(context.Context, *fastly.GetPapertrailInput) (*fastly.Papertrail, error) UpdatePapertrail(context.Context, *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) DeletePapertrail(context.Context, *fastly.DeletePapertrailInput) error CreateSumologic(context.Context, *fastly.CreateSumologicInput) (*fastly.Sumologic, error) ListSumologics(context.Context, *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) GetSumologic(context.Context, *fastly.GetSumologicInput) (*fastly.Sumologic, error) UpdateSumologic(context.Context, *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) DeleteSumologic(context.Context, *fastly.DeleteSumologicInput) error CreateGCS(context.Context, *fastly.CreateGCSInput) (*fastly.GCS, error) ListGCSs(context.Context, *fastly.ListGCSsInput) ([]*fastly.GCS, error) GetGCS(context.Context, *fastly.GetGCSInput) (*fastly.GCS, error) UpdateGCS(context.Context, *fastly.UpdateGCSInput) (*fastly.GCS, error) DeleteGCS(context.Context, *fastly.DeleteGCSInput) error CreateGrafanaCloudLogs(context.Context, *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) ListGrafanaCloudLogs(context.Context, *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) GetGrafanaCloudLogs(context.Context, *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) UpdateGrafanaCloudLogs(context.Context, *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) DeleteGrafanaCloudLogs(context.Context, *fastly.DeleteGrafanaCloudLogsInput) error CreateFTP(context.Context, *fastly.CreateFTPInput) (*fastly.FTP, error) ListFTPs(context.Context, *fastly.ListFTPsInput) ([]*fastly.FTP, error) GetFTP(context.Context, *fastly.GetFTPInput) (*fastly.FTP, error) UpdateFTP(context.Context, *fastly.UpdateFTPInput) (*fastly.FTP, error) DeleteFTP(context.Context, *fastly.DeleteFTPInput) error CreateSplunk(context.Context, *fastly.CreateSplunkInput) (*fastly.Splunk, error) ListSplunks(context.Context, *fastly.ListSplunksInput) ([]*fastly.Splunk, error) GetSplunk(context.Context, *fastly.GetSplunkInput) (*fastly.Splunk, error) UpdateSplunk(context.Context, *fastly.UpdateSplunkInput) (*fastly.Splunk, error) DeleteSplunk(context.Context, *fastly.DeleteSplunkInput) error CreateScalyr(context.Context, *fastly.CreateScalyrInput) (*fastly.Scalyr, error) ListScalyrs(context.Context, *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) GetScalyr(context.Context, *fastly.GetScalyrInput) (*fastly.Scalyr, error) UpdateScalyr(context.Context, *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) DeleteScalyr(context.Context, *fastly.DeleteScalyrInput) error CreateLoggly(context.Context, *fastly.CreateLogglyInput) (*fastly.Loggly, error) ListLoggly(context.Context, *fastly.ListLogglyInput) ([]*fastly.Loggly, error) GetLoggly(context.Context, *fastly.GetLogglyInput) (*fastly.Loggly, error) UpdateLoggly(context.Context, *fastly.UpdateLogglyInput) (*fastly.Loggly, error) DeleteLoggly(context.Context, *fastly.DeleteLogglyInput) error CreateHoneycomb(context.Context, *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) ListHoneycombs(context.Context, *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) GetHoneycomb(context.Context, *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) UpdateHoneycomb(context.Context, *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) DeleteHoneycomb(context.Context, *fastly.DeleteHoneycombInput) error CreateHeroku(context.Context, *fastly.CreateHerokuInput) (*fastly.Heroku, error) ListHerokus(context.Context, *fastly.ListHerokusInput) ([]*fastly.Heroku, error) GetHeroku(context.Context, *fastly.GetHerokuInput) (*fastly.Heroku, error) UpdateHeroku(context.Context, *fastly.UpdateHerokuInput) (*fastly.Heroku, error) DeleteHeroku(context.Context, *fastly.DeleteHerokuInput) error CreateSFTP(context.Context, *fastly.CreateSFTPInput) (*fastly.SFTP, error) ListSFTPs(context.Context, *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) GetSFTP(context.Context, *fastly.GetSFTPInput) (*fastly.SFTP, error) UpdateSFTP(context.Context, *fastly.UpdateSFTPInput) (*fastly.SFTP, error) DeleteSFTP(context.Context, *fastly.DeleteSFTPInput) error CreateLogshuttle(context.Context, *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) ListLogshuttles(context.Context, *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) GetLogshuttle(context.Context, *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) UpdateLogshuttle(context.Context, *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) DeleteLogshuttle(context.Context, *fastly.DeleteLogshuttleInput) error CreateCloudfiles(context.Context, *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) ListCloudfiles(context.Context, *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) GetCloudfiles(context.Context, *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) UpdateCloudfiles(context.Context, *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) DeleteCloudfiles(context.Context, *fastly.DeleteCloudfilesInput) error CreateDigitalOcean(context.Context, *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) ListDigitalOceans(context.Context, *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) GetDigitalOcean(context.Context, *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) UpdateDigitalOcean(context.Context, *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) DeleteDigitalOcean(context.Context, *fastly.DeleteDigitalOceanInput) error CreateElasticsearch(context.Context, *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) ListElasticsearch(context.Context, *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) GetElasticsearch(context.Context, *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) UpdateElasticsearch(context.Context, *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) DeleteElasticsearch(context.Context, *fastly.DeleteElasticsearchInput) error CreateBlobStorage(context.Context, *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) ListBlobStorages(context.Context, *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) GetBlobStorage(context.Context, *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) UpdateBlobStorage(context.Context, *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) DeleteBlobStorage(context.Context, *fastly.DeleteBlobStorageInput) error CreateDatadog(context.Context, *fastly.CreateDatadogInput) (*fastly.Datadog, error) ListDatadog(context.Context, *fastly.ListDatadogInput) ([]*fastly.Datadog, error) GetDatadog(context.Context, *fastly.GetDatadogInput) (*fastly.Datadog, error) UpdateDatadog(context.Context, *fastly.UpdateDatadogInput) (*fastly.Datadog, error) DeleteDatadog(context.Context, *fastly.DeleteDatadogInput) error CreateHTTPS(context.Context, *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) ListHTTPS(context.Context, *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) GetHTTPS(context.Context, *fastly.GetHTTPSInput) (*fastly.HTTPS, error) UpdateHTTPS(context.Context, *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) DeleteHTTPS(context.Context, *fastly.DeleteHTTPSInput) error CreateKafka(context.Context, *fastly.CreateKafkaInput) (*fastly.Kafka, error) ListKafkas(context.Context, *fastly.ListKafkasInput) ([]*fastly.Kafka, error) GetKafka(context.Context, *fastly.GetKafkaInput) (*fastly.Kafka, error) UpdateKafka(context.Context, *fastly.UpdateKafkaInput) (*fastly.Kafka, error) DeleteKafka(context.Context, *fastly.DeleteKafkaInput) error CreatePubsub(context.Context, *fastly.CreatePubsubInput) (*fastly.Pubsub, error) ListPubsubs(context.Context, *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) GetPubsub(context.Context, *fastly.GetPubsubInput) (*fastly.Pubsub, error) UpdatePubsub(context.Context, *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) DeletePubsub(context.Context, *fastly.DeletePubsubInput) error CreateOpenstack(context.Context, *fastly.CreateOpenstackInput) (*fastly.Openstack, error) ListOpenstack(context.Context, *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) GetOpenstack(context.Context, *fastly.GetOpenstackInput) (*fastly.Openstack, error) UpdateOpenstack(context.Context, *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) DeleteOpenstack(context.Context, *fastly.DeleteOpenstackInput) error GetRegions(context.Context) (*fastly.RegionsResponse, error) GetStatsJSON(context.Context, *fastly.GetStatsInput, any) error GetAggregateJSON(context.Context, *fastly.GetAggregateInput, any) error GetUsage(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) GetUsageByService(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) GetDomainMetricsForService(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) GetDomainMetricsForServiceJSON(context.Context, *fastly.GetDomainMetricsInput, any) error GetOriginMetricsForService(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) GetOriginMetricsForServiceJSON(context.Context, *fastly.GetOriginMetricsInput, any) error CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetLoggingEndpointErrors(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) GetGeneratedVCL(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) CreateVCL(context.Context, *fastly.CreateVCLInput) (*fastly.VCL, error) ListVCLs(context.Context, *fastly.ListVCLsInput) ([]*fastly.VCL, error) GetVCL(context.Context, *fastly.GetVCLInput) (*fastly.VCL, error) UpdateVCL(context.Context, *fastly.UpdateVCLInput) (*fastly.VCL, error) DeleteVCL(context.Context, *fastly.DeleteVCLInput) error CreateSnippet(context.Context, *fastly.CreateSnippetInput) (*fastly.Snippet, error) ListSnippets(context.Context, *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) GetSnippet(context.Context, *fastly.GetSnippetInput) (*fastly.Snippet, error) GetDynamicSnippet(context.Context, *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) UpdateSnippet(context.Context, *fastly.UpdateSnippetInput) (*fastly.Snippet, error) UpdateDynamicSnippet(context.Context, *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) DeleteSnippet(context.Context, *fastly.DeleteSnippetInput) error Purge(context.Context, *fastly.PurgeInput) (*fastly.Purge, error) PurgeKey(context.Context, *fastly.PurgeKeyInput) (*fastly.Purge, error) PurgeKeys(context.Context, *fastly.PurgeKeysInput) (map[string]string, error) PurgeAll(context.Context, *fastly.PurgeAllInput) (*fastly.Purge, error) CreateACL(context.Context, *fastly.CreateACLInput) (*fastly.ACL, error) DeleteACL(context.Context, *fastly.DeleteACLInput) error GetACL(context.Context, *fastly.GetACLInput) (*fastly.ACL, error) ListACLs(context.Context, *fastly.ListACLsInput) ([]*fastly.ACL, error) UpdateACL(context.Context, *fastly.UpdateACLInput) (*fastly.ACL, error) CreateACLEntry(context.Context, *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) DeleteACLEntry(context.Context, *fastly.DeleteACLEntryInput) error GetACLEntry(context.Context, *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) GetACLEntries(context.Context, *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] ListACLEntries(context.Context, *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) UpdateACLEntry(context.Context, *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) BatchModifyACLEntries(context.Context, *fastly.BatchModifyACLEntriesInput) error CreateNewRelic(context.Context, *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) DeleteNewRelic(context.Context, *fastly.DeleteNewRelicInput) error GetNewRelic(context.Context, *fastly.GetNewRelicInput) (*fastly.NewRelic, error) ListNewRelic(context.Context, *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) UpdateNewRelic(context.Context, *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) CreateNewRelicOTLP(context.Context, *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) DeleteNewRelicOTLP(context.Context, *fastly.DeleteNewRelicOTLPInput) error GetNewRelicOTLP(context.Context, *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) ListNewRelicOTLP(context.Context, *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) UpdateNewRelicOTLP(context.Context, *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) CreateUser(context.Context, *fastly.CreateUserInput) (*fastly.User, error) DeleteUser(context.Context, *fastly.DeleteUserInput) error GetCurrentUser(context.Context) (*fastly.User, error) GetUser(context.Context, *fastly.GetUserInput) (*fastly.User, error) ListCustomerUsers(context.Context, *fastly.ListCustomerUsersInput) ([]*fastly.User, error) UpdateUser(context.Context, *fastly.UpdateUserInput) (*fastly.User, error) ResetUserPassword(context.Context, *fastly.ResetUserPasswordInput) error BatchDeleteTokens(context.Context, *fastly.BatchDeleteTokensInput) error CreateToken(context.Context, *fastly.CreateTokenInput) (*fastly.Token, error) DeleteToken(context.Context, *fastly.DeleteTokenInput) error DeleteTokenSelf(context.Context) error GetTokenSelf(context.Context) (*fastly.Token, error) ListCustomerTokens(context.Context, *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) ListTokens(context.Context, *fastly.ListTokensInput) ([]*fastly.Token, error) NewListKVStoreKeysPaginator(context.Context, *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries GetCustomTLSConfiguration(context.Context, *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) ListCustomTLSConfigurations(context.Context, *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) UpdateCustomTLSConfiguration(context.Context, *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) GetTLSActivation(context.Context, *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) ListTLSActivations(context.Context, *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) UpdateTLSActivation(context.Context, *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) CreateTLSActivation(context.Context, *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) DeleteTLSActivation(context.Context, *fastly.DeleteTLSActivationInput) error CreateCustomTLSCertificate(context.Context, *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) DeleteCustomTLSCertificate(context.Context, *fastly.DeleteCustomTLSCertificateInput) error GetCustomTLSCertificate(context.Context, *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) ListCustomTLSCertificates(context.Context, *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) UpdateCustomTLSCertificate(context.Context, *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) ListTLSDomains(context.Context, *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) CreatePrivateKey(context.Context, *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) DeletePrivateKey(context.Context, *fastly.DeletePrivateKeyInput) error GetPrivateKey(context.Context, *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) ListPrivateKeys(context.Context, *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) CreateBulkCertificate(context.Context, *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) DeleteBulkCertificate(context.Context, *fastly.DeleteBulkCertificateInput) error GetBulkCertificate(context.Context, *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) ListBulkCertificates(context.Context, *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) UpdateBulkCertificate(context.Context, *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) CreateTLSSubscription(context.Context, *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) DeleteTLSSubscription(context.Context, *fastly.DeleteTLSSubscriptionInput) error GetTLSSubscription(context.Context, *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) ListTLSSubscriptions(context.Context, *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) UpdateTLSSubscription(context.Context, *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) ListServiceAuthorizations(context.Context, *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) GetServiceAuthorization(context.Context, *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) CreateServiceAuthorization(context.Context, *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) UpdateServiceAuthorization(context.Context, *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) DeleteServiceAuthorization(context.Context, *fastly.DeleteServiceAuthorizationInput) error CreateConfigStore(context.Context, *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) DeleteConfigStore(context.Context, *fastly.DeleteConfigStoreInput) error GetConfigStore(context.Context, *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) GetConfigStoreMetadata(context.Context, *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) ListConfigStores(context.Context, *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) ListConfigStoreServices(context.Context, *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) UpdateConfigStore(context.Context, *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) CreateConfigStoreItem(context.Context, *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) DeleteConfigStoreItem(context.Context, *fastly.DeleteConfigStoreItemInput) error GetConfigStoreItem(context.Context, *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) ListConfigStoreItems(context.Context, *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) UpdateConfigStoreItem(context.Context, *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) CreateKVStore(context.Context, *fastly.CreateKVStoreInput) (*fastly.KVStore, error) ListKVStores(context.Context, *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) DeleteKVStore(context.Context, *fastly.DeleteKVStoreInput) error GetKVStore(context.Context, *fastly.GetKVStoreInput) (*fastly.KVStore, error) ListKVStoreKeys(context.Context, *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) GetKVStoreKey(context.Context, *fastly.GetKVStoreKeyInput) (string, error) GetKVStoreItem(context.Context, *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) DeleteKVStoreKey(context.Context, *fastly.DeleteKVStoreKeyInput) error InsertKVStoreKey(context.Context, *fastly.InsertKVStoreKeyInput) error BatchModifyKVStoreKey(context.Context, *fastly.BatchModifyKVStoreKeyInput) error CreateSecretStore(context.Context, *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) GetSecretStore(context.Context, *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) DeleteSecretStore(context.Context, *fastly.DeleteSecretStoreInput) error ListSecretStores(context.Context, *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) CreateSecret(context.Context, *fastly.CreateSecretInput) (*fastly.Secret, error) GetSecret(context.Context, *fastly.GetSecretInput) (*fastly.Secret, error) DeleteSecret(context.Context, *fastly.DeleteSecretInput) error ListSecrets(context.Context, *fastly.ListSecretsInput) (*fastly.Secrets, error) CreateClientKey(context.Context) (*fastly.ClientKey, error) GetSigningKey(context.Context) (ed25519.PublicKey, error) CreateResource(context.Context, *fastly.CreateResourceInput) (*fastly.Resource, error) DeleteResource(context.Context, *fastly.DeleteResourceInput) error GetResource(context.Context, *fastly.GetResourceInput) (*fastly.Resource, error) ListResources(context.Context, *fastly.ListResourcesInput) ([]*fastly.Resource, error) UpdateResource(context.Context, *fastly.UpdateResourceInput) (*fastly.Resource, error) CreateERL(context.Context, *fastly.CreateERLInput) (*fastly.ERL, error) DeleteERL(context.Context, *fastly.DeleteERLInput) error GetERL(context.Context, *fastly.GetERLInput) (*fastly.ERL, error) ListERLs(context.Context, *fastly.ListERLsInput) ([]*fastly.ERL, error) UpdateERL(context.Context, *fastly.UpdateERLInput) (*fastly.ERL, error) CreateCondition(context.Context, *fastly.CreateConditionInput) (*fastly.Condition, error) DeleteCondition(context.Context, *fastly.DeleteConditionInput) error GetCondition(context.Context, *fastly.GetConditionInput) (*fastly.Condition, error) ListConditions(context.Context, *fastly.ListConditionsInput) ([]*fastly.Condition, error) UpdateCondition(context.Context, *fastly.UpdateConditionInput) (*fastly.Condition, error) ListAlertDefinitions(context.Context, *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) CreateAlertDefinition(context.Context, *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) GetAlertDefinition(context.Context, *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) UpdateAlertDefinition(context.Context, *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) DeleteAlertDefinition(context.Context, *fastly.DeleteAlertDefinitionInput) error TestAlertDefinition(context.Context, *fastly.TestAlertDefinitionInput) error ListAlertHistory(context.Context, *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) ListObservabilityCustomDashboards(context.Context, *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) CreateObservabilityCustomDashboard(context.Context, *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) GetObservabilityCustomDashboard(context.Context, *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) UpdateObservabilityCustomDashboard(context.Context, *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) DeleteObservabilityCustomDashboard(context.Context, *fastly.DeleteObservabilityCustomDashboardInput) error } // RealtimeStatsInterface is the subset of go-fastly's realtime stats API used here. type RealtimeStatsInterface interface { GetRealtimeStatsJSON(context.Context, *fastly.GetRealtimeStatsInput, any) error } // Ensure that fastly.Client satisfies Interface. var _ Interface = (*fastly.Client)(nil) // Ensure that fastly.RTSClient satisfies RealtimeStatsInterface. var _ RealtimeStatsInterface = (*fastly.RTSClient)(nil) ================================================ FILE: pkg/api/undocumented/undocumented.go ================================================ // 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" ) // 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() } func (e APIError) Unwrap() error { return e.Err } func (e APIError) HTTPStatusCode() int { return e.StatusCode } // 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 } ================================================ FILE: pkg/app/disable_token_flag_test.go ================================================ package app_test import ( "bytes" stderrors "errors" "io" "strings" "testing" "github.com/fastly/cli/pkg/app" "github.com/fastly/cli/pkg/env" "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/testutil" ) func TestTokenFlagDisabledWhenAuthCommandDisabled(t *testing.T) { t.Setenv(env.DisableAuthCommand, "1") t.Run("--token flag rejected", func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("--token abc version") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) if err == nil { t.Fatal("expected error when using --token with FASTLY_DISABLE_AUTH_COMMAND set") } errStr := err.Error() if !strings.Contains(errStr, "unknown long flag") { t.Errorf("expected unknown flag error, got: %s", errStr) } }) t.Run("-t flag rejected", func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("-t abc version") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) if err == nil { t.Fatal("expected error when using -t with FASTLY_DISABLE_AUTH_COMMAND set") } errStr := err.Error() if !strings.Contains(errStr, "unknown short flag") { t.Errorf("expected unknown flag error, got: %s", errStr) } }) t.Run("help output omits --token", func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("--help") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) var output string if err != nil { var re errors.RemediationError if stderrors.As(err, &re) { output = re.Prefix } } output += stdout.String() if strings.Contains(output, "--token") { t.Errorf("expected --token to be absent from help output when FASTLY_DISABLE_AUTH_COMMAND is set, got:\n%s", output) } }) } func TestTokenFlagAvailableByDefault(t *testing.T) { t.Setenv(env.DisableAuthCommand, "") t.Run("help output includes --token", func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("--help") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) var output string if err != nil { var re errors.RemediationError if stderrors.As(err, &re) { output = re.Prefix } } output += stdout.String() if !strings.Contains(output, "--token") { t.Errorf("expected --token in help output, got:\n%s", output) } }) } ================================================ FILE: pkg/app/doc.go ================================================ // Package app provides helpers for creating and running the CLI application. // Care has been taken to make the CLI testable through the use of dependency // injection and interfaces. package app ================================================ FILE: pkg/app/expiry_warning_test.go ================================================ package app import ( "bytes" "os" "strings" "testing" "time" "github.com/fastly/cli/pkg/config" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/manifest" ) // expiringTokenData returns a global.Data configured with a stored token that // expires soon. Callers can override Flags and commandName to test suppression. func expiringTokenData(out *bytes.Buffer) *global.Data { soon := time.Now().Add(20 * time.Minute).Format(time.RFC3339) return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Manifest: &manifest.Data{}, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: soon, }, }, }, }, } } func TestCheckTokenExpirationWarning(t *testing.T) { soon := time.Now().Add(20 * time.Minute).Format(time.RFC3339) farFuture := time.Now().Add(60 * 24 * time.Hour).Format(time.RFC3339) tests := []struct { name string commandName string data func(out *bytes.Buffer) *global.Data wantWarn bool wantSubstr string }{ { name: "SourceAuth expiring soon shows warning", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: soon, }, }, }, }, } }, wantWarn: true, wantSubstr: "expires in", }, { name: "SourceAuth not expiring soon no warning", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: farFuture, }, }, }, }, } }, wantWarn: false, }, { name: "SourceEnvironment JWT-like token no warning", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Env: config.Environment{APIToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake"}, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: soon, }, }, }, }, } }, wantWarn: false, }, { name: "SourceFlag raw token no warning", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Flags: global.Flags{Token: "some-raw-token"}, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: soon, }, }, }, }, } }, wantWarn: false, }, { name: "stale default name nil token no panic", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Config: config.File{ Auth: config.Auth{ Default: "deleted-token", Tokens: config.AuthTokens{}, }, }, } }, wantWarn: false, }, { name: "malformed expiry logs error no visible warning", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: "not-a-date", }, }, }, }, } }, wantWarn: false, }, { name: "nil ErrLog does not panic", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, Config: config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "tok_abc123", APITokenExpiresAt: "not-a-date", }, }, }, }, } }, wantWarn: false, }, { name: "SSO token expiring soon shows remediation", commandName: "service list", data: func(out *bytes.Buffer) *global.Data { return &global.Data{ Output: out, ErrOutput: out, ErrLog: fsterr.Log, Config: config.File{ Auth: config.Auth{ Default: "sso-tok", Tokens: config.AuthTokens{ "sso-tok": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "tok_sso", RefreshExpiresAt: soon, }, }, }, }, } }, wantWarn: true, wantSubstr: "fastly auth login --sso", }, } // Ensure FASTLY_DISABLE_AUTH_COMMAND is not set. originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND") os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "") defer os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", originalEnv) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var buf bytes.Buffer data := tt.data(&buf) // Ensure Manifest is initialized to avoid nil panics in Token(). if data.Manifest == nil { data.Manifest = &manifest.Data{} } checkTokenExpirationWarning(data, tt.commandName) output := buf.String() if tt.wantWarn && output == "" { t.Error("expected warning output but got none") } if !tt.wantWarn && output != "" { t.Errorf("expected no warning but got: %s", output) } if tt.wantSubstr != "" && !strings.Contains(output, tt.wantSubstr) { t.Errorf("expected output to contain %q, got: %s", tt.wantSubstr, output) } }) } } func TestCheckTokenExpirationWarningDisabledAuth(t *testing.T) { originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND") os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") defer os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", originalEnv) var buf bytes.Buffer data := expiringTokenData(&buf) checkTokenExpirationWarning(data, "service list") output := buf.String() if !strings.Contains(output, "FASTLY_API_TOKEN") { t.Errorf("expected FASTLY_API_TOKEN remediation in disabled-auth mode, got: %s", output) } if strings.Contains(output, "fastly auth") { t.Errorf("should not mention fastly auth in disabled mode, got: %s", output) } } // TestCheckTokenExpirationWarningSuppression tests that the warning is // suppressed for all auth-related commands, --quiet, and --json (which sets // Quiet=true). The auth-related set matches FASTLY_DISABLE_AUTH_COMMAND // (pkg/env/env.go): auth, auth-token, sso, profile, whoami. func TestCheckTokenExpirationWarningSuppression(t *testing.T) { tests := []struct { name string commandName string flags global.Flags }{ // auth family. { name: "suppressed for auth list", commandName: "auth list", }, { name: "suppressed for auth show", commandName: "auth show", }, { name: "suppressed for auth login", commandName: "auth login", }, { name: "suppressed for bare auth", commandName: "auth", }, // Other auth-related families. { name: "suppressed for sso", commandName: "sso", }, { name: "suppressed for auth-token create", commandName: "auth-token create", }, { name: "suppressed for bare auth-token", commandName: "auth-token", }, { name: "suppressed for profile switch", commandName: "profile switch", }, { name: "suppressed for bare profile", commandName: "profile", }, { name: "suppressed for whoami", commandName: "whoami", }, // Flag-based suppression. { name: "suppressed with --quiet flag", commandName: "service list", flags: global.Flags{Quiet: true}, }, } originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND") os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "") defer os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", originalEnv) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var buf bytes.Buffer data := expiringTokenData(&buf) data.Flags = tt.flags checkTokenExpirationWarning(data, tt.commandName) output := buf.String() if output != "" { t.Errorf("expected no output for %q with flags %+v, got: %s", tt.commandName, tt.flags, output) } }) } } // TestCheckTokenExpirationWarningShownForJSON verifies that --json mode still // emits the warning (to stderr) rather than suppressing it entirely. func TestCheckTokenExpirationWarningShownForJSON(t *testing.T) { var buf bytes.Buffer data := expiringTokenData(&buf) data.Flags = global.Flags{JSON: true} checkTokenExpirationWarning(data, "service list") output := buf.String() if !strings.Contains(output, "expires in") { t.Errorf("expected expiry warning in --json mode (written to stderr), got: %s", output) } } // TestCheckTokenExpirationWarningNotSuppressedForNonAuth ensures that commands // starting with "auth" as a prefix of another word (e.g. "authtoken") are not // incorrectly suppressed. func TestCheckTokenExpirationWarningNotSuppressedForNonAuth(t *testing.T) { originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND") os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "") defer os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", originalEnv) var buf bytes.Buffer data := expiringTokenData(&buf) // "authtoken" is not "auth" or "auth ", so warning should fire. checkTokenExpirationWarning(data, "authtoken list") output := buf.String() if output == "" { t.Error("expected warning for non-auth command 'authtoken list' but got none") } } ================================================ FILE: pkg/app/metadata.json ================================================ { "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": { "login": { "examples": [ { "cmd": "fastly auth login", "title": "Authenticate by pasting an API token" }, { "cmd": "fastly auth login --sso --token my-sso", "title": "Authenticate using browser-based SSO" } ] }, "add": { "examples": [ { "cmd": "fastly auth add staging --api-token $FASTLY_API_TOKEN", "title": "Store a named token for a secondary environment" } ] }, "use": { "examples": [ { "cmd": "fastly auth use staging", "title": "Make a stored token the default" } ] }, "list": { "examples": [ { "cmd": "fastly auth list", "title": "List stored tokens" } ] }, "show": { "examples": [ { "cmd": "fastly auth show staging", "title": "Show details for a stored token" } ] }, "delete": { "examples": [ { "cmd": "fastly auth delete staging", "title": "Delete a stored token" } ] } }, "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" ] } } } } ================================================ FILE: pkg/app/run.go ================================================ package app import ( "encoding/json" "errors" "fmt" "io" "net/http" "os" "slices" "strconv" "strings" "time" "github.com/fatih/color" "github.com/hashicorp/cap/oidc" "github.com/skratchdot/open-golang/open" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/kingpin" "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/auth" "github.com/fastly/cli/pkg/commands" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/commands/compute" "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/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/revision" "github.com/fastly/cli/pkg/sync" "github.com/fastly/cli/pkg/text" "github.com/fastly/cli/pkg/useragent" ) // 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) // 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, }), } // If a UserAgent extension has been set in the environment, // apply it if e.UserAgentExtension != "" { useragent.SetExtension(e.UserAgentExtension) } // Override the go-fastly UserAgent value by prepending the CLI version. // // Results in a header similar to: // User-Agent: FastlyCLI/v11.3.0, FastlyGo/10.5.0 (+github.com/fastly/go-fastly; go1.24.3) // (with any extension supplied above between the FastlyCLI and FastlyGo values) fastly.UserAgent = fmt.Sprintf("%s, %s", useragent.Name, fastly.UserAgent) return &global.Data{ APIClientFactory: factory, Args: args, Config: cfg, ConfigPath: config.FilePath, Env: e, ErrLog: fsterr.Log, ErrOutput: os.Stderr, 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 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 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. JSON mode suppresses stdout-bound noise // (metadata notices, update checks) but still allows stderr warnings // (token expiry, profile mismatch) so they don't corrupt JSON output. if slices.Contains(data.Args, "--json") { data.Flags.JSON = true } // We short-circuit the execution for specific cases: // // - argparser.ArgsIsHelpJSON() == true // - shell autocompletion flag provided switch commandName { case "help--json": fallthrough 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 && !data.Flags.JSON { 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.Flags.JSON { data.Manifest.File.SetQuiet(true) } // Migrate legacy profiles to [auth] section. // MigrateProfilesToAuth merges without overwriting existing auth entries. if len(data.Config.Profiles) > 0 { data.Config.MigrateProfilesToAuth() data.Config.Profiles = nil if err := data.Config.Write(data.ConfigPath); err != nil { data.ErrLog.Add(err) } } apiEndpoint, endpointSource := data.APIEndpoint() if data.Verbose() && !commandSuppressesVerbose(command) { displayAPIEndpoint(apiEndpoint, endpointSource, data.Output) } // User can set env.DebugMode env var or the --debug-mode boolean flag. // This will prioritize 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. needsAuthServer := commandRequiresAuthServer(commandName, data.Args) if !commandRequiresToken(command) && needsAuthServer { // 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 { // Non-fatal: SSO flows will detect the nil auth server and // report a clear error. data.ErrLog.Add(err) } else { 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 } if !data.Flags.Quiet && data.Flags.Token == "" && data.Flags.Profile == "" && data.Env.APIToken == "" && data.Manifest != nil && data.Manifest.File.Profile != "" { if data.Config.GetAuthToken(data.Manifest.File.Profile) == nil { if defaultName, _ := data.Config.GetDefaultAuthToken(); defaultName != "" { text.Warning(data.ErrOutput, "fastly.toml profile %q not found in auth config, using default token %q.\n", data.Manifest.File.Profile, defaultName) } else { text.Warning(data.ErrOutput, "fastly.toml profile %q not found in auth config and no default token is configured.\n", data.Manifest.File.Profile) } } } token, tokenSource, err := processToken(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 token == "" && tokenSource == lookup.SourceUndefined { token, tokenSource, err = promptForAuth(data) if err != nil { if errors.Is(err, fsterr.ErrDontContinue) { return nil } return fmt.Errorf("failed to process token: %w", err) } } if data.Verbose() && !commandSuppressesVerbose(command) { displayToken(tokenSource, data) } if !data.Flags.Quiet { checkConfigPermissions(tokenSource, data.ErrOutput) } 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) } } checkTokenExpirationWarning(data, commandName) f := checkForUpdates(data.Versioners.CLI, commandName) defer func() { if !data.Flags.Quiet && !data.Flags.JSON { 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(data.Output, io.Discard) // don't let kingpin write error output app.UsageContext(&kingpin.UsageContext{ Template: VerboseUsageTemplate, Funcs: UsageTemplateFuncs, }) // Prevent kingpin from calling os.Exit, this gives us greater control over // error states and output control flow. app.Terminate(nil) // 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. 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", "[DEPRECATED: use 'fastly auth login --sso --token '] Enable SSO for current profile").Hidden().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", "[DEPRECATED: use 'fastly auth use'] Switch account profile for single command execution").Hidden().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) if !env.AuthCommandDisabled() { tokenHelp := fmt.Sprintf("Fastly API token, or name of a stored auth token (use 'default' for the default token). Falls back to %s env var", env.APIToken) 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. // // For [auth] SSO tokens, we check freshness and attempt to refresh if expired. // If both access and refresh tokens are expired, we trigger the SSO flow. // // Tokens from --token (raw, unavailable when FASTLY_DISABLE_AUTH_COMMAND is // set) or FASTLY_API_TOKEN are assumed to be valid. func processToken(data *global.Data) (token string, tokenSource lookup.Source, err error) { if err := data.ValidateProfileFlag(); err != nil { return "", lookup.SourceUndefined, err } token, tokenSource = data.Token() switch tokenSource { case lookup.SourceUndefined: if data.Flags.NonInteractive || data.Flags.AutoYes || data.Flags.AcceptDefaults { return "", tokenSource, fsterr.ErrNonInteractiveNoToken() } if data.Env.UseSSO == "1" { return ssoAuthentication("No API token could be found", data, false) } return "", tokenSource, nil case lookup.SourceAuth: name := data.AuthTokenName() if name == "" { break } at := data.Config.GetAuthToken(name) if at != nil && at.Type == config.AuthTokenTypeSSO && at.RefreshToken != "" { reauth, err := checkAndRefreshAuthSSOToken(name, at, data) if err != nil { if errors.Is(err, auth.ErrInvalidGrant) { return ssoAuthentication("We can't refresh your token", data, true) } return token, tokenSource, fmt.Errorf("failed to refresh auth token %q: %w", name, err) } if reauth { return ssoAuthentication("Your auth token has expired and needs re-authentication", data, false) } token = at.Token } case lookup.SourceEnvironment, lookup.SourceFlag, lookup.SourceDefault, lookup.SourceFile: // no-op } return token, tokenSource, nil } // checkAndRefreshAuthSSOToken refreshes an SSO-type [auth] token if expired. func checkAndRefreshAuthSSOToken(name string, at *config.AuthToken, data *global.Data) (reauth bool, err error) { if at.AccessExpiresAt == "" { return false, nil // no expiry info, assume still valid } accessExpires, err := time.Parse(time.RFC3339, at.AccessExpiresAt) if err != nil { return false, fmt.Errorf("invalid access_expires_at %q: %w", at.AccessExpiresAt, err) } // Access token still valid. if time.Now().Before(accessExpires) { return false, nil } // Access token expired; check if refresh token is also expired. if at.RefreshExpiresAt != "" { refreshExpires, err := time.Parse(time.RFC3339, at.RefreshExpiresAt) if err != nil { return false, fmt.Errorf("invalid refresh_expires_at %q: %w", at.RefreshExpiresAt, err) } if time.Now().After(refreshExpires) { if at.APITokenExpiresAt != "" { apiExpires, err := time.Parse(time.RFC3339, at.APITokenExpiresAt) if err == nil && time.Now().Before(apiExpires) { return false, nil } } return true, nil } } if data.AuthServer == nil { return true, nil // can't refresh without auth server } 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(at.RefreshToken) if err != nil { if errors.Is(err, auth.ErrInvalidGrant) { return true, nil // refresh token rejected, needs full re-auth } return false, fmt.Errorf("failed to refresh access token: %w", err) } email, apiToken, err := data.AuthServer.ValidateAndRetrieveAPIToken(updatedJWT.AccessToken) if err != nil { return false, fmt.Errorf("failed to validate JWT and retrieve API token: %w", err) } now := time.Now() at.Token = apiToken.AccessToken at.Email = email at.AccessToken = updatedJWT.AccessToken at.AccessExpiresAt = now.Add(time.Duration(updatedJWT.ExpiresIn) * time.Second).Format(time.RFC3339) // Refresh token may also be rotated. if at.RefreshToken != updatedJWT.RefreshToken { if data.Flags.Verbose { text.Info(data.Output, "Your refresh token was also updated") text.Break(data.Output) } at.RefreshToken = updatedJWT.RefreshToken at.RefreshExpiresAt = now.Add(time.Duration(updatedJWT.RefreshExpiresIn) * time.Second).Format(time.RFC3339) } authcmd.EnrichWithTokenSelf(data, at) data.Config.SetAuthToken(name, at) if err := data.Config.Write(data.ConfigPath); err != nil { data.ErrLog.Add(err) return false, fmt.Errorf("error saving config file: %w", err) } return false, nil } // authRelatedCommands lists the top-level command families related to // authentication. Expiry warnings are suppressed for these commands to avoid // noise during login, token management, and identity flows. // This matches the set hidden by FASTLY_DISABLE_AUTH_COMMAND (pkg/env/env.go). var authRelatedCommands = []string{"auth", "auth-token", "sso", "profile", "whoami"} // checkTokenExpirationWarning prints a warning to stderr if the active stored // token is about to expire. Only fires for SourceAuth tokens; env/flag tokens // are opaque. Suppressed for auth-related commands and when --quiet is active. // In --json mode the warning still fires (written to stderr via data.ErrOutput). func checkTokenExpirationWarning(data *global.Data, commandName string) { if data.Flags.Quiet { return } if isAuthRelatedCommand(commandName) { return } _, src := data.Token() if src != lookup.SourceAuth { return } name := data.AuthTokenName() if name == "" { name = data.Config.Auth.Default } at := data.Config.GetAuthToken(name) if at == nil { return } status, expires, err := authcmd.GetExpirationStatus(at, time.Now()) if err != nil && data.ErrLog != nil { data.ErrLog.Add(err) } if status != authcmd.StatusExpiringSoon { return } summary := authcmd.ExpirationSummary(status, expires, time.Now()) remediation := authcmd.ExpirationRemediation(at.Type) label := "" if at.RefreshExpiresAt != "" { label = "session " } text.Warning(data.ErrOutput, "Your active token %s%s. %s\n", label, summary, remediation) } // isAuthRelatedCommand reports whether commandName belongs to an auth-related // command family. Matches both bare commands ("auth") and subcommands ("auth list"). func isAuthRelatedCommand(commandName string) bool { for _, prefix := range authRelatedCommands { if commandName == prefix || strings.HasPrefix(commandName, prefix+" ") { return true } } return false } // ssoAuthentication invokes the SSO runner to handle authentication. func ssoAuthentication(outputMessage string, data *global.Data, forceReAuth bool) (token string, tokenSource lookup.Source, err error) { if data.SSORunner == nil { return "", lookup.SourceUndefined, fmt.Errorf("SSO runner is not configured") } skipPrompt := false 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 } skipPrompt = true } if err := data.SSORunner(data.Input, data.Output, forceReAuth, skipPrompt); err != nil { return token, tokenSource, fmt.Errorf("failed to authenticate: %w", err) } text.Break(data.Output) token, tokenSource = data.Token() if tokenSource == lookup.SourceUndefined { return token, tokenSource, fsterr.ErrNoToken() } return token, tokenSource, nil } func promptForAuth(data *global.Data) (string, lookup.Source, error) { text.Important(data.Output, "This command requires authentication to access your Fastly account.") text.Break(data.Output) if !env.AuthCommandDisabled() { text.Output(data.Output, "If you prefer SSO, run: fastly auth login --sso --token \n") } text.Output(data.Output, "Otherwise, paste an API token now. It will be stored as your default auth token.\n") if env.AuthCommandDisabled() { text.Output(data.Output, "You can also set %s.\n", env.APIToken) } else { text.Output(data.Output, "You can also pass --token or set %s.\n", env.APIToken) } text.Output(data.Output, "An API token can be generated at: https://manage.fastly.com/account/personal/tokens\n") text.Output(data.Output, "Learn more: fastly.help/cli/cli-auth\n\n") token, err := text.InputSecure(data.Output, "Paste your API token: ", data.Input) if err != nil { return "", lookup.SourceUndefined, fmt.Errorf("error reading token input: %w", err) } if token == "" { return "", lookup.SourceUndefined, fsterr.ErrNoToken() } name, md, err := authcmd.StoreStaticToken(data, token) if err != nil { return "", lookup.SourceUndefined, err } text.Success(data.Output, "Authenticated as %s (token stored as %q)", md.Email, name) text.Info(data.Output, "Token saved to %s", data.ConfigPath) return token, lookup.SourceAuth, nil } func displayToken(tokenSource lookup.Source, data *global.Data) { 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.SourceAuth: name := data.AuthTokenName() if name != "" { fmt.Fprintf(data.Output, "Fastly API token provided via config file (auth: %s)\n\n", name) } else { fmt.Fprintf(data.Output, "Fastly API token provided via config file (auth)\n\n") } case lookup.SourceUndefined, lookup.SourceDefault, lookup.SourceFile: 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(tokenSource lookup.Source, out io.Writer) { if tokenSource == lookup.SourceAuth { if fi, err := os.Stat(config.FilePath); err == nil { if mode := fi.Mode().Perm(); mode > config.FilePermissions { 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") } } } } 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, lookup.SourceAuth: fallthrough default: fmt.Fprintf(out, "Fastly API endpoint: %s\n", 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 nil, nil, fmt.Errorf("error constructing Fastly API client: %w", err) } rtsClient, err = fastly.NewRealtimeStatsClientForEndpoint(token, fastly.DefaultRealtimeStatsEndpoint) if err != nil { return nil, nil, fmt.Errorf("error constructing Fastly realtime stats client: %w", err) } return apiClient, rtsClient, nil } func checkForUpdates(av github.AssetVersioner, commandName string) func(io.Writer) { if av != nil && commandName != "update" && !version.IsPreRelease(revision.AppVersion) { return update.CheckAsync(revision.AppVersion, av) } return func(_ io.Writer) { // no-op } } // 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 hash-files", "compute publish", "compute serve": return true } return false } // commandRequiresAuthServer determines if the command to be executed is one that // requires just the authentication server to be running. func commandRequiresAuthServer(command string, args []string) bool { switch command { case "auth login": return slices.Contains(args, "--sso") case "profile create", "profile switch", "profile update", "sso": return true } return false } // 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 pack", "compute serve", "compute validate": return false } commandName = strings.Split(commandName, " ")[0] switch commandName { case "auth", "config", "install", "profile", "sso", "update", "version": return false } return true } // 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 } // 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] } } // 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 } // commandSuppressesVerbose checks if the given command suppresses verbose output. // This uses type assertion to check if the command has an embedded Base struct with SuppressVerbose set. func commandSuppressesVerbose(command argparser.Command) bool { // Try to access the SuppressesVerbose method which is available on commands that embed argparser.Base type verboseSuppressor interface { SuppressesVerbose() bool } if vs, ok := command.(verboseSuppressor); ok { return vs.SuppressesVerbose() } return false } ================================================ FILE: pkg/app/run_test.go ================================================ package app_test import ( "bufio" "bytes" "context" "encoding/json" "io" "os" "strings" "testing" "time" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/app" "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/revision" "github.com/fastly/cli/pkg/testutil" ) // 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: "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: "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 `, }, { Name: "shell evaluate completion options", Args: "--completion-bash", WantOutput: `help auth apisecurity compute config config-store config-store-entry dashboard domain install ip-list kv-store kv-store-entry log-tail ngwaf object-storage pops products secret-store secret-store-entry service stats tls-config tls-custom tls-platform tls-subscription tools update user version whoami `, }, } for testcaseIdx := range scenarios { testcase := &scenarios[testcaseIdx] t.Run(testcase.Name, func(t *testing.T) { var ( stdout bytes.Buffer stderr bytes.Buffer ) // 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) } w.Close() os.Stdout = old out := <-outC testutil.AssertString(t, testcase.WantOutput, stripTrailingSpace(out)) }) } } // TestExecQuietSuppressesExpiryWarning exercises the full Exec path to verify // that --quiet suppresses the expiration warning end-to-end. func TestExecQuietSuppressesExpiryWarning(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("config -l --quiet") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { data := testutil.MockGlobalData(args, &stdout) // Set the default token to expire soon so a warning would fire without --quiet. data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339) return data, nil } err := app.Run(args, nil) if err != nil { t.Fatalf("app.Run returned unexpected error: %v", err) } output := stdout.String() if strings.Contains(output, "expires in") { t.Errorf("--quiet should suppress expiry warning, but got: %s", output) } } // TestExecConfigShowsExpiryWarning is a companion test verifying the warning // does appear for a non-quiet, non-auth command when the token is expiring. func TestExecConfigShowsExpiryWarning(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("config -l") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { data := testutil.MockGlobalData(args, &stdout) data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339) return data, nil } err := app.Run(args, nil) if err != nil { t.Fatalf("app.Run returned unexpected error: %v", err) } output := stdout.String() if !strings.Contains(output, "expires in") { t.Errorf("expected expiry warning for config command, got: %s", output) } } // TestExecJSONLeavesStdoutCleanAndWritesWarningToStderr verifies that in // --json mode, the expiry warning is written to stderr (not stdout) so it // does not corrupt JSON output. Because the config command does not register // --json as a flag, we simulate the effect by pre-setting Flags.JSON (which // is what Exec does when it sees --json in the args). func TestExecJSONLeavesStdoutCleanAndWritesWarningToStderr(t *testing.T) { var ( stdout bytes.Buffer stderr bytes.Buffer ) args := testutil.SplitArgs("config -l") app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { data := testutil.MockGlobalData(args, &stdout) data.ErrOutput = &stderr data.Flags.JSON = true data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339) return data, nil } err := app.Run(args, nil) if err != nil { t.Fatalf("app.Run returned unexpected error: %v", err) } if strings.Contains(stdout.String(), "expires in") { t.Errorf("expected stdout free of expiry warning, got: %s", stdout.String()) } if !strings.Contains(stderr.String(), "expires in") { t.Errorf("expected expiry warning on stderr, got: %s", stderr.String()) } } // TestStatsJSONSuppressesUpdateNotice verifies that --json and --format=json on // stats commands suppress the deferred update-check notice, keeping stdout as // clean JSON. This is the regression test for the timing bug where // data.Flags.JSON was set inside Exec but the update check captured the flag // value before Exec ran. func TestStatsJSONSuppressesUpdateNotice(t *testing.T) { origVersion := revision.AppVersion revision.AppVersion = "0.0.1" t.Cleanup(func() { revision.AppVersion = origVersion }) aggregateOK := func(_ context.Context, _ *fastly.GetAggregateInput, o any) error { msg := []byte(`{"status":"success","meta":{},"msg":null,"data":[{"start_time":0}]}`) return json.Unmarshal(msg, o) } for _, flag := range []string{"--json", "--format=json"} { t.Run(flag, func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("stats aggregate " + flag) app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { data := testutil.MockGlobalData(args, &stdout) data.APIClientFactory = mock.APIClient(mock.API{ GetAggregateJSONFn: aggregateOK, }) data.Versioners.CLI = mock.AssetVersioner{AssetVersion: "99.0.0"} return data, nil } err := app.Run(args, nil) if err != nil { t.Fatalf("app.Run returned unexpected error: %v", err) } if strings.Contains(stdout.String(), "new version") { t.Errorf("update notice should be suppressed in JSON mode, got: %s", stdout.String()) } }) } } // TestHelpJSON verifies that `help --json` takes the same early-exit path as // `help --format=json`. func TestHelpJSON(t *testing.T) { for _, flag := range []string{"--json", "--format=json", "--format json"} { t.Run(flag, func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs("help " + flag) app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) if err != nil { t.Fatalf("app.Run returned unexpected error: %v", err) } if !strings.Contains(stdout.String(), `"commands"`) { t.Errorf("expected JSON usage output containing \"commands\", got: %s", stdout.String()) } }) } } // stripTrailingSpace removes any trailing spaces from the multiline str. func stripTrailingSpace(str string) string { buf := bytes.NewBuffer(nil) scan := bufio.NewScanner(strings.NewReader(str)) for scan.Scan() { _, _ = buf.WriteString(strings.TrimRight(scan.Text(), " \t\r\n")) _, _ = buf.WriteString("\n") } return buf.String() } ================================================ FILE: pkg/app/usage.go ================================================ package app import ( "bytes" _ "embed" "encoding/json" "errors" "fmt" "io" "os" "strings" "text/template" "github.com/fastly/kingpin" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/env" 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, 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() } // authGuideTemplate is the template fragment for the auth guide section, // shared between CompactUsageTemplate and VerboseUsageTemplate. const authGuideTemplate = `{{if .Context.SelectedCommand -}} {{if eq .Context.SelectedCommand.Name "auth" -}} {{if .Context.SelectedCommand.Commands -}} {{T "AUTH GUIDE"|Bold}} Quick start: fastly auth login fastly auth login --sso --token Token precedence: --token (raw or stored name) > FASTLY_API_TOKEN > fastly.toml profile > default auth token Stored tokens: fastly auth list fastly auth use Non-interactive usage: fastly service list --token $TOKEN {{end -}} {{end -}} {{end -}} ` // CompactUsageTemplate is the default usage template, rendered when users type // e.g. just `fastly`, or use the `-h, --help` flag. var CompactUsageTemplate = `{{define "FormatCommand" -}} {{if .FlagSummary}} {{.FlagSummary}}{{end -}} {{range .Args}} {{if not .Required}}[{{end}}<{{.Name}}>{{if .Value|IsCumulative}} ...{{end}}{{if not .Required}}]{{end}}{{end -}} {{end -}} {{define "FormatCommandList" -}} {{range . -}} {{if not .Hidden -}} {{.Depth|Indent}}{{.Name}}{{if .Default}}*{{end}}{{template "FormatCommand" .}} {{end -}} {{template "FormatCommandList" .Commands -}} {{end -}} {{end -}} {{define "FormatUsage" -}} {{template "FormatCommand" .}}{{if .Commands}} [ ...]{{end}} {{if .Help}} {{.Help|Wrap 0 -}} {{end -}} {{end -}} {{define "FormatCommandName" -}} {{if .Parent}}{{if .Parent.Parent}}{{.Parent.Parent.Name}} {{end -}}{{.Parent.Name}} {{end -}}{{.Name -}} {{end -}} {{if .Context.SelectedCommand -}} {{T "USAGE"|Bold}} {{.App.Name}} {{template "FormatCommandName" .Context.SelectedCommand}}{{ template "FormatUsage" .Context.SelectedCommand}} {{else -}} {{T "USAGE"|Bold}} {{.App.Name}}{{template "FormatUsage" .App}} {{end -}} {{if .Context.Flags|RequiredFlags -}} {{T "REQUIRED FLAGS"|Bold}} {{.Context.Flags|RequiredFlags|FlagsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.Flags|OptionalFlags -}} {{T "OPTIONAL FLAGS"|Bold}} {{.Context.Flags|OptionalFlags|FlagsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.Flags|GlobalFlags -}} {{T "GLOBAL FLAGS"|Bold}} {{.Context.Flags|GlobalFlags|FlagsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.Args -}} {{T "ARGS"|Bold}} {{.Context.Args|ArgsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.SelectedCommand -}} {{if .Context.SelectedCommand.Commands -}} {{T "COMMANDS"|Bold}} {{.Context.SelectedCommand}} {{.Context.SelectedCommand.Commands|CommandsToTwoColumns|FormatTwoColumns}} {{end -}} {{else if .App.Commands -}} {{T "COMMANDS"|Bold}} {{.App.Commands|CommandsToTwoColumns|FormatTwoColumns}} {{end -}} ` + authGuideTemplate + ` {{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 { gf := globalFlags() flags := []*kingpin.ClauseModel{} for _, flag := range f { if gf[flag.Name] { flags = append(flags, flag) } } return flags }, "OptionalFlags": func(f []*kingpin.ClauseModel) []*kingpin.ClauseModel { gf := globalFlags() optionalFlags := []*kingpin.ClauseModel{} for _, flag := range f { if !flag.Required && !flag.Hidden && !gf[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. func globalFlags() map[string]bool { m := map[string]bool{ "accept-defaults": true, "account": true, "auto-yes": true, "debug-mode": true, "endpoint": true, "help": true, "non-interactive": true, "quiet": true, "verbose": true, } if !env.AuthCommandDisabled() { m["token"] = true } return m } // VerboseUsageTemplate is the full-fat usage template, rendered when users type // the long-form e.g. `fastly help service`. const VerboseUsageTemplate = `{{define "FormatCommands" -}} {{range .FlattenedCommands -}} {{ if not .Hidden }} {{.CmdSummary|Bold }} {{.Help|Wrap 4 }} {{if .Flags -}} {{with .Flags|FlagsToTwoColumns}}{{FormatTwoColumnsWithIndent . 4 2}}{{end -}} {{end -}} {{end -}} {{end -}} {{end -}} {{define "FormatUsage" -}} {{.AppSummary}} {{if .Help}} {{.Help|Wrap 0 -}} {{end -}} {{end -}} {{if .Context.SelectedCommand -}} {{T "USAGE"|Bold}} {{.App.Name}} {{.App.FlagSummary}} {{.Context.SelectedCommand.CmdSummary}} {{else}} {{- T "USAGE"|Bold}} {{template "FormatUsage" .App -}} {{end -}} {{if .Context.Flags|GlobalFlags }} {{T "GLOBAL FLAGS"|Bold}} {{.Context.Flags|GlobalFlags|FlagsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.Args -}} {{T "ARGS"|Bold}} {{.Context.Args|ArgsToTwoColumns|FormatTwoColumns}} {{end -}} {{if .Context.SelectedCommand -}} {{if len .Context.SelectedCommand.Commands -}} {{T "SUBCOMMANDS\n"|Bold -}} {{ template "FormatCommands" .Context.SelectedCommand}} {{end -}} {{else if .App.Commands -}} {{T "COMMANDS"|Bold -}} {{template "FormatCommands" .App}} {{end -}} ` + authGuideTemplate + ` {{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 compute") fmt.Fprintln(&buf, "\tfastly compute --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 } } ================================================ FILE: pkg/app/usage_auth_help_test.go ================================================ package app_test import ( "bytes" stderrors "errors" "io" "strings" "testing" "github.com/fastly/cli/pkg/app" "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/testutil" ) func TestAuthGuideBlock(t *testing.T) { cases := []struct { name string args string wantGuide bool }{ { name: "auth --help includes AUTH GUIDE", args: "auth --help", wantGuide: true, }, { name: "auth login --help excludes AUTH GUIDE", args: "auth login --help", wantGuide: false, }, { name: "help auth includes AUTH GUIDE", args: "help auth", wantGuide: true, }, { name: "service --help excludes AUTH GUIDE", args: "service --help", wantGuide: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer args := testutil.SplitArgs(tc.args) app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { return testutil.MockGlobalData(args, &stdout), nil } err := app.Run(args, nil) var output string if err != nil { var re errors.RemediationError if stderrors.As(err, &re) { output = re.Prefix } } output += stdout.String() if tc.wantGuide && !strings.Contains(output, "AUTH GUIDE") { t.Errorf("expected AUTH GUIDE in output, got:\n%s", output) } if tc.wantGuide && !strings.Contains(output, "--sso --token") { t.Errorf("expected AUTH GUIDE to contain '--sso --token' quick-start example, got:\n%s", output) } if !tc.wantGuide && strings.Contains(output, "AUTH GUIDE") { t.Errorf("did not expect AUTH GUIDE in output, got:\n%s", output) } }) } } ================================================ FILE: pkg/argparser/cmd.go ================================================ package argparser import ( "fmt" "io" "github.com/fastly/go-fastly/v15/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 SuppressVerbose bool } // 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() } // SuppressesVerbose returns true if this command should suppress verbose output. func (b Base) SuppressesVerbose() bool { return b.SuppressVerbose } // 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`, `help --format json`, or `help --json`. func ArgsIsHelpJSON(args []string) bool { switch len(args) { case 2: if args[0] == "help" && (args[1] == "--format=json" || args[1] == "--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, "--verbose": 0, "-v": 0, } if !env.AuthCommandDisabled() { globals["--token"] = 1 globals["-t"] = 1 } var total int for _, a := range args { for k := range globals { if a == k { total++ total += globals[k] } } } return len(args) == total } ================================================ FILE: pkg/argparser/cmd_test.go ================================================ package argparser_test import ( "testing" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/env" ) func TestIsGlobalFlagsOnly(t *testing.T) { t.Setenv(env.DisableAuthCommand, "") tests := []struct { name string args []string want bool }{ { name: "verbose only", args: []string{"--verbose"}, want: true, }, { name: "token with value", args: []string{"--token", "abc"}, want: true, }, { name: "short token with value", args: []string{"-t", "abc"}, want: true, }, { name: "subcommand present", args: []string{"--verbose", "version"}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := argparser.IsGlobalFlagsOnly(tt.args); got != tt.want { t.Errorf("IsGlobalFlagsOnly(%v) = %v, want %v", tt.args, got, tt.want) } }) } } func TestArgsIsHelpJSON(t *testing.T) { tests := []struct { name string args []string want bool }{ { name: "help --format=json", args: []string{"help", "--format=json"}, want: true, }, { name: "help --format json", args: []string{"help", "--format", "json"}, want: true, }, { name: "help --json", args: []string{"help", "--json"}, want: true, }, { name: "help only", args: []string{"help"}, want: false, }, { name: "help --format=yaml", args: []string{"help", "--format=yaml"}, want: false, }, { name: "help --json extra", args: []string{"help", "--json", "extra"}, want: false, }, { name: "empty", args: []string{}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := argparser.ArgsIsHelpJSON(tt.args); got != tt.want { t.Errorf("ArgsIsHelpJSON(%v) = %v, want %v", tt.args, got, tt.want) } }) } } func TestIsGlobalFlagsOnlyDisabledAuth(t *testing.T) { t.Setenv(env.DisableAuthCommand, "1") tests := []struct { name string args []string want bool }{ { name: "token is not global when auth disabled", args: []string{"--token", "abc"}, want: false, }, { name: "short token is not global when auth disabled", args: []string{"-t", "abc"}, want: false, }, { name: "other globals still work", args: []string{"--verbose"}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := argparser.IsGlobalFlagsOnly(tt.args); got != tt.want { t.Errorf("IsGlobalFlagsOnly(%v) = %v, want %v", tt.args, got, tt.want) } }) } } ================================================ FILE: pkg/argparser/common.go ================================================ 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" // FlagNGWAFAlertID is the alert ID. FlagNGWAFAlertID = "alert-id" // FlagNGWAFAlertIDDesc is the alert ID flag description. FlagNGWAFAlertIDDesc = "Alphanumeric string identifying the alert" // FlagNGWAFWorkspaceID is the workspace ID. FlagNGWAFWorkspaceID = "workspace-id" // FlagNGWAFWorkspaceIDDesc is the workspace ID flag description. FlagNGWAFWorkspaceIDDesc = "Alphanumeric string identifying the NGWAF Workspace (falls back to FASTLY_WORKSPACE_ID)" // 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', 'staged', 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, } } ================================================ FILE: pkg/argparser/doc.go ================================================ // Package argparser contains helper abstractions for working with the CLI parser. package argparser ================================================ FILE: pkg/argparser/fixtures/content_test.txt ================================================ This is a test ================================================ FILE: pkg/argparser/flags.go ================================================ package argparser import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "github.com/fastly/go-fastly/v15/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. // // Supported values: // - Numeric version (e.g., "1", "2", "42"): Returns the specified version // - "active": Returns the currently active version // - "staged": Returns the currently staged version // - "latest": Returns the highest version number (latest version) // - Omitted (no flag provided): Returns active version, falls back to latest if no active version exists func (sv *OptionalServiceVersion) Parse(sid string, client api.Interface) (*fastly.Version, error) { // When no --version flag is provided (WasSet=false), default to "active" to preserve // the original behavior of trying active version first, with fallback to latest. if sv.Value == "" && !sv.WasSet { sv.Value = "active" } // When a specific numeric version is provided, use it directly. if n, err := strconv.Atoi(sv.Value); err == nil { return client.GetVersion(context.TODO(), &fastly.GetVersionInput{ ServiceID: sid, ServiceVersion: n, }) } switch strings.ToLower(sv.Value) { case "active": serviceDetails, err := client.GetServiceDetails(context.TODO(), &fastly.GetServiceDetailsInput{ ServiceID: sid, Filters: []fastly.ServiceDetailsFilter{ {Key: "versions.active", Value: true}, }, }) if err != nil { return nil, fmt.Errorf("error getting service details: %w", err) } // If active version exists, return it if serviceDetails.ActiveVersion != nil { return serviceDetails.ActiveVersion, nil } // If flag was explicitly set to "active" but no active version exists, return error if sv.WasSet { return nil, fmt.Errorf("no active service version found") } // If flag was not explicitly set and there's no active version, fall through to latest fallthrough case "latest": vs, err := client.ListVersions(context.TODO(), &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("no service versions available") } // Sort versions into descending order to ensure we get the latest sort.Slice(vs, func(i, j int) bool { return fastly.ToValue(vs[i].Number) > fastly.ToValue(vs[j].Number) }) return vs[0], nil case "staged": serviceDetails, err := client.GetServiceDetails(context.TODO(), &fastly.GetServiceDetailsInput{ ServiceID: sid, Filters: []fastly.ServiceDetailsFilter{ {Key: "versions.staged", Value: true}, }, }) if err != nil { return nil, fmt.Errorf("error getting service details: %w", err) } if serviceDetails.Version == nil { return nil, fmt.Errorf("no staged service version found") } return serviceDetails.Version, nil default: return nil, fmt.Errorf("invalid version value %q: must be a version number, \"latest\", \"active\", or \"staged\"", sv.Value) } } // 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(context.TODO(), &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 } // OptionalWorkspaceID represents a Fastly NGWAF Workspace ID. type OptionalWorkspaceID struct { OptionalString } // Parse returns a workspace ID either from a flag or from a user defined // environment variable (see pkg/env/env.go). // // NOTE: Will fallback to FASTLY_WORKSPACE_ID environment variable if no flag value set. func (sv *OptionalWorkspaceID) Parse() error { if sv.Value == "" { if e := os.Getenv(env.WorkspaceID); e != "" { sv.Value = e return nil } return fsterr.ErrNoWorkspaceID } 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, } } stateUnknown := v.Active == nil && v.Locked == nil if ac.Value && (stateUnknown || v.Active != nil && *v.Active || v.Locked != nil && *v.Locked) { version, err := client.CloneVersion(context.TODO(), &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 } // 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) } func ConvertBoolFromStringFlag(value string, argName string) (*bool, error) { switch value { case "true": return fastly.ToPointer(true), nil case "false": return fastly.ToPointer(false), nil default: return nil, fmt.Errorf("'%s' flag must be one of the following [true, false]", argName) } } func ConvertOrderFromStringFlag(value string, argName string) (string, error) { switch value { case "asc": return "", nil case "desc": return "-", nil default: return "", fmt.Errorf("'%s' flag must be one of the following [asc, desc]", argName) } } ================================================ FILE: pkg/argparser/flags_test.go ================================================ package argparser_test import ( "bytes" "context" "fmt" "io" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/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, }, "staged": { flagValue: "staged", wantVersion: 4, }, "empty with WasSet": { flagValue: "", errExpected: true, }, "omitted": { flagOmitted: true, wantVersion: 1, // Returns active version when flag not provided (falls back to latest if no active) }, "specific version": { flagValue: "2", wantVersion: 2, }, "specific version not found": { flagValue: "999", errExpected: true, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { sv := &argparser.OptionalServiceVersion{} if !c.flagOmitted { sv.OptionalString = argparser.OptionalString{ Optional: argparser.Optional{WasSet: true}, Value: c.flagValue, } } v, err := sv.Parse("123", mock.API{ GetVersionFn: testutil.GetVersion, ListVersionsFn: listVersions, GetServiceDetailsFn: getServiceDetails, }) if err != nil { if c.errExpected { return } t.Fatalf("unexpected error: %v", err) } 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. // // Versions are returned in descending order by version number (highest first), // matching the real Fastly API behavior. // Version 4 (staged), Version 3 (editable), Version 2 (locked), Version 1 (active). func listVersions(_ context.Context, i *fastly.ListVersionsInput) ([]*fastly.Version, error) { return []*fastly.Version{ { ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-04T01: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(2), Locked: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z"), }, { ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), }, }, nil } // getServiceDetails returns service details with active and latest version info. func getServiceDetails(_ context.Context, i *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { result := &fastly.ServiceDetail{ ServiceID: fastly.ToPointer(i.ServiceID), } // Check if specific version is requested if i.Version != nil { result.Version = &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: i.Version, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), } return result, nil } // Check filters for _, filter := range i.Filters { if filter.Key == "versions.active" && filter.Value { result.ActiveVersion = &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), } return result, nil } if filter.Key == "versions.staged" && filter.Value { result.Version = &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-04T01:00:00Z"), } return result, nil } } // Default: return both active and latest result.ActiveVersion = &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), } result.Version = &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-04T01:00:00Z"), } return result, nil } 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), Active: fastly.ToPointer(false), Locked: fastly.ToPointer(false), }, 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, }, "version state unknown with autoclone": { version: &fastly.Version{ Number: fastly.ToPointer(1), Active: nil, Locked: nil, }, wantVersion: 2, }, } 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 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 EnvVars map[string]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, EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, }, "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(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &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(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { // Set environment variables for this test case for k, v := range c.EnvVars { t.Setenv(k, v) } 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(_ context.Context, i *fastly.CloneVersionInput) (*fastly.Version, error) { return func(_ context.Context, 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) } ================================================ FILE: pkg/auth/auth.go ================================================ 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/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" // redirectPath is the path in the internal webserver which will receive the authorization code. const redirectPath = "/callback" // redirectURL is the endpoint the auth provider will pass an authorization code to. const redirectURL = "http://localhost:8080" + redirectPath // 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, redirectURL, // 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) { if r.URL.Path != redirectPath { w.WriteHeader(http.StatusBadRequest) return } switch r.Method { case http.MethodOptions: w.Header().Add("Access-Control-Allow-Origin", "accounts.fastly.com") w.WriteHeader(http.StatusOK) return case http.MethodGet: // handled below default: w.WriteHeader(http.StatusBadRequest) return } 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 } ================================================ FILE: pkg/auth/doc.go ================================================ // Package auth contains types to authenticate with Fastly. package auth ================================================ FILE: pkg/check/check.go ================================================ // Package check provides functions for validating installed binaries. package check import ( "time" ) // Stale validates if the given time is older than the given duration. // // EXAMPLE: // dur is a string like "24h", "10m" or "5s". func Stale(lastVersionCheck string, dur string) bool { 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 } lastChecked, _ := time.Parse(time.RFC3339, lastVersionCheck) return lastChecked.Add(ttl).Before(time.Now()) } ================================================ FILE: pkg/commands/alias/acl/create.go ================================================ package acl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/acl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service acl create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/acl/delete.go ================================================ package acl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/acl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service acl delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/acl/describe.go ================================================ package acl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/acl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service acl describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/acl/doc.go ================================================ // Package acl contains deprecated aliases for the 'service acl' commands. package acl ================================================ FILE: pkg/commands/alias/acl/list.go ================================================ package acl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/acl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service acl list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/acl/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/acl/update.go ================================================ package acl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/acl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service acl update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/aclentry/create.go ================================================ package aclentry import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/aclentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service aclentry create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/aclentry/delete.go ================================================ package aclentry import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/aclentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service aclentry delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/aclentry/describe.go ================================================ package aclentry import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/aclentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service aclentry describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/aclentry/doc.go ================================================ // Package aclentry contains deprecated aliases for the 'service aclentry' commands. package aclentry ================================================ FILE: pkg/commands/alias/aclentry/list.go ================================================ package aclentry import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/aclentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service aclentry list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/aclentry/root.go ================================================ 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 = "aclentry" // 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/aclentry/update.go ================================================ package aclentry import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/aclentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service aclentry update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/create.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service alert create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/delete.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service alert delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/describe.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service alert describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/doc.go ================================================ // Package alerts contains deprecated aliases for the 'service alert' commands. package alerts ================================================ FILE: pkg/commands/alias/alerts/history.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListHistoryCommand wraps the ListHistoryCommand from the newcmd package. type ListHistoryCommand struct { *newcmd.ListHistoryCommand } // NewListCommand returns a usable command registered under the parent. func NewListHistoryCommand(parent argparser.Registerer, g *global.Data) *ListHistoryCommand { c := ListHistoryCommand{newcmd.NewListHistoryCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListHistoryCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service alert list history' command instead.") } return c.ListHistoryCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/list.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service alert list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/alerts/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/alerts/update.go ================================================ package alerts import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service alert update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/backend/create.go ================================================ package backend import ( "io" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the servicebackend package. type CreateCommand struct { *servicebackend.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{servicebackend.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service backend create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/backend/delete.go ================================================ package backend import ( "io" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the servicebackend package. type DeleteCommand struct { *servicebackend.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{servicebackend.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service backend delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/backend/describe.go ================================================ package backend import ( "io" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the servicebackend package. type DescribeCommand struct { *servicebackend.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{servicebackend.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service backend describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/backend/doc.go ================================================ // Package backend contains the 'backend' alias for the 'service backend' command. package backend ================================================ FILE: pkg/commands/alias/backend/list.go ================================================ package backend import ( "io" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the servicebackend package. type ListCommand struct { *servicebackend.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{servicebackend.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service backend list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/backend/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/backend/update.go ================================================ package backend import ( "io" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the servicebackend package. type UpdateCommand struct { *servicebackend.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{servicebackend.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service backend update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionary/create.go ================================================ package dictionary import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/dictionary" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionary/delete.go ================================================ package dictionary import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/dictionary" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionary/describe.go ================================================ package dictionary import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/dictionary" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service dictionary describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionary/doc.go ================================================ // Package dictionary contains deprecated aliases for the 'service dictionary' commands. package dictionary ================================================ FILE: pkg/commands/alias/dictionary/list.go ================================================ package dictionary import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/dictionary" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service dictionary list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionary/root.go ================================================ 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 FastlyService Dictionaries").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/dictionary/update.go ================================================ package dictionary import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/dictionary" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionaryentry/create.go ================================================ package dictionaryentry import ( "io" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the servicedictionaryentry package. type CreateCommand struct { *servicedictionaryentry.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{servicedictionaryentry.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary-entry create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionaryentry/delete.go ================================================ package dictionaryentry import ( "io" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the servicedictionaryentry package. type DeleteCommand struct { *servicedictionaryentry.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{servicedictionaryentry.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary-entry delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionaryentry/describe.go ================================================ package dictionaryentry import ( "io" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the servicedictionaryentry package. type DescribeCommand struct { *servicedictionaryentry.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{servicedictionaryentry.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service dictionary-entry describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionaryentry/doc.go ================================================ // Package dictionaryentry contains the 'dictionary-entry' alias for the 'service dictionary-entry' command. package dictionaryentry ================================================ FILE: pkg/commands/alias/dictionaryentry/list.go ================================================ package dictionaryentry import ( "io" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the servicedictionaryentry package. type ListCommand struct { *servicedictionaryentry.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{servicedictionaryentry.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service dictionary-entry list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/dictionaryentry/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/dictionaryentry/update.go ================================================ package dictionaryentry import ( "io" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the servicedictionaryentry package. type UpdateCommand struct { *servicedictionaryentry.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{servicedictionaryentry.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service dictionary-entry update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/doc.go ================================================ // Package alias contains aliases for commands which have been renamed/relocated and are deprecated. package alias ================================================ FILE: pkg/commands/alias/healthcheck/create.go ================================================ package healthcheck import ( "io" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the servicehealthcheck package. type CreateCommand struct { *servicehealthcheck.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{servicehealthcheck.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service healthcheck create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/healthcheck/delete.go ================================================ package healthcheck import ( "io" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the servicehealthcheck package. type DeleteCommand struct { *servicehealthcheck.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{servicehealthcheck.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service healthcheck delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/healthcheck/describe.go ================================================ package healthcheck import ( "io" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the servicehealthcheck package. type DescribeCommand struct { *servicehealthcheck.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{servicehealthcheck.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service healthcheck describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/healthcheck/doc.go ================================================ // Package healthcheck contains the 'healthcheck' alias for the 'service healthcheck' command. package healthcheck ================================================ FILE: pkg/commands/alias/healthcheck/list.go ================================================ package healthcheck import ( "io" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the servicehealthcheck package. type ListCommand struct { *servicehealthcheck.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{servicehealthcheck.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service healthcheck list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/healthcheck/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/healthcheck/update.go ================================================ package healthcheck import ( "io" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the servicehealthcheck package. type UpdateCommand struct { *servicehealthcheck.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{servicehealthcheck.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service healthcheck update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/imageoptimizerdefaults/doc.go ================================================ // Package imageoptimizerdefaults contains deprecated aliases for the 'service imageoptimizerdefaults' commands. package imageoptimizerdefaults ================================================ FILE: pkg/commands/alias/imageoptimizerdefaults/get.go ================================================ package imageoptimizerdefaults import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/imageoptimizerdefaults" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // GetCommand wraps the GetCommand from the newcmd package. type GetCommand struct { *newcmd.GetCommand } // NewGetCommand returns a usable command registered under the parent. func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { c := GetCommand{newcmd.NewGetCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *GetCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service imageoptimizerdefaults get' command instead.") return c.GetCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/imageoptimizerdefaults/root.go ================================================ package imageoptimizerdefaults 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 = "imageoptimizerdefaults" // 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 Image Optimizer Defaults").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/imageoptimizerdefaults/update.go ================================================ package imageoptimizerdefaults import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/imageoptimizerdefaults" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service imageoptimizerdefaults update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/azureblob/create.go ================================================ package azureblob import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/azureblob" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging azureblob create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/azureblob/delete.go ================================================ package azureblob import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/azureblob" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging azureblob delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/azureblob/describe.go ================================================ package azureblob import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/azureblob" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging azureblob describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/azureblob/doc.go ================================================ // Package azureblob contains deprecated aliases for the 'service logging azureblob' commands. package azureblob ================================================ FILE: pkg/commands/alias/logging/azureblob/list.go ================================================ package azureblob import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/azureblob" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging azureblob list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/azureblob/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/azureblob/update.go ================================================ package azureblob import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/azureblob" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging azureblob update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/bigquery/create.go ================================================ package bigquery import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/bigquery" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging bigquery create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/bigquery/delete.go ================================================ package bigquery import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/bigquery" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging bigquery delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/bigquery/describe.go ================================================ package bigquery import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/bigquery" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging bigquery describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/bigquery/doc.go ================================================ // Package bigquery contains deprecated aliases for the 'service logging bigquery' commands. package bigquery ================================================ FILE: pkg/commands/alias/logging/bigquery/list.go ================================================ package bigquery import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/bigquery" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging bigquery list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/bigquery/root.go ================================================ 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 Google BigQuery logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/bigquery/update.go ================================================ package bigquery import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/bigquery" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging bigquery update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/create.go ================================================ package cloudfiles import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging cloudfiles create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/delete.go ================================================ package cloudfiles import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging cloudfiles delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/describe.go ================================================ package cloudfiles import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging cloudfiles describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/doc.go ================================================ // Package cloudfiles contains deprecated aliases for the 'service logging cloudfiles' commands. package cloudfiles ================================================ FILE: pkg/commands/alias/logging/cloudfiles/list.go ================================================ package cloudfiles import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging cloudfiles list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/root.go ================================================ 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 Rackspace Cloud Files logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/cloudfiles/update.go ================================================ package cloudfiles import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging cloudfiles update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/datadog/create.go ================================================ package datadog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging datadog create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/datadog/delete.go ================================================ package datadog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging datadog delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/datadog/describe.go ================================================ package datadog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging datadog describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/datadog/doc.go ================================================ // Package datadog contains deprecated aliases for the 'service logging datadog' commands. package datadog ================================================ FILE: pkg/commands/alias/logging/datadog/list.go ================================================ package datadog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging datadog list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/datadog/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/datadog/update.go ================================================ package datadog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging datadog update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/digitalocean/create.go ================================================ package digitalocean import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging digitalocean create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/digitalocean/delete.go ================================================ package digitalocean import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging digitalocean delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/digitalocean/describe.go ================================================ package digitalocean import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging digitalocean describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/digitalocean/doc.go ================================================ // Package digitalocean contains deprecated aliases for the 'service logging digitalocean' commands. package digitalocean ================================================ FILE: pkg/commands/alias/logging/digitalocean/list.go ================================================ package digitalocean import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging digitalocean list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/digitalocean/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/digitalocean/update.go ================================================ package digitalocean import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging digitalocean update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/doc.go ================================================ // Package logging contains deprecated aliases for the 'service logging' commands. package logging ================================================ FILE: pkg/commands/alias/logging/elasticsearch/create.go ================================================ package elasticsearch import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging elasticsearch create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/elasticsearch/delete.go ================================================ package elasticsearch import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging elasticsearch delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/elasticsearch/describe.go ================================================ package elasticsearch import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging elasticsearch describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/elasticsearch/doc.go ================================================ // Package elasticsearch contains deprecated aliases for the 'service logging elasticsearch' commands. package elasticsearch ================================================ FILE: pkg/commands/alias/logging/elasticsearch/list.go ================================================ package elasticsearch import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging elasticsearch list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/elasticsearch/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/elasticsearch/update.go ================================================ package elasticsearch import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging elasticsearch update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/ftp/create.go ================================================ package ftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/ftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging ftp create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/ftp/delete.go ================================================ package ftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/ftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging ftp delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/ftp/describe.go ================================================ package ftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/ftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging ftp describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/ftp/doc.go ================================================ // Package ftp contains deprecated aliases for the 'service logging ftp' commands. package ftp ================================================ FILE: pkg/commands/alias/logging/ftp/list.go ================================================ package ftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/ftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging ftp list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/ftp/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/ftp/update.go ================================================ package ftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/ftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging ftp update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/gcs/create.go ================================================ package gcs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/gcs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging gcs create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/gcs/delete.go ================================================ package gcs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/gcs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging gcs delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/gcs/describe.go ================================================ package gcs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/gcs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging gcs describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/gcs/doc.go ================================================ // Package gcs contains deprecated aliases for the 'service logging gcs' commands. package gcs ================================================ FILE: pkg/commands/alias/logging/gcs/list.go ================================================ package gcs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/gcs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging gcs list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/gcs/root.go ================================================ 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 Google Cloud Storage logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/gcs/update.go ================================================ package gcs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/gcs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging gcs update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/create.go ================================================ package googlepubsub import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging googlepubsub create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/delete.go ================================================ package googlepubsub import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging googlepubsub delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/describe.go ================================================ package googlepubsub import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging googlepubsub describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/doc.go ================================================ // Package googlepubsub contains deprecated aliases for the 'service logging googlepubsub' commands. package googlepubsub ================================================ FILE: pkg/commands/alias/logging/googlepubsub/list.go ================================================ package googlepubsub import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging googlepubsub list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/googlepubsub/update.go ================================================ package googlepubsub import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging googlepubsub update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/create.go ================================================ package grafanacloudlogs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging grafanacloudlogs create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/delete.go ================================================ package grafanacloudlogs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging grafanacloudlogs delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/describe.go ================================================ package grafanacloudlogs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging grafanacloudlogs describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/doc.go ================================================ // Package grafanacloudlogs contains deprecated aliases for the 'service logging grafanacloudlogs' commands. package grafanacloudlogs ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/list.go ================================================ package grafanacloudlogs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging grafanacloudlogs list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/grafanacloudlogs/update.go ================================================ package grafanacloudlogs import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging grafanacloudlogs update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/heroku/create.go ================================================ package heroku import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/heroku" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging heroku create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/heroku/delete.go ================================================ package heroku import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/heroku" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging heroku delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/heroku/describe.go ================================================ package heroku import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/heroku" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging heroku describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/heroku/doc.go ================================================ // Package heroku contains deprecated aliases for the 'service logging heroku' commands. package heroku ================================================ FILE: pkg/commands/alias/logging/heroku/list.go ================================================ package heroku import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/heroku" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging heroku list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/heroku/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/heroku/update.go ================================================ package heroku import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/heroku" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging heroku update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/honeycomb/create.go ================================================ package honeycomb import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging honeycomb create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/honeycomb/delete.go ================================================ package honeycomb import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging honeycomb delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/honeycomb/describe.go ================================================ package honeycomb import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging honeycomb describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/honeycomb/doc.go ================================================ // Package honeycomb contains deprecated aliases for the 'service logging honeycomb' commands. package honeycomb ================================================ FILE: pkg/commands/alias/logging/honeycomb/list.go ================================================ package honeycomb import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging honeycomb list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/honeycomb/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/honeycomb/update.go ================================================ package honeycomb import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging honeycomb update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/https/create.go ================================================ package https import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/https" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging https create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/https/delete.go ================================================ package https import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/https" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging https delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/https/describe.go ================================================ package https import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/https" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging https describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/https/doc.go ================================================ // Package https contains deprecated aliases for the 'service logging https' commands. package https ================================================ FILE: pkg/commands/alias/logging/https/list.go ================================================ package https import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/https" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging https list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/https/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/https/update.go ================================================ package https import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/https" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging https update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kafka/create.go ================================================ package kafka import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kafka" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kafka create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kafka/delete.go ================================================ package kafka import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kafka" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kafka delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kafka/describe.go ================================================ package kafka import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kafka" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging kafka describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kafka/doc.go ================================================ // Package kafka contains deprecated aliases for the 'service logging kafka' commands. package kafka ================================================ FILE: pkg/commands/alias/logging/kafka/list.go ================================================ package kafka import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kafka" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging kafka list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kafka/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/kafka/update.go ================================================ package kafka import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kafka" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kafka update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kinesis/create.go ================================================ package kinesis import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kinesis" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kinesis create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kinesis/delete.go ================================================ package kinesis import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kinesis" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kinesis delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kinesis/describe.go ================================================ package kinesis import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kinesis" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging kinesis describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kinesis/doc.go ================================================ // Package kinesis contains deprecated aliases for the 'service logging kinesis' commands. package kinesis ================================================ FILE: pkg/commands/alias/logging/kinesis/list.go ================================================ package kinesis import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kinesis" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging kinesis list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/kinesis/root.go ================================================ 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 Fastly service version Amazon Kinesis logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/kinesis/update.go ================================================ package kinesis import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/kinesis" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging kinesis update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/loggly/create.go ================================================ package loggly import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/loggly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging loggly create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/loggly/delete.go ================================================ package loggly import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/loggly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging loggly delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/loggly/describe.go ================================================ package loggly import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/loggly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging loggly describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/loggly/doc.go ================================================ // Package loggly contains deprecated aliases for the 'service logging loggly' commands. package loggly ================================================ FILE: pkg/commands/alias/logging/loggly/list.go ================================================ package loggly import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/loggly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging loggly list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/loggly/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/loggly/update.go ================================================ package loggly import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/loggly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging loggly update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/logshuttle/create.go ================================================ package logshuttle import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging logshuttle create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/logshuttle/delete.go ================================================ package logshuttle import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging logshuttle delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/logshuttle/describe.go ================================================ package logshuttle import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging logshuttle describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/logshuttle/doc.go ================================================ // Package logshuttle contains deprecated aliases for the 'service logging logshuttle' commands. package logshuttle ================================================ FILE: pkg/commands/alias/logging/logshuttle/list.go ================================================ package logshuttle import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging logshuttle list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/logshuttle/root.go ================================================ 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 Log Shuttle logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/logshuttle/update.go ================================================ package logshuttle import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging logshuttle update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelic/create.go ================================================ package newrelic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelic create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelic/delete.go ================================================ package newrelic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelic delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelic/describe.go ================================================ package newrelic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging newrelic describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelic/doc.go ================================================ // Package newrelic contains deprecated aliases for the 'service logging newrelic' commands. package newrelic ================================================ FILE: pkg/commands/alias/logging/newrelic/list.go ================================================ package newrelic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging newrelic list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelic/root.go ================================================ 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 Fastly service version New Relic logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/newrelic/update.go ================================================ package newrelic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelic update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/create.go ================================================ package newrelicotlp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelicotlp create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/delete.go ================================================ package newrelicotlp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelicotlp delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/describe.go ================================================ package newrelicotlp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging newrelicotlp describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/doc.go ================================================ // Package newrelicotlp contains deprecated aliases for the 'service logging newrelicotlp' commands. package newrelicotlp ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/list.go ================================================ package newrelicotlp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging newrelicotlp list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/root.go ================================================ 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 Fastly service version New Relic OTLP logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/newrelicotlp/update.go ================================================ package newrelicotlp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging newrelicotlp update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/openstack/create.go ================================================ package openstack import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/openstack" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging openstack create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/openstack/delete.go ================================================ package openstack import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/openstack" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging openstack delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/openstack/describe.go ================================================ package openstack import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/openstack" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging openstack describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/openstack/doc.go ================================================ // Package openstack contains deprecated aliases for the 'service logging openstack' commands. package openstack ================================================ FILE: pkg/commands/alias/logging/openstack/list.go ================================================ package openstack import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/openstack" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging openstack list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/openstack/root.go ================================================ 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 Swift logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/openstack/update.go ================================================ package openstack import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/openstack" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging openstack update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/papertrail/create.go ================================================ package papertrail import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/papertrail" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging papertrail create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/papertrail/delete.go ================================================ package papertrail import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/papertrail" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging papertrail delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/papertrail/describe.go ================================================ package papertrail import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/papertrail" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging papertrail describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/papertrail/doc.go ================================================ // Package papertrail contains deprecated aliases for the 'service logging papertrail' commands. package papertrail ================================================ FILE: pkg/commands/alias/logging/papertrail/list.go ================================================ package papertrail import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/papertrail" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging papertrail list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/papertrail/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/papertrail/update.go ================================================ package papertrail import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/papertrail" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging papertrail update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/s3/create.go ================================================ package s3 import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/s3" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging s3 create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/s3/delete.go ================================================ package s3 import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/s3" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging s3 delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/s3/describe.go ================================================ package s3 import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/s3" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging s3 describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/s3/doc.go ================================================ // Package s3 contains deprecated aliases for the 'service logging s3' commands. package s3 ================================================ FILE: pkg/commands/alias/logging/s3/list.go ================================================ package s3 import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/s3" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging s3 list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/s3/root.go ================================================ 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 Amazon S3 logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/s3/update.go ================================================ package s3 import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/s3" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging s3 update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/scalyr/create.go ================================================ package scalyr import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/scalyr" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging scalyr create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/scalyr/delete.go ================================================ package scalyr import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/scalyr" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging scalyr delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/scalyr/describe.go ================================================ package scalyr import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/scalyr" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging scalyr describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/scalyr/doc.go ================================================ // Package scalyr contains deprecated aliases for the 'service logging scalyr' commands. package scalyr ================================================ FILE: pkg/commands/alias/logging/scalyr/list.go ================================================ package scalyr import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/scalyr" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging scalyr list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/scalyr/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/scalyr/update.go ================================================ package scalyr import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/scalyr" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging scalyr update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sftp/create.go ================================================ package sftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sftp create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sftp/delete.go ================================================ package sftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sftp delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sftp/describe.go ================================================ package sftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging sftp describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sftp/doc.go ================================================ // Package sftp contains deprecated aliases for the 'service logging sftp' commands. package sftp ================================================ FILE: pkg/commands/alias/logging/sftp/list.go ================================================ package sftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging sftp list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sftp/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/sftp/update.go ================================================ package sftp import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sftp" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sftp update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/splunk/create.go ================================================ package splunk import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging splunk create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/splunk/delete.go ================================================ package splunk import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging splunk delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/splunk/describe.go ================================================ package splunk import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging splunk describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/splunk/doc.go ================================================ // Package splunk contains deprecated aliases for the 'service logging splunk' commands. package splunk ================================================ FILE: pkg/commands/alias/logging/splunk/list.go ================================================ package splunk import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging splunk list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/splunk/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/splunk/update.go ================================================ package splunk import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging splunk update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sumologic/create.go ================================================ package sumologic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sumologic create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sumologic/delete.go ================================================ package sumologic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sumologic delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sumologic/describe.go ================================================ package sumologic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging sumologic describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sumologic/doc.go ================================================ // Package sumologic contains deprecated aliases for the 'service logging sumologic' commands. package sumologic ================================================ FILE: pkg/commands/alias/logging/sumologic/list.go ================================================ package sumologic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging sumologic list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/sumologic/root.go ================================================ 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 Sumo Logic logging endpoints").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/sumologic/update.go ================================================ package sumologic import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging sumologic update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/syslog/create.go ================================================ package syslog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/syslog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging syslog create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/syslog/delete.go ================================================ package syslog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/syslog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging syslog delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/syslog/describe.go ================================================ package syslog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/syslog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging syslog describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/syslog/doc.go ================================================ // Package syslog contains deprecated aliases for the 'service logging syslog' commands. package syslog ================================================ FILE: pkg/commands/alias/logging/syslog/list.go ================================================ package syslog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/syslog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service logging syslog list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/logging/syslog/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/logging/syslog/update.go ================================================ package syslog import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/logging/syslog" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service logging syslog update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/purge/doc.go ================================================ // Package purge contains the 'purge' alias for the 'service purge' command. package purge ================================================ FILE: pkg/commands/alias/purge/purge.go ================================================ package purge import ( "io" servicepurge "github.com/fastly/cli/pkg/commands/service/purge" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // PurgeCommand wraps the PurgeCommand from the servicepurge package. type Command struct { *servicepurge.PurgeCommand } // NewCommand returns a usable command registered under the parent. func NewCommand(parent argparser.Registerer, g *global.Data) *Command { c := Command{servicepurge.NewPurgeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *Command) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service purge' command instead.") return c.PurgeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/ratelimit/create.go ================================================ package ratelimit import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/ratelimit" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service rate-limit create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/ratelimit/delete.go ================================================ package ratelimit import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/ratelimit" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service rate-limit delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/ratelimit/describe.go ================================================ package ratelimit import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/ratelimit" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service rate-limit describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/ratelimit/doc.go ================================================ // Package ratelimit contains deprecated aliases for the 'service ratelimit' commands. package ratelimit ================================================ FILE: pkg/commands/alias/ratelimit/list.go ================================================ package ratelimit import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/ratelimit" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service rate-limit list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/ratelimit/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/ratelimit/update.go ================================================ package ratelimit import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/ratelimit" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service rate-limit update' command instead.") } return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/resourcelink/create.go ================================================ package resourcelink import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service resource-link create' command instead.") } return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/resourcelink/delete.go ================================================ package resourcelink import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service resource-link delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/resourcelink/describe.go ================================================ package resourcelink import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service resource-link describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/resourcelink/list.go ================================================ package resourcelink import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service resource-link list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/resourcelink/root.go ================================================ package resourcelink 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 = "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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/resourcelink/update.go ================================================ package resourcelink import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service resource-link update' command instead.") } return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceauth/create.go ================================================ package serviceauth import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service auth create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceauth/delete.go ================================================ package serviceauth import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service auth delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceauth/describe.go ================================================ package serviceauth import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service auth describe' command instead.") } return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceauth/doc.go ================================================ // Package serviceauth contains deprecated aliases for the 'service auth' commands. package serviceauth ================================================ FILE: pkg/commands/alias/serviceauth/list.go ================================================ package serviceauth import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service auth list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceauth/root.go ================================================ 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, "Manipulate Fastly Service Authentication").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/serviceauth/update.go ================================================ package serviceauth import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service auth update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/activate.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ActivateCommand wraps the ActivateCommand from the newcmd package. type ActivateCommand struct { *newcmd.ActivateCommand } // NewActivateCommand returns a usable command registered under the parent. func NewActivateCommand(parent argparser.Registerer, g *global.Data) *ActivateCommand { c := ActivateCommand{newcmd.NewActivateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ActivateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version activate' command instead.") return c.ActivateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/clone.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CloneCommand wraps the CloneCommand from the newcmd package. type CloneCommand struct { *newcmd.CloneCommand } // NewCloneCommand returns a usable command registered under the parent. func NewCloneCommand(parent argparser.Registerer, g *global.Data) *CloneCommand { c := CloneCommand{newcmd.NewCloneCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CloneCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service version clone' command instead.") } return c.CloneCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/deactivate.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeactivateCommand wraps the DeactivateCommand from the newcmd package. type DeactivateCommand struct { *newcmd.DeactivateCommand } // NewDeactivateCommand returns a usable command registered under the parent. func NewDeactivateCommand(parent argparser.Registerer, g *global.Data) *DeactivateCommand { c := DeactivateCommand{newcmd.NewDeactivateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeactivateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version deactivate' command instead.") return c.DeactivateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/doc.go ================================================ // Package serviceversion contains the 'service-version' aliases for the 'service version' commands. package serviceversion ================================================ FILE: pkg/commands/alias/serviceversion/list.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service version list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/lock.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // LockCommand wraps the LockCommand from the newcmd package. type LockCommand struct { *newcmd.LockCommand } // NewLockCommand returns a usable command registered under the parent. func NewLockCommand(parent argparser.Registerer, g *global.Data) *LockCommand { c := LockCommand{newcmd.NewLockCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *LockCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version lock' command instead.") return c.LockCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/serviceversion/stage.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // StageCommand wraps the StageCommand from the newcmd package. type StageCommand struct { *newcmd.StageCommand } // NewStageCommand returns a usable command registered under the parent. func NewStageCommand(parent argparser.Registerer, g *global.Data) *StageCommand { c := StageCommand{newcmd.NewStageCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *StageCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version stage' command instead.") return c.StageCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/unstage.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UnstageCommand wraps the UnstageCommand from the newcmd package. type UnstageCommand struct { *newcmd.UnstageCommand } // NewUnstageCommand returns a usable command registered under the parent. func NewUnstageCommand(parent argparser.Registerer, g *global.Data) *UnstageCommand { c := UnstageCommand{newcmd.NewUnstageCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UnstageCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version unstage' command instead.") return c.UnstageCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/serviceversion/update.go ================================================ package serviceversion import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service version update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/condition/create.go ================================================ package condition import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/condition" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl condition create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/condition/delete.go ================================================ package condition import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/condition" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl condition delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/condition/describe.go ================================================ package condition import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/condition" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl condition describe' command instead.") return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/condition/doc.go ================================================ // Package condition contains deprecated aliases for the 'service vcl' condition commands. package condition ================================================ FILE: pkg/commands/alias/vcl/condition/list.go ================================================ package condition import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/condition" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service vcl condition list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/condition/root.go ================================================ 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 VCL conditions").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/vcl/condition/update.go ================================================ package condition import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/condition" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl condition update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/custom/create.go ================================================ package custom import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/custom" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl custom create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/custom/delete.go ================================================ package custom import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/custom" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl custom delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/custom/describe.go ================================================ package custom import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/custom" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl custom describe' command instead.") return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/custom/doc.go ================================================ // Package custom contains deprecated aliases for the 'service vcl' custom commands. package custom ================================================ FILE: pkg/commands/alias/vcl/custom/list.go ================================================ package custom import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/custom" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service vcl custom list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/custom/root.go ================================================ 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 custom VCL files").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/vcl/custom/update.go ================================================ package custom import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/custom" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl custom update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/describe.go ================================================ package vcl import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl describe' command instead.") return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/doc.go ================================================ // Package vcl contains deprecated aliases for the 'service vcl' commands. package vcl ================================================ FILE: pkg/commands/alias/vcl/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/vcl/snippet/create.go ================================================ package snippet import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/snippet" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // CreateCommand wraps the CreateCommand from the newcmd package. type CreateCommand struct { *newcmd.CreateCommand } // NewCreateCommand returns a usable command registered under the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { c := CreateCommand{newcmd.NewCreateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl snippet create' command instead.") return c.CreateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/snippet/delete.go ================================================ package snippet import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/snippet" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand wraps the DeleteCommand from the newcmd package. type DeleteCommand struct { *newcmd.DeleteCommand } // NewDeleteCommand returns a usable command registered under the parent. func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { c := DeleteCommand{newcmd.NewDeleteCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl snippet delete' command instead.") return c.DeleteCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/snippet/describe.go ================================================ package snippet import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/snippet" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DescribeCommand wraps the DescribeCommand from the newcmd package. type DescribeCommand struct { *newcmd.DescribeCommand } // NewDescribeCommand returns a usable command registered under the parent. func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { c := DescribeCommand{newcmd.NewDescribeCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl snippet describe' command instead.") return c.DescribeCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/snippet/doc.go ================================================ // Package snippet contains deprecated aliases for the 'service vcl' snippet commands. package snippet ================================================ FILE: pkg/commands/alias/vcl/snippet/list.go ================================================ package snippet import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/snippet" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand wraps the ListCommand from the newcmd package. type ListCommand struct { *newcmd.ListCommand } // NewListCommand returns a usable command registered under the parent. func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { c := ListCommand{newcmd.NewListCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { if !c.JSONOutput.Enabled { text.Deprecated("Use the 'service vcl snippet list' command instead.") } return c.ListCommand.Exec(in, out) } ================================================ FILE: pkg/commands/alias/vcl/snippet/root.go ================================================ 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").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/alias/vcl/snippet/update.go ================================================ package snippet import ( "io" newcmd "github.com/fastly/cli/pkg/commands/service/vcl/snippet" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UpdateCommand wraps the UpdateCommand from the newcmd package. type UpdateCommand struct { *newcmd.UpdateCommand } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { c := UpdateCommand{newcmd.NewUpdateCommand(parent, g)} c.CmdClause.Hidden() return &c } // Exec implements the command interface. func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { text.Deprecated("Use the 'service vcl snippet update' command instead.") return c.UpdateCommand.Exec(in, out) } ================================================ FILE: pkg/commands/apisecurity/discoveredoperations/discoveredoperations_test.go ================================================ package discoveredoperations_test import ( "bytes" "fmt" "io" "net/http" "os" "path/filepath" "strings" "testing" apisecurity "github.com/fastly/cli/pkg/commands/apisecurity" root "github.com/fastly/cli/pkg/commands/apisecurity/discoveredoperations" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" ) const ( serviceID = "test-service-id" operationID = "test-operation-id" ) var ( listResponse = operations.DiscoveredOperations{ Data: []operations.DiscoveredOperation{ { ID: "test-operation-id", Method: "GET", Domain: "example.com", Path: "/api/users", Status: "DISCOVERED", RPS: 10.5, LastSeenAt: "2026-03-10T12:00:00Z", UpdatedAt: "2026-03-10T12:00:00Z", }, { ID: "test-operation-id-2", Method: "POST", Domain: "example.com", Path: "/api/users", Status: "SAVED", RPS: 5.2, LastSeenAt: "2026-03-10T12:00:00Z", UpdatedAt: "2026-03-10T12:00:00Z", }, }, Meta: operations.Meta{ Limit: 2, Total: 2, }, } updateResponse = operations.DiscoveredOperation{ ID: "test-operation-id", Method: "GET", Domain: "example.com", Path: "/api/users", Status: "IGNORED", RPS: 10.5, LastSeenAt: "2026-03-10T12:00:00Z", UpdatedAt: "2026-03-10T13:00:00Z", } updateResponseJSON = testutil.GenJSON(updateResponse) listResponseJSON = testutil.GenJSON(listResponse) bulkResponse = operations.BulkOperationResultsResponse{ Data: []operations.BulkOperationResult{ { ID: "op-id-1", StatusCode: 200, }, { ID: "op-id-2", StatusCode: 200, }, }, } bulkResponseJSON = testutil.GenJSON(bulkResponse) listDiscoveredOperationsOutput = strings.TrimSpace(` METHOD DOMAIN PATH STATUS RPS LAST SEEN GET example.com /api/users DISCOVERED 10.50 2026-03-10T12:00:00Z POST example.com /api/users SAVED 5.20 2026-03-10T12:00:00Z `) + "\n" listDiscoveredOperationsVerboseOutput = strings.TrimSpace(` Fastly API endpoint: https://api.fastly.com Fastly API token provided via config file (auth: user) Service ID (via --service-id): test-service-id Discovered Operation 1/2 ID: test-operation-id Method: GET Domain: example.com Path: /api/users Status: DISCOVERED RPS: 10.50 Last Seen: 2026-03-10T12:00:00Z Updated At: 2026-03-10T12:00:00Z Discovered Operation 2/2 ID: test-operation-id-2 Method: POST Domain: example.com Path: /api/users Status: SAVED RPS: 5.20 Last Seen: 2026-03-10T12:00:00Z Updated At: 2026-03-10T12:00:00Z `) + "\n\n" ) func TestListCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--status discovered", WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate list without status filter", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsOutput, }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsOutput, }, { Name: "validate --json flag", Args: fmt.Sprintf("--service-id %s --status saved --json", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(listResponseJSON)), }, }, }, WantOutput: string(testutil.GenJSON(listResponse.Data)), }, { Name: "validate invalid status", Args: fmt.Sprintf("--service-id %s --status invalid", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantError: "invalid status: invalid. Valid options: 'discovered', 'saved', 'ignored'", }, { Name: "validate API error", Args: fmt.Sprintf("--service-id %s --status discovered", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusInternalServerError, Status: http.StatusText(http.StatusInternalServerError), Body: io.NopCloser(strings.NewReader(`{"detail":"Internal Server Error"}`)), }, }, }, WantError: "500", }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) } func TestListCommandWithFilters(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate --domain filter", Args: fmt.Sprintf("--service-id %s --status discovered --domain example.com", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsOutput, }, { Name: "validate --method filter", Args: fmt.Sprintf("--service-id %s --status discovered --method GET", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsOutput, }, { Name: "validate --path filter", Args: fmt.Sprintf("--service-id %s --status discovered --path /api/users", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsOutput, }, { Name: "validate --verbose output", Args: fmt.Sprintf("--service-id %s --status discovered --verbose", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listDiscoveredOperationsVerboseOutput, }, { Name: "validate empty results", Args: fmt.Sprintf("--service-id %s --status discovered", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.DiscoveredOperations{ Data: []operations.DiscoveredOperation{}, Meta: operations.Meta{ Limit: 0, Total: 0, }, }))), }, }, }, }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) } func TestUpdateCommand(t *testing.T) { // Create temp file for bulk update test tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test-ops.json") content := `{"operation_ids": ["op-id-1", "op-id-2"], "status": "ignored"}` err := os.WriteFile(testFile, []byte(content), 0o600) if err != nil { t.Fatalf("failed to create test file: %v", err) } discoveredResponse := operations.DiscoveredOperation{ ID: "test-operation-id", Method: "GET", Domain: "example.com", Path: "/api/users", Status: "DISCOVERED", RPS: 10.5, LastSeenAt: "2026-03-10T12:00:00Z", UpdatedAt: "2026-03-10T13:00:00Z", } scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: fmt.Sprintf("--operation-id %s --status ignored", operationID), WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate missing --operation-id and --file flags", Args: fmt.Sprintf("--service-id %s --status ignored", serviceID), WantError: "error parsing arguments: must provide either --operation-id or --file", }, { Name: "validate missing --status flag", Args: fmt.Sprintf("--service-id %s --operation-id %s", serviceID, operationID), WantError: "error parsing arguments: --status is required when using --operation-id", }, { Name: "validate invalid status", Args: fmt.Sprintf("--service-id %s --operation-id %s --status invalid", serviceID, operationID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updateResponse))), }, }, }, WantError: "invalid status: invalid. Valid options: 'discovered', 'ignored'", }, { Name: "validate API success with status ignored", Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored", serviceID, operationID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updateResponse))), }, }, }, WantOutputs: []string{ "Updated discovered operation:", "ID: test-operation-id", "Method: GET", "Domain: example.com", "Path: /api/users", "Status: IGNORED", }, }, { Name: "validate API success with status discovered", Args: fmt.Sprintf("--service-id %s --operation-id %s --status discovered", serviceID, operationID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(discoveredResponse))), }, }, }, WantOutputs: []string{ "Updated discovered operation:", "Status: DISCOVERED", }, }, { Name: "validate --json flag", Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored --json", serviceID, operationID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(updateResponseJSON)), }, }, }, WantOutput: string(updateResponseJSON), }, { Name: "validate API error", Args: fmt.Sprintf("--service-id %s --operation-id %s --status ignored", serviceID, operationID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(strings.NewReader(`{"detail":"Not Found"}`)), }, }, }, WantError: "404", }, { Name: "validate bulk mode with --json flag", Args: fmt.Sprintf("--service-id %s --file %s --json", serviceID, testFile), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusMultiStatus, Status: http.StatusText(http.StatusMultiStatus), Body: io.NopCloser(bytes.NewReader(bulkResponseJSON)), }, }, }, WantOutput: string(bulkResponseJSON), }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "update"}, scenarios) } func TestUpdateCommandEdgeCases(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate cannot use both --operation-id and --file", Args: fmt.Sprintf("--service-id %s --operation-id %s --file /tmp/test.json --status ignored", serviceID, operationID), WantError: "error parsing arguments: cannot use both --operation-id and --file", }, { Name: "validate cannot use --file with --status flag", Args: fmt.Sprintf("--service-id %s --file /tmp/test.json --status ignored", serviceID), WantError: "error parsing arguments: cannot use both --file and --status", }, { Name: "validate comma-separated operation IDs", Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2 --status ignored", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusMultiStatus, Status: http.StatusText(http.StatusMultiStatus), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), }, }, }, WantOutputs: []string{ "Updated 2 discovered operation(s)", }, }, { Name: "validate --verbose with bulk update", Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2 --status ignored --verbose", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusMultiStatus, Status: http.StatusText(http.StatusMultiStatus), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(bulkResponse))), }, }, }, WantOutputs: []string{ "Updated 2 discovered operation(s)", "Updating 2 operation(s) with status: IGNORED", "OPERATION ID", "STATUS CODE", "RESULT", "op-id-1", "200", "Success", }, }, { Name: "validate bulk update with mixed results", Args: fmt.Sprintf("--service-id %s --operation-id op-id-1,op-id-2,op-id-3 --status ignored", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusMultiStatus, Status: http.StatusText(http.StatusMultiStatus), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.BulkOperationResultsResponse{ Data: []operations.BulkOperationResult{ {ID: "op-id-1", StatusCode: 200}, {ID: "op-id-2", StatusCode: 404, Reason: "Not Found"}, {ID: "op-id-3", StatusCode: 200}, }, }))), }, }, }, WantOutputs: []string{ "Updated 2 discovered operation(s)", "1 operation(s) failed to update", }, }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "update"}, scenarios) } ================================================ FILE: pkg/commands/apisecurity/discoveredoperations/doc.go ================================================ // Package discoveredoperations contains commands to list and update discovered operations. package discoveredoperations ================================================ FILE: pkg/commands/apisecurity/discoveredoperations/list.go ================================================ package discoveredoperations import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 discovered API operations. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. input operations.ListDiscoveredInput serviceName argparser.OptionalServiceNameID // Optional. domain argparser.OptionalString method argparser.OptionalString path argparser.OptionalString status 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 discovered operations") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) // Optional. c.CmdClause.Flag("status", "Filters operations by status. Valid values are: discovered, saved, ignored").Action(c.status.Set).StringVar(&c.status.Value) c.CmdClause.Flag("domain", "The domain for the operation").Action(c.domain.Set).StringVar(&c.domain.Value) c.CmdClause.Flag("method", "Filters operations by HTTP method (e.g., GET, POST, PUT)").Action(c.method.Set).StringVar(&c.method.Value) c.CmdClause.Flag("path", "Filters operations by path (exact match)").Action(c.path.Set).StringVar(&c.path.Value) 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) } c.input.ServiceID = &serviceID // The API only accepts uppercase values for 'status', // so we are handling accordingly here and allowing // end users to still use the normal lowercase pattern // for input in the CLI. if c.status.WasSet { switch c.status.Value { case "discovered": status := "DISCOVERED" c.input.Status = &status case "saved": status := "SAVED" c.input.Status = &status case "ignored": status := "IGNORED" c.input.Status = &status default: err := fmt.Errorf("invalid status: %s. Valid options: 'discovered', 'saved', 'ignored'", c.status.Value) c.Globals.ErrLog.Add(err) return err } } if c.domain.WasSet { c.input.Domain = []string{c.domain.Value} } if c.method.WasSet { c.input.Method = []string{c.method.Value} } if c.path.WasSet { c.input.Path = &c.path.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } // Auto-paginate through all results var allOperations []operations.DiscoveredOperation page := 0 limit := 100 for { c.input.Page = &page c.input.Limit = &limit o, err := operations.ListDiscovered(context.TODO(), fc, &c.input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Domain": c.domain.Value, "Method": c.method.Value, "Status": c.status.Value, "Path": c.path.Value, "Page": page, }) return err } if o == nil || len(o.Data) == 0 { break } allOperations = append(allOperations, o.Data...) // Check if we've fetched all results if len(allOperations) >= o.Meta.Total { break } page++ } if ok, err := c.WriteJSON(out, allOperations); ok { return err } if !c.Globals.Verbose() { return c.printSummary(out, allOperations) } return c.printVerbose(out, allOperations) } // printSummary displays the discovered operations in a table format. func (c *ListCommand) printSummary(out io.Writer, o []operations.DiscoveredOperation) error { tw := text.NewTable(out) tw.AddHeader("METHOD", "DOMAIN", "PATH", "STATUS", "RPS", "LAST SEEN") for _, op := range o { tw.AddLine( strings.ToUpper(op.Method), op.Domain, op.Path, op.Status, fmt.Sprintf("%.2f", op.RPS), op.LastSeenAt, ) } tw.Print() return nil } // printVerbose displays detailed information for each discovered operation. func (c *ListCommand) printVerbose(out io.Writer, o []operations.DiscoveredOperation) error { for i, op := range o { fmt.Fprintf(out, "\nDiscovered Operation %d/%d\n", i+1, len(o)) fmt.Fprintf(out, "\tID: %s\n", op.ID) fmt.Fprintf(out, "\tMethod: %s\n", strings.ToUpper(op.Method)) fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) fmt.Fprintf(out, "\tPath: %s\n", op.Path) fmt.Fprintf(out, "\tStatus: %s\n", op.Status) fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) if op.LastSeenAt != "" { fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) } if op.UpdatedAt != "" { fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) } } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/apisecurity/discoveredoperations/root.go ================================================ package discoveredoperations 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 = "discovered-operations" // 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, "Retrieve and update discovered API operations") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/apisecurity/discoveredoperations/update.go ================================================ package discoveredoperations import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 discovered API operation's status. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required . serviceName argparser.OptionalServiceNameID file string operationID argparser.OptionalString status 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 the status of discovered operation(s)") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("operation-id", "The ID of the discovered operation (comma-separated for multiple)").Action(c.operationID.Set).StringVar(&c.operationID.Value) c.CmdClause.Flag("file", "Update operations in bulk from a JSON file").StringVar(&c.file) // Optional. c.CmdClause.Flag("status", "The new status to apply. Valid values are: 'discovered', 'ignored'").Action(c.status.Set).StringVar(&c.status.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 } if !c.operationID.WasSet && c.file == "" { return fmt.Errorf("error parsing arguments: must provide either --operation-id or --file") } if c.operationID.WasSet && c.file != "" { return fmt.Errorf("error parsing arguments: cannot use both --operation-id and --file") } // When using --file, status should not be provided via flag. if c.file != "" && c.status.WasSet { return fmt.Errorf("error parsing arguments: cannot use both --file and --status (status should be specified in the JSON file)") } // When using --operation-id, status is required. if c.operationID.WasSet && !c.status.WasSet { return fmt.Errorf("error parsing arguments: --status is required when using --operation-id") } 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) } // Handle bulk mode from file. if c.file != "" { fileInput, err := c.readFromFile() if err != nil { c.Globals.ErrLog.Add(err) return err } // Convert status from file to uppercase to map to API. status, err := c.validateStatus(fileInput.Status) if err != nil { c.Globals.ErrLog.Add(err) return err } return c.executeBulkUpdate(out, serviceID, status, fileInput.OperationIDs) } // Convert status to uppercase for API. status, err := c.validateStatus(c.status.Value) if err != nil { c.Globals.ErrLog.Add(err) return err } // Handle comma-separated operation IDs. operationIDs := strings.Split(c.operationID.Value, ",") // Trim whitespace from each ID for i, id := range operationIDs { operationIDs[i] = strings.TrimSpace(id) } // If multiple operation IDs, use bulk update. if len(operationIDs) > 1 { return c.executeBulkUpdate(out, serviceID, status, operationIDs) } // Handle single operation mode. input := operations.UpdateDiscoveredStatusInput{ ServiceID: &serviceID, OperationID: &c.operationID.Value, Status: &status, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } o, err := operations.UpdateDiscoveredStatus(context.TODO(), fc, &input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Operation ID": c.operationID.Value, "Status": c.status.Value, }) return err } if ok, err := c.WriteJSON(out, o); ok { return err } if !c.Globals.Verbose() { return c.printSummary(out, o) } return c.printVerbose(out, o) } // validateStatus converts and validates the status value. func (c *UpdateCommand) validateStatus(statusValue string) (string, error) { switch statusValue { case "discovered", "DISCOVERED": return "DISCOVERED", nil case "ignored", "IGNORED": return "IGNORED", nil default: return "", fmt.Errorf("invalid status: %s. Valid options: 'discovered', 'ignored'", statusValue) } } // executeBulkUpdate performs a bulk update operation for multiple operation IDs. func (c *UpdateCommand) executeBulkUpdate(out io.Writer, serviceID string, status string, operationIDs []string) error { if c.Globals.Verbose() { fmt.Fprintf(out, "Updating %d operation(s) with status: %s\n", len(operationIDs), status) fmt.Fprintf(out, "Operation IDs: %v\n", operationIDs) } input := operations.BulkUpdateDiscoveredStatusInput{ ServiceID: &serviceID, OperationIDs: operationIDs, Status: &status, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } results, err := operations.BulkUpdateDiscoveredStatus(context.TODO(), fc, &input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Status": c.status.Value, "Count": len(operationIDs), }) return err } if ok, err := c.WriteJSON(out, results); ok { return err } return c.printBulkResults(out, results) } // UpdateFileInput represents the JSON file format for bulk update operation. type UpdateFileInput struct { OperationIDs []string `json:"operation_ids"` Status string `json:"status"` } // readFromFile reads operation IDs and status from a JSON file. func (c *UpdateCommand) readFromFile() (*UpdateFileInput, error) { path, err := filepath.Abs(c.file) if err != nil { return nil, err } if _, err := os.Stat(path); err != nil { return nil, err } file, err := os.Open(path) /* #nosec */ if err != nil { return nil, err } defer file.Close() byteValue, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } var input UpdateFileInput if err := json.Unmarshal(byteValue, &input); err != nil { return nil, fmt.Errorf("invalid JSON format: %w", err) } if len(input.OperationIDs) == 0 { return nil, fmt.Errorf("no operation IDs found in file: %s", c.file) } if input.Status == "" { return nil, fmt.Errorf("status not specified in file: %s", c.file) } return &input, nil } // printBulkResults displays the results of a bulk update operation. func (c *UpdateCommand) printBulkResults(out io.Writer, results *operations.BulkOperationResultsResponse) error { var succeeded, failed int for _, result := range results.Data { if result.StatusCode >= 200 && result.StatusCode < 300 { succeeded++ } else { failed++ } } text.Success(out, "Updated %d discovered operation(s)", succeeded) if failed > 0 { text.Warning(out, "%d operation(s) failed to update", failed) } if c.Globals.Verbose() { text.Break(out) tw := text.NewTable(out) tw.AddHeader("OPERATION ID", "STATUS CODE", "RESULT") for _, result := range results.Data { status := "Success" if result.StatusCode < 200 || result.StatusCode >= 300 { status = fmt.Sprintf("Failed: %s", result.Reason) } tw.AddLine(result.ID, fmt.Sprintf("%d", result.StatusCode), status) } tw.Print() } return nil } // printSummary displays the discovered operation in a simple format. func (c *UpdateCommand) printSummary(out io.Writer, op *operations.DiscoveredOperation) error { fmt.Fprintf(out, "Updated discovered operation:\n") fmt.Fprintf(out, " ID: %s\n", op.ID) fmt.Fprintf(out, " Method: %s\n", op.Method) fmt.Fprintf(out, " Domain: %s\n", op.Domain) fmt.Fprintf(out, " Path: %s\n", op.Path) fmt.Fprintf(out, " Status: %s\n", op.Status) return nil } // printVerbose displays detailed information for the discovered operation. func (c *UpdateCommand) printVerbose(out io.Writer, op *operations.DiscoveredOperation) error { fmt.Fprintf(out, "\nUpdated Discovered Operation\n") fmt.Fprintf(out, "\tID: %s\n", op.ID) fmt.Fprintf(out, "\tMethod: %s\n", op.Method) fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) fmt.Fprintf(out, "\tPath: %s\n", op.Path) fmt.Fprintf(out, "\tStatus: %s\n", op.Status) fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) if op.LastSeenAt != "" { fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) } if op.UpdatedAt != "" { fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/apisecurity/doc.go ================================================ // Package apisecurity contains commands to manage API operations for services. package apisecurity ================================================ FILE: pkg/commands/apisecurity/operations/addtags.go ================================================ package operations import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "github.com/fastly/kingpin" "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" ) // AddTagsCommand calls the Fastly API to add tags to operations. type AddTagsCommand struct { argparser.Base argparser.JSONOutput // Required. serviceName argparser.OptionalServiceNameID tagIDs []string // Optional. operationIDs []string file string } // NewAddTagsCommand returns a usable command registered under the parent. func NewAddTagsCommand(parent argparser.Registerer, g *global.Data) *AddTagsCommand { c := AddTagsCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("add-tags", "Add tags to operation(s)") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("tag-ids", "Comma-separated list of tag IDs to add").Required().StringsVar(&c.tagIDs, kingpin.Separator(",")) // Optional. c.CmdClause.Flag("operation-ids", "Comma-separated list of operation IDs to add tags to").StringsVar(&c.operationIDs, kingpin.Separator(",")) c.CmdClause.Flag("file", "Add tags to operations in bulk from a JSON file").StringVar(&c.file) c.RegisterFlagBool(c.JSONFlag()) // --json return &c } // Exec invokes the application logic for the command. func (c *AddTagsCommand) Exec(_ io.Reader, out io.Writer) error { if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } if len(c.operationIDs) == 0 && c.file == "" { return fmt.Errorf("error parsing arguments: must provide either --operation-ids or --file") } if len(c.operationIDs) > 0 && c.file != "" { return fmt.Errorf("error parsing arguments: cannot use both --operation-ids and --file") } 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) } // Get operation IDs and tag IDs from file or flags var operationIDs []string var tagIDs []string if c.file != "" { fileInput, err := c.readFromFile() if err != nil { c.Globals.ErrLog.Add(err) return err } operationIDs = fileInput.OperationIDs tagIDs = fileInput.TagIDs } else { operationIDs = c.operationIDs tagIDs = c.tagIDs } if c.Globals.Verbose() { fmt.Fprintf(out, "Adding %d tag(s) to %d operation(s)\n", len(tagIDs), len(operationIDs)) } input := &operations.BulkAddTagsInput{ ServiceID: &serviceID, OperationIDs: operationIDs, TagIDs: tagIDs, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } results, err := operations.BulkAddTags(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Operation Count": len(operationIDs), "Tag Count": len(c.tagIDs), }) return err } if ok, err := c.WriteJSON(out, results); ok { return err } return c.printResults(out, results) } // AddTagsFileInput represents the JSON file format for bulk add-tags operation. type AddTagsFileInput struct { OperationIDs []string `json:"operation_ids"` TagIDs []string `json:"tag_ids"` } // readFromFile reads operation IDs and tag IDs from a JSON file. func (c *AddTagsCommand) readFromFile() (*AddTagsFileInput, error) { path, err := filepath.Abs(c.file) if err != nil { return nil, err } if _, err := os.Stat(path); err != nil { return nil, err } file, err := os.Open(path) /* #nosec */ if err != nil { return nil, err } defer file.Close() byteValue, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } var input AddTagsFileInput if err := json.Unmarshal(byteValue, &input); err != nil { return nil, fmt.Errorf("invalid JSON format: %w", err) } if len(input.OperationIDs) == 0 { return nil, fmt.Errorf("no operation IDs found in file: %s", c.file) } if len(input.TagIDs) == 0 { return nil, fmt.Errorf("no tag IDs found in file: %s", c.file) } return &input, nil } // printResults displays the results of the bulk add tags operation. func (c *AddTagsCommand) printResults(out io.Writer, results *operations.BulkOperationResultsResponse) error { var succeeded, failed int for _, result := range results.Data { if result.StatusCode >= 200 && result.StatusCode < 300 { succeeded++ } else { failed++ } } text.Success(out, "Added tags to %d operation(s)", succeeded) if failed > 0 { text.Warning(out, "%d operation(s) failed", failed) } if c.Globals.Verbose() { text.Break(out) tw := text.NewTable(out) tw.AddHeader("OPERATION ID", "STATUS CODE", "RESULT") for _, result := range results.Data { status := "Success" if result.StatusCode < 200 || result.StatusCode >= 300 { status = fmt.Sprintf("Failed: %s", result.Reason) } tw.AddLine(result.ID, fmt.Sprintf("%d", result.StatusCode), status) } tw.Print() } return nil } ================================================ FILE: pkg/commands/apisecurity/operations/create.go ================================================ package operations import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "github.com/fastly/kingpin" "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 operation. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. serviceName argparser.OptionalServiceNameID method string domain string path string // Optional. description string tagIDs []string file string } // OperationInput represents a single operation to be created from JSON. type OperationInput struct { Method string `json:"method"` Domain string `json:"domain"` Path string `json:"path"` Description string `json:"description,omitempty"` TagIDs []string `json:"tag_ids,omitempty"` } // CreateFileInput represents the JSON file format for bulk create operations. type CreateFileInput struct { Operations []OperationInput `json:"operations"` } // 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 operation").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("method", "The HTTP method for the operation (e.g., GET, POST, PUT)").StringVar(&c.method) c.CmdClause.Flag("domain", "Domain for the operation").StringVar(&c.domain) c.CmdClause.Flag("path", "The path for the operation, which may include path parameters.(e.g., /api/users)").StringVar(&c.path) // Optional. c.CmdClause.Flag("description", "Description of what the operation does").StringVar(&c.description) c.CmdClause.Flag("tag-ids", "A comma-separated array of operation tag IDs associated with this operation").StringsVar(&c.tagIDs, kingpin.Separator(",")) c.CmdClause.Flag("file", "Create operations in bulk from a JSON file").StringVar(&c.file) c.RegisterFlagBool(c.JSONFlag()) // --json 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 } // Validate flags if c.file != "" && (c.method != "" || c.domain != "" || c.path != "") { return fmt.Errorf("error parsing arguments: cannot use both --file and individual operation flags (--method, --domain, --path)") } if c.file == "" && (c.method == "" || c.domain == "" || c.path == "") { return fmt.Errorf("error parsing arguments: must provide either --file or all of --method, --domain, and --path") } 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) } // Handle bulk mode from file if c.file != "" { return c.createFromFile(serviceID, out) } // Handle single operation mode input := c.constructInput(serviceID) fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } o, err := operations.Create(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Method": c.method, "Domain": c.domain, "Path": c.path, }) return err } if ok, err := c.WriteJSON(out, o); ok { return err } text.Success(out, "Created operation %s %s%s (ID: %s)", strings.ToUpper(o.Method), o.Domain, o.Path, o.ID) if c.description != "" { fmt.Fprintf(out, "\nDescription: %s\n", o.Description) } if len(o.TagIDs) > 0 { fmt.Fprintf(out, "Tags: %d associated\n", len(o.TagIDs)) } 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) *operations.CreateInput { input := &operations.CreateInput{ ServiceID: &serviceID, Method: &c.method, Domain: &c.domain, Path: &c.path, } if c.description != "" { input.Description = &c.description } if len(c.tagIDs) > 0 { input.TagIDs = c.tagIDs } return input } // createFromFile creates operations in bulk from a newline-delimited JSON file. func (c *CreateCommand) createFromFile(serviceID string, out io.Writer) error { ops, err := c.readOperationsFromFile() if err != nil { c.Globals.ErrLog.Add(err) return err } if c.Globals.Verbose() { fmt.Fprintf(out, "Creating %d operation(s) from file\n", len(ops)) } type result struct { Operation *operations.Operation Error error } results := make([]result, 0, len(ops)) fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } for _, op := range ops { input := &operations.CreateInput{ ServiceID: &serviceID, Method: &op.Method, Domain: &op.Domain, Path: &op.Path, Description: &op.Description, TagIDs: op.TagIDs, } o, err := operations.Create(context.TODO(), fc, input) results = append(results, result{ Operation: o, Error: err, }) } // Count successes and failures var succeeded, failed int for _, r := range results { if r.Error == nil { succeeded++ } else { failed++ } } if c.JSONOutput.Enabled { type jsonResult struct { Success int `json:"success"` Failed int `json:"failed"` Operations []*operations.Operation `json:"operations,omitempty"` Errors []string `json:"errors,omitempty"` } jr := jsonResult{ Success: succeeded, Failed: failed, } for _, r := range results { if r.Error == nil { jr.Operations = append(jr.Operations, r.Operation) } else { jr.Errors = append(jr.Errors, r.Error.Error()) } } _, err := c.WriteJSON(out, jr) return err } text.Success(out, "Created %d operation(s)", succeeded) if failed > 0 { text.Warning(out, "%d operation(s) failed to create", failed) } if c.Globals.Verbose() { text.Break(out) tw := text.NewTable(out) tw.AddHeader("METHOD", "DOMAIN", "PATH", "RESULT") for i, r := range results { status := "Success" if r.Error != nil { status = fmt.Sprintf("Failed: %s", r.Error.Error()) } op := ops[i] tw.AddLine(strings.ToUpper(op.Method), op.Domain, op.Path, status) } tw.Print() } if failed > 0 { return fmt.Errorf("%d operation(s) failed to create", failed) } return nil } // readOperationsFromFile reads operations from a JSON file. func (c *CreateCommand) readOperationsFromFile() ([]OperationInput, error) { path, err := filepath.Abs(c.file) if err != nil { return nil, err } if _, err := os.Stat(path); err != nil { return nil, err } file, err := os.Open(path) /* #nosec */ if err != nil { return nil, err } defer file.Close() byteValue, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } var input CreateFileInput if err := json.Unmarshal(byteValue, &input); err != nil { return nil, fmt.Errorf("invalid JSON format: %w", err) } if len(input.Operations) == 0 { return nil, fmt.Errorf("no operations found in file: %s", c.file) } // Validate required fields for i, op := range input.Operations { if op.Method == "" || op.Domain == "" || op.Path == "" { return nil, fmt.Errorf("operation %d: missing required fields (method, domain, path)", i+1) } } return input.Operations, nil } ================================================ FILE: pkg/commands/apisecurity/operations/delete.go ================================================ package operations import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 operation. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. serviceName argparser.OptionalServiceNameID operationID 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 operation").Alias("remove") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) // Optional. 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, 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 := &operations.DeleteInput{ ServiceID: &serviceID, OperationID: &c.operationID, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err = operations.Delete(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Operation ID": c.operationID, }) return err } if c.JSONOutput.Enabled { o := struct { ServiceID string `json:"service_id"` OperationID string `json:"operation_id"` Deleted bool `json:"deleted"` }{ serviceID, c.operationID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted operation '%s'", c.operationID) return nil } ================================================ FILE: pkg/commands/apisecurity/operations/describe.go ================================================ package operations import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 an operation. type DescribeCommand struct { argparser.Base argparser.JSONOutput // Required. serviceName argparser.OptionalServiceNameID operationID 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", "Retrieve a single operation").Alias("get") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) // 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) } input := &operations.DescribeInput{ ServiceID: &serviceID, OperationID: &c.operationID, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } o, err := operations.Describe(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Operation ID": c.operationID, }) 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, o *operations.Operation) error { fmt.Fprintf(out, "\nOperation ID: %s\n", o.ID) fmt.Fprintf(out, "Method: %s\n", strings.ToUpper(o.Method)) fmt.Fprintf(out, "Domain: %s\n", o.Domain) fmt.Fprintf(out, "Path: %s\n", o.Path) fmt.Fprintf(out, "Description: %s\n", o.Description) fmt.Fprintf(out, "Status: %s\n", o.Status) fmt.Fprintf(out, "Tag IDs: %s\n", strings.Join(o.TagIDs, ", ")) fmt.Fprintf(out, "RPS: %.2f\n\n", o.RPS) if o.CreatedAt != "" { fmt.Fprintf(out, "Created At: %s\n", o.CreatedAt) } if o.UpdatedAt != "" { fmt.Fprintf(out, "Updated At: %s\n", o.UpdatedAt) } if o.LastSeenAt != "" { fmt.Fprintf(out, "Last Seen At: %s\n", o.LastSeenAt) } return nil } ================================================ FILE: pkg/commands/apisecurity/operations/doc.go ================================================ // Package operations contains commands to manage operations associated with services. package operations ================================================ FILE: pkg/commands/apisecurity/operations/list.go ================================================ package operations import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 operations. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. input operations.ListOperationsInput serviceName argparser.OptionalServiceNameID // Optional. domain argparser.OptionalString method argparser.OptionalString path argparser.OptionalString tagID 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 operations") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) // Optional. c.CmdClause.Flag("domain", "Filters operations by domain (exact match)").Action(c.domain.Set).StringVar(&c.domain.Value) c.CmdClause.Flag("method", "Filters operations by HTTP method (e.g., GET, POST, PUT)").Action(c.method.Set).StringVar(&c.method.Value) c.CmdClause.Flag("path", "Filters operations by path (exact match)").Action(c.path.Set).StringVar(&c.path.Value) c.CmdClause.Flag("tag-id", "Filters operations by tag ID").Action(c.tagID.Set).StringVar(&c.tagID.Value) 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) } c.input.ServiceID = &serviceID if c.domain.WasSet { c.input.Domain = []string{c.domain.Value} } if c.method.WasSet { c.input.Method = []string{c.method.Value} } if c.path.WasSet { c.input.Path = &c.path.Value } if c.tagID.WasSet { c.input.TagID = &c.tagID.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } // Auto-paginate through all results var allOperations []operations.Operation page := 0 limit := 100 for { c.input.Page = &page c.input.Limit = &limit o, err := operations.ListOperations(context.TODO(), fc, &c.input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Domain": c.domain.Value, "Method": c.method.Value, "Path": c.path.Value, "Tag ID": c.tagID.Value, "Page": page, }) return err } if o == nil || len(o.Data) == 0 { break } allOperations = append(allOperations, o.Data...) // Check if we've fetched all results if len(allOperations) >= o.Meta.Total { break } page++ } if ok, err := c.WriteJSON(out, allOperations); ok { return err } if !c.Globals.Verbose() { return c.printSummary(out, allOperations) } return c.printVerbose(out, allOperations) } // printSummary displays the operations in a table format. func (c *ListCommand) printSummary(out io.Writer, o []operations.Operation) error { tw := text.NewTable(out) tw.AddHeader("ID", "METHOD", "DOMAIN", "PATH", "DESCRIPTION", "TAGS") for _, op := range o { description := op.Description if len(description) > 50 { description = description[:47] + "..." } tags := fmt.Sprintf("%d", len(op.TagIDs)) tw.AddLine( op.ID, strings.ToUpper(op.Method), op.Domain, op.Path, description, tags, ) } tw.Print() return nil } // printVerbose displays detailed information for each operation. func (c *ListCommand) printVerbose(out io.Writer, o []operations.Operation) error { for i, op := range o { fmt.Fprintf(out, "\nOperation %d/%d\n", i+1, len(o)) fmt.Fprintf(out, "\tID: %s\n", op.ID) fmt.Fprintf(out, "\tMethod: %s\n", strings.ToUpper(op.Method)) fmt.Fprintf(out, "\tDomain: %s\n", op.Domain) fmt.Fprintf(out, "\tPath: %s\n", op.Path) if op.Description != "" { fmt.Fprintf(out, "\tDescription: %s\n", op.Description) } if op.Status != "" { fmt.Fprintf(out, "\tStatus: %s\n", op.Status) } if len(op.TagIDs) > 0 { fmt.Fprintf(out, "\tTag IDs: %s\n", strings.Join(op.TagIDs, ", ")) } if op.RPS > 0 { fmt.Fprintf(out, "\tRPS: %.2f\n", op.RPS) } if op.CreatedAt != "" { fmt.Fprintf(out, "\tCreated At: %s\n", op.CreatedAt) } if op.UpdatedAt != "" { fmt.Fprintf(out, "\tUpdated At: %s\n", op.UpdatedAt) } if op.LastSeenAt != "" { fmt.Fprintf(out, "\tLast Seen: %s\n", op.LastSeenAt) } } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/apisecurity/operations/operations_test.go ================================================ package operations_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" apisecurity "github.com/fastly/cli/pkg/commands/apisecurity" root "github.com/fastly/cli/pkg/commands/apisecurity/operations" "github.com/fastly/cli/pkg/testutil" ) const ( serviceID = "test-service-id" ) var ( listResponse = operations.Operations{ Data: []operations.Operation{ { ID: "test-operation-id", Method: "DELETE", Domain: "www.foo.com", Path: "/api/v1/users/{var1}", Description: "Retrieve user information", Status: "SAVED", RPS: 10.5, CreatedAt: "2026-02-02T14:27:16Z", UpdatedAt: "2026-02-02T14:33:19Z", TagIDs: []string{}, }, { ID: "test-operation-id-2", Method: "POST", Domain: "www.foo.com", Path: "/api/v1/users", Description: "Create a new user", Status: "SAVED", RPS: 5.2, CreatedAt: "2026-02-01T10:00:00Z", UpdatedAt: "2026-02-01T10:30:00Z", TagIDs: []string{"tag-1", "tag-2"}, }, }, Meta: operations.Meta{ Limit: 2, Total: 2, }, } listResponseJSON = testutil.GenJSON(listResponse) listOperationsOutput = strings.TrimSpace(` ID METHOD DOMAIN PATH DESCRIPTION TAGS test-operation-id DELETE www.foo.com /api/v1/users/{var1} Retrieve user information 0 test-operation-id-2 POST www.foo.com /api/v1/users Create a new user 2 `) + "\n" listOperationsVerboseOutput = strings.TrimSpace(` Operation 1/2 ID: test-operation-id Method: DELETE Domain: www.foo.com Path: /api/v1/users/{var1} Description: Retrieve user information Status: SAVED RPS: 10.50 Created At: 2026-02-02T14:27:16Z Updated At: 2026-02-02T14:33:19Z Operation 2/2 ID: test-operation-id-2 Method: POST Domain: www.foo.com Path: /api/v1/users Description: Create a new user Status: SAVED Tag IDs: tag-1, tag-2 RPS: 5.20 Created At: 2026-02-01T10:00:00Z Updated At: 2026-02-01T10:30:00Z `) + "\n\n" ) func TestListCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "", WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsOutput, }, { Name: "validate --json flag", Args: fmt.Sprintf("--service-id %s --json", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(listResponseJSON)), }, }, }, WantOutput: string(testutil.GenJSON(listResponse.Data)), }, { Name: "validate --verbose output", Args: fmt.Sprintf("--service-id %s --verbose", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsVerboseOutput, }, { Name: "validate API error", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusInternalServerError, Status: http.StatusText(http.StatusInternalServerError), Body: io.NopCloser(strings.NewReader(`{"detail":"Internal Server Error"}`)), }, }, }, WantError: "500", }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) } func TestListCommandWithFilters(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate --domain filter", Args: fmt.Sprintf("--service-id %s --domain www.foo.com", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsOutput, }, { Name: "validate --method filter", Args: fmt.Sprintf("--service-id %s --method DELETE", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsOutput, }, { Name: "validate --path filter", Args: fmt.Sprintf("--service-id %s --path /api/v1/users", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsOutput, }, { Name: "validate --tag-id filter", Args: fmt.Sprintf("--service-id %s --tag-id tag-1", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listResponse))), }, }, }, WantOutput: listOperationsOutput, }, { Name: "validate empty results", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.Operations{ Data: []operations.Operation{}, Meta: operations.Meta{ Limit: 0, Total: 0, }, }))), }, }, }, }, } testutil.RunCLIScenarios(t, []string{apisecurity.CommandName, root.CommandName, "list"}, scenarios) } ================================================ FILE: pkg/commands/apisecurity/operations/root.go ================================================ package operations 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 = "operations" // 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, "Manage operations associated with services") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/apisecurity/operations/update.go ================================================ package operations import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "github.com/fastly/kingpin" "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 an operation. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. serviceName argparser.OptionalServiceNameID operationID string // Optional. description argparser.OptionalString tagIDs argparser.OptionalStringSlice } // 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 operation") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("operation-id", "The unique identifier of the operation").Required().StringVar(&c.operationID) // Optional. c.CmdClause.Flag("description", "Updated description of what the operation does").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("tag-ids", "Comma-separated list of tag IDs to associate with the operation").Action(c.tagIDs.Set).StringsVar(&c.tagIDs.Value, kingpin.Separator(",")) 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 } if !c.description.WasSet && !c.tagIDs.WasSet { return fmt.Errorf("error parsing arguments: must provide at least one field to update (--description or --tag-ids)") } 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) fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } o, err := operations.Update(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Operation ID": c.operationID, }) return err } if ok, err := c.WriteJSON(out, o); ok { return err } text.Success(out, "Updated operation %s %s%s (ID: %s)", strings.ToUpper(o.Method), o.Domain, o.Path, o.ID) if c.Globals.Verbose() { fmt.Fprintln(out) if o.Description != "" { fmt.Fprintf(out, "Description: %s\n", o.Description) } if len(o.TagIDs) > 0 { fmt.Fprintf(out, "Tags: %d associated\n", len(o.TagIDs)) } if o.UpdatedAt != "" { fmt.Fprintf(out, "Updated At: %s\n", o.UpdatedAt) } } 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) *operations.UpdateInput { input := &operations.UpdateInput{ ServiceID: &serviceID, OperationID: &c.operationID, } if c.description.WasSet { input.Description = &c.description.Value } if c.tagIDs.WasSet { input.TagIDs = c.tagIDs.Value } return input } ================================================ FILE: pkg/commands/apisecurity/root.go ================================================ package apisecurity 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 = "apisecurity" // 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 API security operations") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/apisecurity/tags/create.go ================================================ package tags import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 operation tag. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. name string serviceName argparser.OptionalServiceNameID // Optional. description 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 operation tag") // Required. c.CmdClause.Flag("name", "Name of the operation tag").Required().StringVar(&c.name) // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.CmdClause.Flag("description", "Description of the operation tag").Action(c.description.Set).StringVar(&c.description.Value) 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 } 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 serviceID == "" { return errors.New("service-id is required") } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } input := &operations.CreateTagInput{ ServiceID: &serviceID, Name: &c.name, } if c.description.WasSet { input.Description = &c.description.Value } tag, err := operations.CreateTag(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, tag); ok { return err } text.Success(out, "Created operation tag '%s' (id: %s)", tag.Name, tag.ID) return nil } ================================================ FILE: pkg/commands/apisecurity/tags/delete.go ================================================ package tags import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 operation tag. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. tagID string 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 operation tag") // Required. c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) 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 } 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 serviceID == "" { return errors.New("service-id is required") } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err = operations.DeleteTag(context.TODO(), fc, &operations.DeleteTagInput{ ServiceID: &serviceID, TagID: &c.tagID, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if c.JSONOutput.Enabled { o := struct { ServiceID string `json:"service_id"` TagID string `json:"tag_id"` Deleted bool `json:"deleted"` }{ serviceID, c.tagID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted operation tag (id: %s)", c.tagID) return nil } ================================================ FILE: pkg/commands/apisecurity/tags/doc.go ================================================ // Package tags contains commands to manipulate Fastly API Security operation tags. package tags ================================================ FILE: pkg/commands/apisecurity/tags/get.go ================================================ package tags import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 operation tag. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. tagID string serviceName argparser.OptionalServiceNameID } // 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 operation tag") // Required. c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) 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 } 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 serviceID == "" { return errors.New("service-id is required") } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } tag, err := operations.DescribeTag(context.TODO(), fc, &operations.DescribeTagInput{ ServiceID: &serviceID, TagID: &c.tagID, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, tag); ok { return err } text.PrintOperationTag(out, tag) return nil } ================================================ FILE: pkg/commands/apisecurity/tags/list.go ================================================ package tags import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" ) // ListCommand calls the Fastly API to list all operation tags. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. 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 all operation tags") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) 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 } 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 serviceID == "" { return errors.New("service-id is required") } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } // Auto-paginate through all results var allTags []operations.OperationTag page := 0 limit := 100 input := &operations.ListTagsInput{ ServiceID: &serviceID, } for { input.Page = &page input.Limit = &limit tags, err := operations.ListTags(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, "Page": page, }) return err } if tags == nil || len(tags.Data) == 0 { break } allTags = append(allTags, tags.Data...) // Check if we've fetched all results if len(allTags) >= tags.Meta.Total { break } page++ } if ok, err := c.WriteJSON(out, allTags); ok { return err } text.PrintOperationTagsTbl(out, allTags) return nil } ================================================ FILE: pkg/commands/apisecurity/tags/root.go ================================================ package tags 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 = "tags" // 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 API Security operation tags") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/apisecurity/tags/tags_test.go ================================================ package tags_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/apisecurity" sub "github.com/fastly/cli/pkg/commands/apisecurity/tags" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" ) const ( serviceID = "test-service-id" tagID = "tag-123" tagName = "APIv1" tagDescription = "All-APIv1-endpoints" updatedTagName = "APIv1.1" updatedTagDesc = "Updated-APIv1-endpoints" ) var tag = operations.OperationTag{ ID: tagID, Name: tagName, Description: tagDescription, Count: 5, CreatedAt: "2021-06-15T23:00:00Z", UpdatedAt: "2021-06-15T23:00:00Z", } func TestTagsCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: fmt.Sprintf("--name %s", tagName), EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--service-id %s", serviceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--service-id %s --name %s", serviceID, tagName), 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("--service-id %s --name %s --description %s", serviceID, tagName, tagDescription), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), }, }, }, WantOutput: fstfmt.Success("Created operation tag '%s' (id: %s)", tagName, tagID), }, { Name: "validate API success without description", Args: fmt.Sprintf("--service-id %s --name %s", serviceID, tagName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), }, }, }, WantOutput: fstfmt.Success("Created operation tag '%s' (id: %s)", tagName, tagID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--service-id %s --name %s --description %s --json", serviceID, tagName, tagDescription), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), }, }, }, WantOutput: fstfmt.EncodeJSON(tag), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestTagsDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: fmt.Sprintf("--tag-id %s", tagID), EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --tag-id flag", Args: fmt.Sprintf("--service-id %s", serviceID), WantError: "error parsing arguments: required flag --tag-id not provided", }, { Name: "validate bad request", Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), 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 tag ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted operation tag (id: %s)", tagID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--service-id %s --tag-id %s --json", serviceID, tagID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"service_id": %q, "tag_id": %q, "deleted": true}`, serviceID, tagID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestTagsGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: fmt.Sprintf("--tag-id %s", tagID), EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --tag-id flag", Args: fmt.Sprintf("--service-id %s", serviceID), WantError: "error parsing arguments: required flag --tag-id not provided", }, { Name: "validate bad request", Args: fmt.Sprintf("--service-id %s --tag-id invalid", serviceID), 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 tag ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s --tag-id %s", serviceID, tagID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), }, }, }, WantOutput: tagString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--service-id %s --tag-id %s --json", serviceID, tagID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tag))), }, }, }, WantOutput: fstfmt.EncodeJSON(tag), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestTagsList(t *testing.T) { tagsObject := operations.OperationTags{ Data: []operations.OperationTag{ { ID: "tag-001", Name: "API v1", Description: "All v1 endpoints", Count: 10, CreatedAt: "2021-06-15T23:00:00Z", UpdatedAt: "2021-06-15T23:00:00Z", }, { ID: "tag-002", Name: "API v2", Description: "All v2 endpoints", Count: 25, CreatedAt: "2021-07-01T12:00:00Z", UpdatedAt: "2021-07-01T12:00:00Z", }, }, Meta: operations.Meta{ Limit: 50, Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--service-id %s", serviceID), 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 tags)", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(operations.OperationTags{ Data: []operations.OperationTag{}, Meta: operations.Meta{ Limit: 50, Total: 0, }, }))), }, }, }, WantOutput: zeroListTagsString, }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), }, }, }, WantOutput: listTagsString, }, { Name: "validate API success with pagination", Args: fmt.Sprintf("--service-id %s", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), }, }, }, WantOutput: listTagsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--service-id %s --json", serviceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(tagsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(tagsObject.Data), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestTagsUpdate(t *testing.T) { updatedTag := operations.OperationTag{ ID: tagID, Name: updatedTagName, Description: updatedTagDesc, Count: 5, CreatedAt: "2021-06-15T23:00:00Z", UpdatedAt: "2021-06-16T10:00:00Z", } scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: fmt.Sprintf("--tag-id %s --name %s --description %s", tagID, updatedTagName, updatedTagDesc), EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --tag-id flag", Args: fmt.Sprintf("--service-id %s --name %s --description %s", serviceID, updatedTagName, updatedTagDesc), WantError: "error parsing arguments: required flag --tag-id not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--service-id %s --tag-id %s --description %s", serviceID, tagID, updatedTagDesc), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --description flag", Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s", serviceID, tagID, updatedTagName), WantError: "error parsing arguments: required flag --description not provided", }, { Name: "validate bad request", Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s", serviceID, tagID, updatedTagName, updatedTagDesc), 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 tag", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s", serviceID, tagID, updatedTagName, updatedTagDesc), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedTag))), }, }, }, WantOutput: fstfmt.Success("Updated operation tag '%s' (id: %s)", updatedTagName, tagID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--service-id %s --tag-id %s --name %s --description %s --json", serviceID, tagID, updatedTagName, updatedTagDesc), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedTag))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatedTag), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var tagString = strings.TrimSpace(` ID: tag-123 Name: APIv1 Description: All-APIv1-endpoints Operation Count: 5 Created At: 2021-06-15T23:00:00Z Updated At: 2021-06-15T23:00:00Z `) + "\n" var listTagsString = strings.TrimSpace(` ID Name Description Operations Created At Updated At tag-001 API v1 All v1 endpoints 10 2021-06-15T23:00:00Z 2021-06-15T23:00:00Z tag-002 API v2 All v2 endpoints 25 2021-07-01T12:00:00Z 2021-07-01T12:00:00Z `) + "\n" var zeroListTagsString = strings.TrimSpace(` ID Name Description Operations Created At Updated At `) + "\n" ================================================ FILE: pkg/commands/apisecurity/tags/update.go ================================================ package tags import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" "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 an operation tag. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. tagID string name string description string 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 an operation tag") // Required. c.CmdClause.Flag("tag-id", "Tag ID").Required().StringVar(&c.tagID) c.CmdClause.Flag("name", "Updated name of the operation tag").Required().StringVar(&c.name) c.CmdClause.Flag("description", "Updated description of the operation tag").Required().StringVar(&c.description) // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, }) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.serviceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.serviceName.Value, }) c.RegisterFlagBool(c.JSONFlag()) 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, 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 serviceID == "" { return errors.New("service-id is required") } if c.name == "" { return errors.New("--name is required") } if c.description == "" { return errors.New("--description is required") } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } input := &operations.UpdateTagInput{ Description: &c.description, Name: &c.name, ServiceID: &serviceID, TagID: &c.tagID, } tag, err := operations.UpdateTag(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, tag); ok { return err } text.Success(out, "Updated operation tag '%s' (id: %s)", tag.Name, tag.ID) return nil } ================================================ FILE: pkg/commands/auth/add.go ================================================ package auth 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" ) // AddCommand adds a named token entry. type AddCommand struct { argparser.Base name string token string } func NewAddCommand(parent argparser.Registerer, g *global.Data) *AddCommand { var c AddCommand c.Globals = g c.CmdClause = parent.Command("add", "Store a named token") // Optional. c.CmdClause.Arg("name", "Name for this token (pass to --token to use it later); if omitted, uses the API token's name").StringVar(&c.name) // Required. c.CmdClause.Flag("api-token", "Fastly API token to store").Required().StringVar(&c.token) return &c } func (c *AddCommand) Exec(_ io.Reader, out io.Writer) error { // Short-circuit: if an explicit name was provided and already exists, // fail before making any network calls. if c.name != "" && c.Globals.Config.GetAuthToken(c.name) != nil { return fmt.Errorf("token %q already exists; use 'fastly auth delete %s' first", c.name, c.name) } md, err := FetchTokenMetadataLenient(c.Globals, c.token) if err != nil { return err } name := c.name if name == "" { if md.APITokenName == "" { return fsterr.RemediationError{ Inner: fmt.Errorf("could not determine a name for this token"), Remediation: "Provide a name as the first argument, e.g.: fastly auth add my-token --api-token ", } } name = md.APITokenName // Check collision for the derived name too. if c.Globals.Config.GetAuthToken(name) != nil { return fmt.Errorf("token %q already exists; use 'fastly auth delete %s' first", name, name) } } entry := &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: c.token, Email: md.Email, AccountID: md.AccountID, APITokenName: md.APITokenName, APITokenScope: md.APITokenScope, APITokenExpiresAt: md.APITokenExpiresAt, APITokenID: md.APITokenID, } c.Globals.Config.SetAuthToken(name, entry) // When no default token is configured, automatically promote this token // so CLI commands work without an explicit --token flag. setDefault := c.Globals.Config.Auth.Default == "" if setDefault { c.Globals.Config.Auth.Default = name } if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config: %w", err) } text.Success(out, "Token %q added", name) if setDefault { text.Info(out, "Token %q set as default (no previous default was configured)", name) } text.Info(out, "Token saved to %s", c.Globals.ConfigPath) return nil } ================================================ FILE: pkg/commands/auth/delete.go ================================================ package auth import ( "fmt" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DeleteCommand removes a stored token. type DeleteCommand struct { argparser.Base name string } func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { var c DeleteCommand c.Globals = g c.CmdClause = parent.Command("delete", "Delete a stored token") // Required. c.CmdClause.Arg("name", "Name of the token to remove").Required().StringVar(&c.name) return &c } func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { if c.Globals.Config.GetAuthToken(c.name) == nil { return fmt.Errorf("token %q not found", c.name) } wasDefault := c.Globals.Config.Auth.Default == c.name if wasDefault && !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { text.Warning(out, "%q is your current default token. Deleting it will affect commands that don't use --token or FASTLY_API_TOKEN.", c.name) cont, err := text.AskYesNo(out, "Are you sure? [y/N]: ", in) if err != nil { return err } if !cont { return nil } } c.Globals.Config.DeleteAuthToken(c.name) if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config: %w", err) } text.Success(out, "Token %q removed", c.name) if wasDefault { if c.Globals.Config.Auth.Default != "" { text.Info(out, "Default token reassigned to %q", c.Globals.Config.Auth.Default) } else { text.Warning(out, "No default token configured; use 'fastly auth use ' to set one") } } return nil } ================================================ FILE: pkg/commands/auth/expiry.go ================================================ package auth import ( "fmt" "math" "time" "github.com/fastly/cli/pkg/config" fsterr "github.com/fastly/cli/pkg/errors" ) // ExpirationStatus represents the expiration state of a token. type ExpirationStatus int const ( // StatusNoExpiry means no expiration information is available. StatusNoExpiry ExpirationStatus = iota // StatusOK means the token is valid and not close to expiring. StatusOK // StatusExpiringSoon means the token will expire within the warning threshold. StatusExpiringSoon // StatusExpired means the token has already expired. StatusExpired // StatusNeedsReauth means the token requires re-authentication (NeedsReauth flag set). StatusNeedsReauth ) const ( // expiryWarningThreshold is the warning window for API tokens and SSO refresh tokens. expiryWarningThreshold = 30 * time.Minute // accessOnlyWarningThreshold is the warning window when only an access token // expiry is available (no refresh token). This is shorter because access // tokens are short-lived and auto-refresh. accessOnlyWarningThreshold = 1 * time.Hour ) // GetExpirationStatus computes the expiration status for a token. // It returns the status, the effective expiry time (zero if no expiry), and a // parse error if timestamp fields are present but malformed. func GetExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus, time.Time, error) { if at == nil { return StatusNoExpiry, time.Time{}, nil } if at.NeedsReauth { return StatusNeedsReauth, time.Time{}, nil } switch at.Type { case config.AuthTokenTypeStatic: return staticExpirationStatus(at, now) case config.AuthTokenTypeSSO: return ssoExpirationStatus(at, now) default: // Unknown type; try static-style check on APITokenExpiresAt. return staticExpirationStatus(at, now) } } func staticExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus, time.Time, error) { if at.APITokenExpiresAt == "" { return StatusNoExpiry, time.Time{}, nil } expires, err := time.Parse(time.RFC3339, at.APITokenExpiresAt) if err != nil { return StatusNoExpiry, time.Time{}, fmt.Errorf("invalid api_token_expires_at %q: %w", at.APITokenExpiresAt, err) } return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil } // ssoExpirationStatus handles expiration for SSO tokens. func ssoExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus, time.Time, error) { if at.APITokenExpiresAt != "" { expires, err := time.Parse(time.RFC3339, at.APITokenExpiresAt) if err == nil { return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil } } if at.RefreshToken != "" && at.RefreshExpiresAt == "" { return StatusNoExpiry, time.Time{}, nil } if at.RefreshExpiresAt != "" { expires, err := time.Parse(time.RFC3339, at.RefreshExpiresAt) if err == nil { return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil } } if at.AccessExpiresAt != "" { expires, err := time.Parse(time.RFC3339, at.AccessExpiresAt) if err == nil { return classifyExpiry(expires, now, accessOnlyWarningThreshold), expires, nil } return StatusNoExpiry, time.Time{}, fmt.Errorf("invalid access_expires_at %q: %w", at.AccessExpiresAt, err) } return StatusNoExpiry, time.Time{}, nil } func classifyExpiry(expires, now time.Time, threshold time.Duration) ExpirationStatus { if now.After(expires) { return StatusExpired } remaining := expires.Sub(now) if remaining <= threshold { return StatusExpiringSoon } return StatusOK } // ExpirationSummary returns a human-readable string describing the time until // or since expiry. Returns "" for StatusNoExpiry and StatusNeedsReauth. func ExpirationSummary(status ExpirationStatus, expires time.Time, now time.Time) string { switch status { case StatusOK, StatusExpiringSoon: return "expires in " + humanDuration(expires.Sub(now)) case StatusExpired: return "expired " + humanDuration(now.Sub(expires)) + " ago" case StatusNoExpiry, StatusNeedsReauth: return "" } return "" } // ExpirationRemediation returns actionable remediation text for the given token type. func ExpirationRemediation(tokenType string) string { return fsterr.TokenExpirationRemediationForType(tokenType) } // humanDuration formats a duration into a short human-readable string like // "3 days", "2 hours", "45 minutes". Always returns a positive representation. func humanDuration(d time.Duration) string { if d < 0 { d = -d } switch { case d < time.Minute: s := int(d.Seconds()) if s <= 1 { return "1 second" } return fmt.Sprintf("%d seconds", s) case d < time.Hour: m := int(d.Minutes()) if m == 1 { return "1 minute" } return fmt.Sprintf("%d minutes", m) case d < 24*time.Hour: h := int(math.Round(d.Hours())) if h == 1 { return "1 hour" } return fmt.Sprintf("%d hours", h) default: days := int(math.Round(d.Hours() / 24)) if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) } } ================================================ FILE: pkg/commands/auth/expiry_test.go ================================================ package auth_test import ( "os" "testing" "time" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/config" ) func TestGetExpirationStatus(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) tests := []struct { name string token *config.AuthToken wantStatus authcmd.ExpirationStatus wantErr bool }{ // Nil token. { name: "nil token", token: nil, wantStatus: authcmd.StatusNoExpiry, }, // NeedsReauth precedence. { name: "needs reauth takes precedence over valid expiry", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, NeedsReauth: true, RefreshExpiresAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusNeedsReauth, }, { name: "needs reauth takes precedence over expired", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, NeedsReauth: true, RefreshExpiresAt: now.Add(-1 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusNeedsReauth, }, // Static tokens. { name: "static no expiry", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, }, wantStatus: authcmd.StatusNoExpiry, }, { name: "static future expiry OK", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "static not expiring soon (3 days out)", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "static expiring soon (within 30 minutes)", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: now.Add(20 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, { name: "static expiring soon (exactly 30 minutes)", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: now.Add(30 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, { name: "static expired", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: now.Add(-2 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, { name: "static malformed expiry", token: &config.AuthToken{ Type: config.AuthTokenTypeStatic, APITokenExpiresAt: "not-a-date", }, wantStatus: authcmd.StatusNoExpiry, wantErr: true, }, // SSO tokens: RefreshExpiresAt primary. { name: "sso api_token_expires_at preferred over refresh", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, APITokenExpiresAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339), RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso api_token_expires_at not expiring soon (3 days out)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339), RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso api_token_expires_at expiring soon (within 30 minutes)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, APITokenExpiresAt: now.Add(15 * time.Minute).Format(time.RFC3339), RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, { name: "sso api_token_expires_at expired", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, APITokenExpiresAt: now.Add(-1 * time.Hour).Format(time.RFC3339), RefreshExpiresAt: now.Add(-2 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, { name: "sso refresh OK", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso refresh not expiring soon (3 days out)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso refresh expiring soon (within 30 minutes)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: now.Add(20 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, { name: "sso refresh expired", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: now.Add(-1 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, // SSO tokens: AccessExpiresAt fallback. { name: "sso no refresh, access OK (beyond 1h threshold)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: now.Add(2 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso no refresh, access expiring soon (within 1h)", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: now.Add(30 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, { name: "sso no refresh, access expired", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: now.Add(-10 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, // SSO tokens: malformed timestamps. { name: "sso malformed refresh, valid access fallback", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: "garbage", AccessExpiresAt: now.Add(2 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "sso malformed refresh, no access", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: "garbage", }, wantStatus: authcmd.StatusNoExpiry, wantErr: false, }, { name: "sso no refresh, malformed access", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: "garbage", }, wantStatus: authcmd.StatusNoExpiry, wantErr: true, }, { name: "sso both malformed", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, RefreshExpiresAt: "bad1", AccessExpiresAt: "bad2", }, wantStatus: authcmd.StatusNoExpiry, wantErr: true, }, { name: "sso no expiry fields at all", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, }, wantStatus: authcmd.StatusNoExpiry, }, // Unknown type falls through to static-style check. { name: "unknown type with expiry (not soon)", token: &config.AuthToken{ Type: "unknown", APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusOK, }, { name: "unknown type with expiry (within 30 minutes)", token: &config.AuthToken{ Type: "unknown", APITokenExpiresAt: now.Add(10 * time.Minute).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpiringSoon, }, // Consistency with checkAndRefreshAuthSSOToken: when both access and // refresh are expired, the refresh function returns reauth=true. // ExpirationStatus must not return StatusOK. { name: "consistency: both expired yields StatusExpired not StatusOK", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: now.Add(-2 * time.Hour).Format(time.RFC3339), RefreshExpiresAt: now.Add(-1 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, { name: "consistency: access expired, no refresh yields StatusExpired", token: &config.AuthToken{ Type: config.AuthTokenTypeSSO, AccessExpiresAt: now.Add(-2 * time.Hour).Format(time.RFC3339), }, wantStatus: authcmd.StatusExpired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, _, err := authcmd.GetExpirationStatus(tt.token, now) if status != tt.wantStatus { t.Errorf("GetExpirationStatus() status = %v, want %v", status, tt.wantStatus) } if (err != nil) != tt.wantErr { t.Errorf("GetExpirationStatus() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestExpirationSummary(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) tests := []struct { name string status authcmd.ExpirationStatus expires time.Time want string }{ { name: "expires in 3 days", status: authcmd.StatusExpiringSoon, expires: now.Add(3 * 24 * time.Hour), want: "expires in 3 days", }, { name: "expires in 2 hours", status: authcmd.StatusExpiringSoon, expires: now.Add(2 * time.Hour), want: "expires in 2 hours", }, { name: "expired 2 hours ago", status: authcmd.StatusExpired, expires: now.Add(-2 * time.Hour), want: "expired 2 hours ago", }, { name: "expired 1 day ago", status: authcmd.StatusExpired, expires: now.Add(-24 * time.Hour), want: "expired 1 day ago", }, { name: "OK returns summary too", status: authcmd.StatusOK, expires: now.Add(30 * 24 * time.Hour), want: "expires in 30 days", }, { name: "no expiry returns empty", status: authcmd.StatusNoExpiry, want: "", }, { name: "needs reauth returns empty", status: authcmd.StatusNeedsReauth, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := authcmd.ExpirationSummary(tt.status, tt.expires, now) if got != tt.want { t.Errorf("ExpirationSummary() = %q, want %q", got, tt.want) } }) } } func TestExpirationRemediation(t *testing.T) { // Ensure we test in a clean env state. originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND") defer os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", originalEnv) tests := []struct { name string tokenType string disableEnv string wantSubstr string }{ { name: "sso with auth enabled", tokenType: "sso", wantSubstr: "fastly auth login --sso", }, { name: "static with auth enabled", tokenType: "static", wantSubstr: "fastly auth add", }, { name: "unknown type with auth enabled defaults to sso", tokenType: "", wantSubstr: "fastly auth login --sso", }, { name: "sso with auth disabled", tokenType: "sso", disableEnv: "1", wantSubstr: "FASTLY_API_TOKEN", }, { name: "static with auth disabled", tokenType: "static", disableEnv: "1", wantSubstr: "FASTLY_API_TOKEN", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { os.Setenv("FASTLY_DISABLE_AUTH_COMMAND", tt.disableEnv) got := authcmd.ExpirationRemediation(tt.tokenType) if got == "" { t.Fatal("ExpirationRemediation() returned empty string") } found := false if len(tt.wantSubstr) > 0 { for i := 0; i <= len(got)-len(tt.wantSubstr); i++ { if got[i:i+len(tt.wantSubstr)] == tt.wantSubstr { found = true break } } } if !found { t.Errorf("ExpirationRemediation() = %q, want substring %q", got, tt.wantSubstr) } }) } } ================================================ FILE: pkg/commands/auth/list.go ================================================ package auth import ( "fmt" "io" "time" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // ListCommand lists stored tokens. type ListCommand struct { argparser.Base } func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { var c ListCommand c.Globals = g c.CmdClause = parent.Command("list", "List stored tokens and show the default") return &c } func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { tokens := c.Globals.Config.Auth.Tokens if len(tokens) == 0 { text.Output(out, "No tokens stored. Run `fastly auth login` to add one.\n") return nil } now := time.Now() for name, entry := range tokens { marker := " " if name == c.Globals.Config.Auth.Default { marker = "* " } info := entry.Type if entry.Email != "" { info = entry.Email } reauthStr := "" if entry.NeedsReauth { reauthStr = " (needs re-authentication)" } expiryStr := "" if !entry.NeedsReauth { status, expires, err := GetExpirationStatus(entry, now) if err != nil && c.Globals.ErrLog != nil { c.Globals.ErrLog.Add(err) } label := "" if entry.RefreshExpiresAt != "" { label = "session " } switch status { case StatusExpiringSoon: summary := ExpirationSummary(status, expires, now) expiryStr = " " + text.BoldYellow(fmt.Sprintf("[%s%s]", label, summary)) case StatusExpired: summary := ExpirationSummary(status, expires, now) expiryStr = " " + text.BoldRed(fmt.Sprintf("[%s%s]", label, summary)) case StatusOK, StatusNoExpiry, StatusNeedsReauth: } } text.Output(out, "%s%s (%s)%s%s\n", marker, name, info, reauthStr, expiryStr) } return nil } ================================================ FILE: pkg/commands/auth/login.go ================================================ package auth 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" ) // LoginCommand stores a token as the default credential. type LoginCommand struct { argparser.Base sso bool } func NewLoginCommand(parent argparser.Registerer, g *global.Data) *LoginCommand { var c LoginCommand c.Globals = g c.CmdClause = parent.Command("login", "Authenticate and store a default token (paste token or use --sso)") // Optional. c.CmdClause.Flag("sso", "Authenticate via browser-based SSO (requires --token to specify the stored token name)").BoolVar(&c.sso) return &c } func (c *LoginCommand) Exec(in io.Reader, out io.Writer) error { if c.sso { return c.execSSO(in, out) } text.Output(out, "An API token can be generated at: https://manage.fastly.com/account/personal/tokens\n\n") token, err := text.InputSecure(out, "Paste your API token: ", in) if err != nil { return fmt.Errorf("error reading token input: %w", err) } if token == "" { return fmt.Errorf("no token provided") } name, md, err := StoreStaticToken(c.Globals, token) if err != nil { return err } text.Success(out, "Authenticated as %s (token stored as %q)", md.Email, name) text.Info(out, "Token saved to %s", c.Globals.ConfigPath) return nil } func (c *LoginCommand) execSSO(in io.Reader, out io.Writer) error { if c.Globals.Flags.Token == "" { return fsterr.RemediationError{ Inner: fmt.Errorf("SSO login requires a token name via --token"), Remediation: "Provide a name for the stored token, e.g.: fastly auth login --sso --token work-sso", } } tokenName := c.Globals.Flags.Token if c.Globals.AuthServer == nil { return fmt.Errorf("SSO authentication requires network access to the Fastly OIDC provider, but the auth server could not be configured; use 'fastly auth login' (without --sso) to paste a static API token instead") } if err := RunSSOWithTokenName(in, out, c.Globals, false, false, tokenName); err != nil { return fmt.Errorf("SSO authentication failed: %w", err) } c.Globals.Config.Auth.Default = tokenName if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config: %w", err) } text.Break(out) text.Success(out, "Authenticated via SSO.") return nil } ================================================ FILE: pkg/commands/auth/metadata.go ================================================ package auth import ( "context" "fmt" "time" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/global" ) // TokenMetadata holds validated token information from the Fastly API. type TokenMetadata struct { Email string AccountID string APITokenName string APITokenScope string APITokenExpiresAt string APITokenID string } // FetchTokenMetadata validates a token by calling GetCurrentUser (required) // and GetTokenSelf (best-effort), returning metadata for storage. // It constructs its own API client from the provided token string. func FetchTokenMetadata(g *global.Data, token string) (*TokenMetadata, error) { endpoint, _ := g.APIEndpoint() apiClient, err := g.APIClientFactory(token, endpoint, g.Flags.Debug) if err != nil { return nil, fmt.Errorf("error creating API client: %w", err) } user, err := apiClient.GetCurrentUser(context.TODO()) if err != nil { return nil, fmt.Errorf("token validation failed (could not look up current user): %w", err) } md := &TokenMetadata{ Email: fastly.ToValue(user.Login), AccountID: fastly.ToValue(user.CustomerID), } fetchTokenSelf(g, apiClient, md) return md, nil } // FetchTokenMetadataLenient is like FetchTokenMetadata but treats // GetCurrentUser as best-effort. Use this for scoped tokens that may lack // permission to call /current_user. At least one of GetCurrentUser or // GetTokenSelf must succeed to confirm the token is valid. func FetchTokenMetadataLenient(g *global.Data, token string) (*TokenMetadata, error) { endpoint, _ := g.APIEndpoint() apiClient, err := g.APIClientFactory(token, endpoint, g.Flags.Debug) if err != nil { return nil, fmt.Errorf("error creating API client: %w", err) } md := &TokenMetadata{} anyOK := false user, err := apiClient.GetCurrentUser(context.TODO()) if err != nil { g.ErrLog.Add(fmt.Errorf("GetCurrentUser failed (best-effort): %w", err)) } else { md.Email = fastly.ToValue(user.Login) md.AccountID = fastly.ToValue(user.CustomerID) anyOK = true } if fetchTokenSelf(g, apiClient, md) { anyOK = true } if !anyOK { return nil, fmt.Errorf("token validation failed: neither /current_user nor /tokens/self responded successfully") } return md, nil } // EnrichWithTokenSelf calls GetTokenSelf to populate API token metadata on // an existing AuthToken. It constructs its own API client from the token. // This is best-effort: failures are logged and existing fields are preserved. func EnrichWithTokenSelf(g *global.Data, at *config.AuthToken) { endpoint, _ := g.APIEndpoint() apiClient, err := g.APIClientFactory(at.Token, endpoint, g.Flags.Debug) if err != nil { g.ErrLog.Add(fmt.Errorf("EnrichWithTokenSelf: error creating API client: %w", err)) return } var md TokenMetadata if fetchTokenSelf(g, apiClient, &md) { at.APITokenName = md.APITokenName at.APITokenScope = md.APITokenScope at.APITokenExpiresAt = md.APITokenExpiresAt at.APITokenID = md.APITokenID } } // BuildAndStoreStaticToken constructs an AuthToken from pre-fetched metadata // and stores it under the given name. Does NOT write config to disk. func BuildAndStoreStaticToken(g *global.Data, token, name string, md *TokenMetadata, makeDefault bool) { entry := &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: token, Email: md.Email, AccountID: md.AccountID, APITokenName: md.APITokenName, APITokenScope: md.APITokenScope, APITokenExpiresAt: md.APITokenExpiresAt, APITokenID: md.APITokenID, } g.Config.SetAuthToken(name, entry) if makeDefault { g.Config.Auth.Default = name } } // StoreStaticToken validates a raw API token, fetches metadata, and stores it // in the auth config as the default token. Returns the stored name and // metadata. func StoreStaticToken(g *global.Data, token string) (name string, md *TokenMetadata, err error) { md, err = FetchTokenMetadata(g, token) if err != nil { return "", nil, err } name = md.APITokenName if name == "" { name = "default" } BuildAndStoreStaticToken(g, token, name, md, true) if err := g.Config.Write(g.ConfigPath); err != nil { return "", nil, fmt.Errorf("error saving config: %w", err) } return name, md, nil } // fetchTokenSelf calls GetTokenSelf and populates md on success. // Returns true if the call succeeded and md was populated, false otherwise. // Failures (including panics from unmocked test doubles) are logged. func fetchTokenSelf(g *global.Data, apiClient api.Interface, md *TokenMetadata) (ok bool) { defer func() { if r := recover(); r != nil { g.ErrLog.Add(fmt.Errorf("GetTokenSelf panicked (best-effort): %v", r)) ok = false } }() tok, err := apiClient.GetTokenSelf(context.TODO()) if err != nil { g.ErrLog.Add(fmt.Errorf("GetTokenSelf failed (best-effort): %w", err)) return false } md.APITokenName = fastly.ToValue(tok.Name) if tok.Scope != nil { md.APITokenScope = string(*tok.Scope) } if tok.ExpiresAt != nil { md.APITokenExpiresAt = tok.ExpiresAt.Format(time.RFC3339) } md.APITokenID = fastly.ToValue(tok.TokenID) return true } ================================================ FILE: pkg/commands/auth/metadata_test.go ================================================ package auth_test import ( "context" "fmt" "testing" "time" "github.com/fastly/go-fastly/v15/fastly" authcmd "github.com/fastly/cli/pkg/commands/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" ) var ( testTokenScope = fastly.GlobalScope testTokenExpiry = time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) testTokenSelfFull = func(_ context.Context) (*fastly.Token, error) { return &fastly.Token{ TokenID: fastly.ToPointer("tok-id-123"), Name: fastly.ToPointer("my-api-token"), Scope: &testTokenScope, }, nil } testTokenSelfWithExpiry = func(_ context.Context) (*fastly.Token, error) { return &fastly.Token{ TokenID: fastly.ToPointer("tok-id-expiry"), Name: fastly.ToPointer("expiring-token"), Scope: &testTokenScope, ExpiresAt: &testTokenExpiry, }, nil } testTokenSelfNoName = func(_ context.Context) (*fastly.Token, error) { return &fastly.Token{ TokenID: fastly.ToPointer("tok-id-noname"), Scope: &testTokenScope, }, nil } testGetCurrentUser = func(_ context.Context) (*fastly.User, error) { return &fastly.User{ Login: fastly.ToPointer("alice@example.com"), CustomerID: fastly.ToPointer("cust-abc123"), }, nil } ) func TestAuthAdd(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "add with explicit name stores metadata", Args: "add mytoken --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfFull, }, WantOutputs: []string{`Token "mytoken" added`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("mytoken") if at == nil { t.Fatal("expected auth token 'mytoken' to exist") } if at.Email != "alice@example.com" { t.Errorf("want email alice@example.com, got %s", at.Email) } if at.AccountID != "cust-abc123" { t.Errorf("want account ID cust-abc123, got %s", at.AccountID) } if at.APITokenName != "my-api-token" { t.Errorf("want APITokenName my-api-token, got %s", at.APITokenName) } if at.APITokenScope != "global" { t.Errorf("want APITokenScope global, got %s", at.APITokenScope) } if at.APITokenID != "tok-id-123" { t.Errorf("want APITokenID tok-id-123, got %s", at.APITokenID) } }, }, { Name: "add without name derives from API token name", Args: "add --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfFull, }, WantOutputs: []string{`Token "my-api-token" added`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("my-api-token") if at == nil { t.Fatal("expected auth token 'my-api-token' to exist") } if at.APITokenName != "my-api-token" { t.Errorf("want APITokenName my-api-token, got %s", at.APITokenName) } }, }, { Name: "add without name fails when API token has no name", Args: "add --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfNoName, }, WantError: "could not determine a name for this token", }, { Name: "add stores expiry when present", Args: "add expiring --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfWithExpiry, }, WantOutputs: []string{`Token "expiring" added`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("expiring") if at == nil { t.Fatal("expected auth token to exist") } if at.APITokenExpiresAt == "" { t.Error("expected APITokenExpiresAt to be set") } }, }, { Name: "add sets default when no default exists", Args: "add first-token --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfFull, }, ConfigFile: &config.File{ Auth: config.Auth{}, }, WantOutputs: []string{`Token "first-token" added`, "set as default"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.Auth.Default != "first-token" { t.Errorf("want Auth.Default first-token, got %s", opts.Config.Auth.Default) } }, }, { Name: "add rejects duplicate name", Args: "add existing --api-token test-token-value", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfFull, }, ConfigFile: &config.File{ Auth: config.Auth{ Default: "existing", Tokens: config.AuthTokens{ "existing": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "old-token", }, }, }, }, WantError: `token "existing" already exists`, }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } func TestAuthLogin(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "login stores metadata under API token name", Args: "login", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfFull, }, Stdin: []string{ "my-login-token", }, WantOutputs: []string{`Authenticated as alice@example.com (token stored as "my-api-token")`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("my-api-token") if at == nil { t.Fatal("expected auth token 'my-api-token' to exist") } if at.APITokenName != "my-api-token" { t.Errorf("want APITokenName my-api-token, got %s", at.APITokenName) } if at.APITokenScope != "global" { t.Errorf("want APITokenScope global, got %s", at.APITokenScope) } if at.APITokenID != "tok-id-123" { t.Errorf("want APITokenID tok-id-123, got %s", at.APITokenID) } if at.Email != "alice@example.com" { t.Errorf("want email alice@example.com, got %s", at.Email) } if opts.Config.Auth.Default != "my-api-token" { t.Errorf("want Auth.Default my-api-token, got %s", opts.Config.Auth.Default) } }, }, { Name: "login falls back to default when API token has no name", Args: "login", API: &mock.API{ GetCurrentUserFn: testGetCurrentUser, GetTokenSelfFn: testTokenSelfNoName, }, Stdin: []string{ "my-login-token", }, WantOutputs: []string{`Authenticated as alice@example.com (token stored as "default")`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("default") if at == nil { t.Fatal("expected auth token 'default' to exist") } if opts.Config.Auth.Default != "default" { t.Errorf("want Auth.Default default, got %s", opts.Config.Auth.Default) } }, }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } func TestAuthShow(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "show displays metadata when present", Args: "show mytoken", ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "test-token-value", Email: "alice@example.com", AccountID: "cust-abc123", APITokenName: "my-api-token", APITokenScope: "global", APITokenExpiresAt: "2025-12-31T23:59:59Z", APITokenID: "tok-id-123", }, }, }, }, WantOutputs: []string{ "API token name: my-api-token", "API token scope: global", "API token expires at: 2025-12-31T23:59:59Z", "API token ID: tok-id-123", }, }, { Name: "show omits metadata when absent", Args: "show basic", ConfigFile: &config.File{ Auth: config.Auth{ Default: "basic", Tokens: config.AuthTokens{ "basic": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "test-token-value", }, }, }, }, DontWantOutputs: []string{ "API token name:", "API token scope:", "API token expires at:", "API token ID:", }, }, { Name: "show without name uses default token", Args: "show", ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "test-token-value", Email: "alice@example.com", APITokenName: "my-api-token", }, }, }, }, WantOutputs: []string{ "Name: mytoken (default)", "Email: alice@example.com", "API token name: my-api-token", }, }, { Name: "show without name errors when env token set", Args: "show", Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Env.APIToken = "env-token-value" }, ConfigFile: &config.File{}, WantError: "current token is not stored", }, { Name: "show without name resolves manifest profile", Args: "show", ConfigFile: &config.File{ Auth: config.Auth{ Default: "other", Tokens: config.AuthTokens{ "other": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "other-token", }, "from-manifest": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "manifest-token", Email: "manifest@example.com", APITokenName: "manifest-api-token", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Manifest.File.Profile = "from-manifest" }, WantOutputs: []string{ "Name: from-manifest", "Email: manifest@example.com", "API token name: manifest-api-token", }, DontWantOutputs: []string{ "(default)", }, }, { Name: "show with unknown --profile rejected", Args: "show --profile bogus", ConfigFile: &config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, }, }, }, WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "show with known --profile uses that profile", Args: "show --profile alt", ConfigFile: &config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token", Email: "a@example.com"}, }, }, }, WantOutputs: []string{"Name: alt", "a@example.com"}, }, { Name: "show with --token raw --profile bogus skips profile validation", Args: "show --token raw --profile bogus", ConfigFile: &config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, }, }, }, WantError: "current token is not stored", DontWantOutput: "not found in auth config", }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } func TestAuthAddScopedToken(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "add with name succeeds when GetCurrentUser fails but GetTokenSelf succeeds", Args: "add purge-token --api-token scoped-token-value", API: &mock.API{ GetCurrentUserFn: func(_ context.Context) (*fastly.User, error) { return nil, fmt.Errorf("403 Forbidden: Access denied to purge token") }, GetTokenSelfFn: testTokenSelfFull, }, WantOutputs: []string{`Token "purge-token" added`, "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("purge-token") if at == nil { t.Fatal("expected auth token 'purge-token' to exist") } if at.Token != "scoped-token-value" { t.Errorf("want token scoped-token-value, got %s", at.Token) } if at.Email != "" { t.Errorf("want empty email for scoped token, got %s", at.Email) } if at.APITokenName != "my-api-token" { t.Errorf("want APITokenName my-api-token, got %s", at.APITokenName) } if at.APITokenScope != "global" { t.Errorf("want APITokenScope global, got %s", at.APITokenScope) } }, }, { Name: "add with name fails when both API calls fail (invalid token)", Args: "add bad-token --api-token invalid-token-value", API: &mock.API{ GetCurrentUserFn: func(_ context.Context) (*fastly.User, error) { return nil, fmt.Errorf("403 Forbidden") }, GetTokenSelfFn: func(_ context.Context) (*fastly.Token, error) { return nil, fmt.Errorf("403 Forbidden") }, }, WantError: "token validation failed: neither /current_user nor /tokens/self responded successfully", }, { Name: "add without name gives friendly error for scoped token", Args: "add --api-token scoped-token-value", API: &mock.API{ GetCurrentUserFn: func(_ context.Context) (*fastly.User, error) { return nil, fmt.Errorf("403 Forbidden: Access denied to purge token") }, GetTokenSelfFn: testTokenSelfNoName, }, WantError: "could not determine a name for this token", }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } func TestEnrichWithTokenSelfPreservesOnFailure(t *testing.T) { var stdout threadsafe.Buffer data := testutil.MockGlobalData([]string{"fastly"}, &stdout) data.APIClientFactory = mock.APIClient(mock.API{ GetTokenSelfFn: func(_ context.Context) (*fastly.Token, error) { return nil, fmt.Errorf("403 forbidden") }, }) at := &config.AuthToken{ Token: "existing-token", APITokenName: "original-name", APITokenScope: "global", APITokenExpiresAt: "2025-06-01T00:00:00Z", APITokenID: "original-id", } authcmd.EnrichWithTokenSelf(data, at) if at.APITokenName != "original-name" { t.Errorf("want APITokenName preserved as original-name, got %s", at.APITokenName) } if at.APITokenScope != "global" { t.Errorf("want APITokenScope preserved as global, got %s", at.APITokenScope) } if at.APITokenExpiresAt != "2025-06-01T00:00:00Z" { t.Errorf("want APITokenExpiresAt preserved, got %s", at.APITokenExpiresAt) } if at.APITokenID != "original-id" { t.Errorf("want APITokenID preserved as original-id, got %s", at.APITokenID) } } func TestAuthDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "delete non-default token", Args: "delete secondary", ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok2"}, }, }, }, WantOutputs: []string{`Token "secondary" removed`}, DontWantOutput: "reassigned", }, { Name: "delete default token confirms and reassigns", Args: "delete primary", ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok2"}, }, }, }, Stdin: []string{"y"}, WantOutputs: []string{"current default token", "FASTLY_API_TOKEN", "Are you sure", `Token "primary" removed`, "Default token reassigned"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.Auth.Default == "primary" { t.Error("expected default to no longer be the deleted token") } if opts.Config.Auth.Default == "" { t.Error("expected default to be reassigned to remaining token") } }, }, { Name: "delete default token aborted by user", Args: "delete primary", ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok2"}, }, }, }, Stdin: []string{"n"}, DontWantOutput: "removed", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.Auth.Default != "primary" { t.Error("expected default to remain unchanged after user declined") } if opts.Config.GetAuthToken("primary") == nil { t.Error("expected token to still exist after user declined") } }, }, { Name: "delete last token confirms and warns no default", Args: "delete only", ConfigFile: &config.File{ Auth: config.Auth{ Default: "only", Tokens: config.AuthTokens{ "only": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, }, }, }, Stdin: []string{"y"}, WantOutputs: []string{`Token "only" removed`, "No default token configured"}, }, { Name: "delete stale default returns not found without prompting", Args: "delete ghost", ConfigFile: &config.File{ Auth: config.Auth{ Default: "ghost", Tokens: config.AuthTokens{}, }, }, WantError: `token "ghost" not found`, DontWantOutput: "Are you sure", }, { Name: "delete nonexistent token fails", Args: "delete ghost", WantError: `token "ghost" not found`, }, { Name: "delete default token skips prompt with --auto-yes", Args: "delete primary -y", ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok2"}, }, }, }, DontWantOutput: "Are you sure", WantOutputs: []string{`Token "primary" removed`, "Default token reassigned"}, }, { Name: "delete default token skips prompt with --non-interactive", Args: "delete primary -i", ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok1"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok2"}, }, }, }, DontWantOutput: "Are you sure", WantOutputs: []string{`Token "primary" removed`, "Default token reassigned"}, }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } ================================================ FILE: pkg/commands/auth/revoke.go ================================================ package auth import ( "bufio" "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/api" "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" ) // errCancelled is returned when a user declines a confirmation prompt. // It signals intentional cancellation, not a failure condition. var errCancelled = errors.New("cancelled") // RevokeCommand revokes a token via the API and removes it from local config. type RevokeCommand struct { argparser.Base current bool name string tokenValue string id string file string } func NewRevokeCommand(parent argparser.Registerer, g *global.Data) *RevokeCommand { var c RevokeCommand c.Globals = g c.CmdClause = parent.Command("revoke", "Revoke a token via the API and remove it from local config") c.CmdClause.Flag("current", "Revoke the token used to authenticate the current request").BoolVar(&c.current) c.CmdClause.Flag("name", "Name of a locally stored token to revoke").StringVar(&c.name) c.CmdClause.Flag("token-value", "Raw API token string to revoke (pass '-' to read from stdin)").StringVar(&c.tokenValue) c.CmdClause.Flag("id", "Alphanumeric string identifying a token to revoke").StringVar(&c.id) c.CmdClause.Flag("file", "Path to a newline-delimited file of token IDs to revoke in bulk").StringVar(&c.file) return &c } func (c *RevokeCommand) Exec(in io.Reader, out io.Writer) error { if err := c.validateFlags(); err != nil { return err } if err := c.Globals.ValidateProfileFlag(); err != nil { return err } switch { case c.current: return c.revokeCurrent(in, out) case c.name != "": return c.revokeByName(in, out) case c.tokenValue != "": return c.revokeByTokenValue(in, out) case c.id != "": return c.revokeByID(out) case c.file != "": return c.revokeByFile(out) } return nil } func (c *RevokeCommand) validateFlags() error { count := 0 if c.current { count++ } if c.name != "" { count++ } if c.tokenValue != "" { count++ } if c.id != "" { count++ } if c.file != "" { count++ } if count == 0 { return fmt.Errorf("error parsing arguments: must provide one of --current, --name, --token-value, --id, or --file") } if count > 1 { return fmt.Errorf("error parsing arguments: only one of --current, --name, --token-value, --id, or --file may be used") } return nil } func (c *RevokeCommand) revokeCurrent(in io.Reader, out io.Writer) error { tok, _ := c.Globals.Token() names := findLocalTokensByValue(&c.Globals.Config, tok) if err := c.confirmDefaultRevocation(names, in, out); err != nil { if errors.Is(err, errCancelled) { return nil } return err } client, err := c.authClient() if err != nil { return err } err = client.DeleteTokenSelf(context.TODO()) if err != nil { c.Globals.ErrLog.Add(err) return err } text.Success(out, "Revoked current token") return c.removeLocalTokens(names, out) } func (c *RevokeCommand) revokeByName(in io.Reader, out io.Writer) error { entry := c.Globals.Config.GetAuthToken(c.name) if entry == nil { return fmt.Errorf("token %q not found", c.name) } if err := c.confirmDefaultRevocation([]string{c.name}, in, out); err != nil { if errors.Is(err, errCancelled) { return nil } return err } client, err := c.buildClient(entry.Token) if err != nil { return err } err = client.DeleteTokenSelf(context.TODO()) if err != nil { if isSelfAlreadyGone(err) { text.Warning(out, "Token was already revoked remotely\n") } else { c.Globals.ErrLog.Add(err) return err } } else { text.Success(out, "Revoked token %q", c.name) } names := []string{c.name} for _, n := range findLocalTokensByValue(&c.Globals.Config, entry.Token) { if n != c.name { names = append(names, n) } } return c.removeLocalTokens(names, out) } func (c *RevokeCommand) revokeByTokenValue(in io.Reader, out io.Writer) error { raw, err := readTokenValue(c.tokenValue, in) if err != nil { return err } names := findLocalTokensByValue(&c.Globals.Config, raw) if err := c.confirmDefaultRevocation(names, in, out); err != nil { if errors.Is(err, errCancelled) { return nil } return err } client, err := c.buildClient(raw) if err != nil { return err } err = client.DeleteTokenSelf(context.TODO()) if err != nil { if isSelfAlreadyGone(err) { text.Warning(out, "Token was already revoked remotely\n") } else { c.Globals.ErrLog.Add(err) return err } } else { text.Success(out, "Revoked token") } if len(names) == 0 { text.Info(out, "No matching local token entry found\n") return nil } return c.removeLocalTokens(names, out) } func (c *RevokeCommand) revokeByID(out io.Writer) error { client, err := c.authClient() if err != nil { return err } err = client.DeleteToken(context.TODO(), &fastly.DeleteTokenInput{ TokenID: c.id, }) if err != nil { if isAlreadyGone(err) { text.Warning(out, "Token was already revoked remotely\n") } else { c.Globals.ErrLog.Add(err) return err } } else { text.Success(out, "Revoked token '%s'", c.id) } names := findLocalTokensByID(&c.Globals.Config, c.id) if len(names) == 0 { text.Info(out, "No local token entry with matching API token ID found; local cleanup skipped\n") return nil } return c.removeLocalTokens(names, out) } func (c *RevokeCommand) revokeByFile(out io.Writer) error { ids, err := readTokenIDFile(c.file) if err != nil { return err } client, err := c.authClient() if err != nil { return err } tokens := make([]*fastly.BatchToken, len(ids)) for i, id := range ids { tokens[i] = &fastly.BatchToken{ID: id} } err = client.BatchDeleteTokens(context.TODO(), &fastly.BatchDeleteTokensInput{ Tokens: tokens, }) if err != nil { c.Globals.ErrLog.Add(err) return err } text.Success(out, "Revoked %d token(s)", len(ids)) if c.Globals.Verbose() { tbl := text.NewTable(out) tbl.AddHeader("TOKEN ID") for _, id := range ids { tbl.AddLine(id) } tbl.Print() } var names []string for _, id := range ids { names = append(names, findLocalTokensByID(&c.Globals.Config, id)...) } if len(names) == 0 { text.Info(out, "No local token entries with matching API token IDs found; local cleanup skipped\n") return nil } return c.removeLocalTokens(names, out) } func (c *RevokeCommand) authClient() (api.Interface, error) { tok, _ := c.Globals.Token() if tok == "" { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("no token available for authentication"), Remediation: fsterr.AuthRemediation(), } } return c.buildClient(tok) } func (c *RevokeCommand) buildClient(token string) (api.Interface, error) { endpoint, _ := c.Globals.APIEndpoint() client, err := c.Globals.APIClientFactory(token, endpoint, c.Globals.Flags.Debug) if err != nil { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("error creating API client: %w", err), Remediation: "Check your network connection and API endpoint configuration.", } } return client, nil } func (c *RevokeCommand) confirmDefaultRevocation(names []string, in io.Reader, out io.Writer) error { def := c.Globals.Config.Auth.Default if def == "" { return nil } isDefault := false for _, n := range names { if n == def { isDefault = true break } } if !isDefault { return nil } if c.Globals.Flags.AutoYes || c.Globals.Flags.NonInteractive { return nil } text.Warning(out, "%q is your current default token. Revoking it will invalidate it remotely and remove it from local config.", def) cont, err := text.AskYesNo(out, "Are you sure? [y/N]: ", in) if err != nil { return err } if !cont { return errCancelled } return nil } func isAlreadyGone(err error) bool { var httpErr *fastly.HTTPError return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound } func isSelfAlreadyGone(err error) bool { var httpErr *fastly.HTTPError return isAlreadyGone(err) || (errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusUnauthorized) } func readTokenValue(flag string, in io.Reader) (string, error) { if flag == "-" { const maxTokenSize = 4096 b, err := io.ReadAll(io.LimitReader(in, maxTokenSize+1)) if err != nil { return "", fsterr.RemediationError{ Inner: fmt.Errorf("failed to read token from stdin: %w", err), Remediation: "Pipe a token value, e.g.: echo $TOKEN | fastly auth revoke --token-value=-", } } if len(b) > maxTokenSize { return "", fsterr.RemediationError{ Inner: fmt.Errorf("stdin input exceeds %d bytes", maxTokenSize), Remediation: "Pipe a single token value, not a file. Example: echo $TOKEN | fastly auth revoke --token-value=-", } } val := strings.TrimSpace(string(b)) if val == "" { return "", fsterr.RemediationError{ Inner: fmt.Errorf("no token provided on stdin"), Remediation: "Pipe a token value, e.g.: echo $TOKEN | fastly auth revoke --token-value=-", } } return val, nil } return flag, nil } func readTokenIDFile(path string) ([]string, error) { abs, err := filepath.Abs(path) if err != nil { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("invalid file path %q: %w", path, err), Remediation: "Check the file path and try again.", } } f, err := os.Open(abs) // #nosec if err != nil { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("failed to open %q: %w", abs, err), Remediation: "Check the file path and permissions, then try again.", } } defer f.Close() // #nosec var ids []string scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" { ids = append(ids, line) } } if err := scanner.Err(); err != nil { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("error reading %q: %w", abs, err), Remediation: "Check the file for encoding issues or try recreating it.", } } if len(ids) == 0 { return nil, fsterr.RemediationError{ Inner: fmt.Errorf("file %q contains no token IDs", abs), Remediation: "The file should contain one token ID per line.", } } return ids, nil } func findLocalTokensByValue(cfg *config.File, raw string) []string { var names []string for name, entry := range cfg.Auth.Tokens { if entry.Token == raw { names = append(names, name) } } return names } func findLocalTokensByID(cfg *config.File, id string) []string { var names []string for name, entry := range cfg.Auth.Tokens { if entry.APITokenID == id { names = append(names, name) } } return names } func (c *RevokeCommand) removeLocalTokens(names []string, out io.Writer) error { if len(names) == 0 { return nil } originalDefault := c.Globals.Config.Auth.Default removedDefault := false for _, name := range names { if name == originalDefault { removedDefault = true } c.Globals.Config.DeleteAuthToken(name) } if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fsterr.RemediationError{ Inner: fmt.Errorf("token(s) revoked remotely but failed to update local config: %w", err), Remediation: fmt.Sprintf("Check file permissions on %s. The local config may be stale; use 'fastly auth delete' to clean up manually.", c.Globals.ConfigPath), } } for _, name := range names { text.Info(out, "Removed local token entry %q\n", name) } if removedDefault { if c.Globals.Config.Auth.Default != "" { text.Info(out, "Default token reassigned to %q\n", c.Globals.Config.Auth.Default) } else { text.Warning(out, "No default token configured; use 'fastly auth use ' to set one\n") } } return nil } ================================================ FILE: pkg/commands/auth/revoke_test.go ================================================ package auth_test import ( "context" "fmt" "net/http" "os" "path/filepath" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/api" "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 TestAuthRevoke(t *testing.T) { deleteTokenSelfOK := func(_ context.Context) error { return nil } deleteTokenOK := func(_ context.Context, _ *fastly.DeleteTokenInput) error { return nil } batchDeleteTokensOK := func(_ context.Context, _ *fastly.BatchDeleteTokensInput) error { return nil } deleteTokenSelf401 := func(_ context.Context) error { return &fastly.HTTPError{StatusCode: http.StatusUnauthorized} } deleteTokenSelf500 := func(_ context.Context) error { return &fastly.HTTPError{StatusCode: http.StatusInternalServerError} } twoTokenConfig := &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary", APITokenID: "id-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary", APITokenID: "id-secondary"}, }, }, } scenarios := []testutil.CLIScenario{ { Name: "no flags provided", Args: "revoke", WantError: "must provide one of", }, { Name: "multiple flags provided", Args: "revoke --current --name foo", WantError: "only one of", }, // --current { Name: "revoke current token", Args: "revoke --current --token tok-stored", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, }, }, }, Stdin: []string{"y"}, WantOutputs: []string{"Revoked current token", `Removed local token entry "mytoken"`}, }, { Name: "revoke current default declined is clean exit", Args: "revoke --current --token tok-stored", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, }, }, }, Stdin: []string{"n"}, WantOutput: "current default token", DontWantOutput: "Revoked", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("mytoken") == nil { t.Error("expected token to still exist after decline") } }, }, { Name: "revoke current skips prompt with --auto-yes", Args: "revoke --current --token 123 -y", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, DontWantOutput: "Are you sure", WantOutput: "Revoked current token", }, { Name: "revoke current with --token flag (unstored token)", Args: "revoke --current --token raw-ephemeral-token", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, WantOutputs: []string{"Revoked current token"}, DontWantOutput: "Removed local", }, // --name { Name: "revoke by name success", Args: "revoke --name secondary", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantOutputs: []string{`Revoked token "secondary"`, `Removed local token entry "secondary"`}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("secondary") != nil { t.Error("expected secondary token to be removed") } if opts.Config.GetAuthToken("primary") == nil { t.Error("expected primary token to still exist") } }, }, { Name: "revoke by name not found", Args: "revoke --name ghost", WantError: `token "ghost" not found`, }, { Name: "revoke by name remote 401 still cleans up locally", Args: "revoke --name secondary", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelf401}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantOutputs: []string{"already revoked", `Removed local token entry "secondary"`}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("secondary") != nil { t.Error("expected secondary token to be removed after 401") } }, }, { Name: "revoke by name remote 5xx does not clean up locally", Args: "revoke --name secondary", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelf500}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantError: "500", DontWantOutput: "Removed", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("secondary") == nil { t.Error("expected secondary token to still exist after 5xx") } }, }, { Name: "revoke by name default token reassigns", Args: "revoke --name primary -y", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantOutputs: []string{`Removed local token entry "primary"`, "Default token reassigned"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.Auth.Default == "primary" { t.Error("expected default to no longer be primary") } if opts.Config.Auth.Default == "" { t.Error("expected default to be reassigned") } }, }, // --token-value { Name: "revoke by token value success with local match", Args: "revoke --token-value tok-secondary", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantOutputs: []string{"Revoked token", `Removed local token entry "secondary"`}, }, { Name: "revoke by token value no local match", Args: "revoke --token-value tok-unknown", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, WantOutputs: []string{"Revoked token", "No matching local token entry found"}, }, { Name: "revoke by token value from stdin", Args: "revoke --token-value=-", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "other", Tokens: config.AuthTokens{ "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "other-tok"}, "target": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-from-stdin"}, }, }, }, Stdin: []string{"tok-from-stdin"}, WantOutputs: []string{"Revoked token", `Removed local token entry "target"`}, }, { Name: "revoke by token value rejects oversized stdin", Args: "revoke --token-value=-", Stdin: []string{strings.Repeat("x", 5000)}, WantError: "exceeds 4096 bytes", WantRemediation: "single token value", }, { Name: "revoke by token value removes duplicate local entries", Args: "revoke --token-value shared-tok", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "alias1", Tokens: config.AuthTokens{ "alias1": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "shared-tok"}, "alias2": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "shared-tok"}, "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "other-tok"}, }, }, }, Stdin: []string{"y"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("alias1") != nil { t.Error("expected alias1 to be removed") } if opts.Config.GetAuthToken("alias2") != nil { t.Error("expected alias2 to be removed") } if opts.Config.GetAuthToken("other") == nil { t.Error("expected other token to still exist") } }, }, { Name: "revoke by token value confirms when revoking default", Args: "revoke --token-value tok-default", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "mydefault", Tokens: config.AuthTokens{ "mydefault": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-default"}, }, }, }, Stdin: []string{"n"}, WantOutput: "current default token", DontWantOutput: "Revoked", }, // --id { Name: "revoke by ID with local match", Args: "revoke --id id-secondary --token 123", API: &mock.API{DeleteTokenFn: deleteTokenOK}, ConfigFile: func() *config.File { c := *twoTokenConfig c.Auth.Tokens = make(config.AuthTokens) for k, v := range twoTokenConfig.Auth.Tokens { cp := *v c.Auth.Tokens[k] = &cp } return &c }(), WantOutputs: []string{"Revoked token 'id-secondary'", `Removed local token entry "secondary"`}, }, { Name: "revoke by ID no local match warns", Args: "revoke --id id-unknown --token 123", API: &mock.API{DeleteTokenFn: deleteTokenOK}, ConfigFile: func() *config.File { c := *twoTokenConfig c.Auth.Tokens = make(config.AuthTokens) for k, v := range twoTokenConfig.Auth.Tokens { cp := *v c.Auth.Tokens[k] = &cp } return &c }(), WantOutputs: []string{"Revoked token 'id-unknown'", "local cleanup skipped"}, }, { Name: "revoke by ID API 401 returns error without local cleanup", Args: "revoke --id some-id --token 123", API: &mock.API{ DeleteTokenFn: func(_ context.Context, _ *fastly.DeleteTokenInput) error { return &fastly.HTTPError{StatusCode: http.StatusUnauthorized} }, }, ConfigFile: &config.File{ Auth: config.Auth{ Default: "stored", Tokens: config.AuthTokens{ "stored": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok", APITokenID: "some-id"}, }, }, }, WantError: "401", DontWantOutput: "Removed", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("stored") == nil { t.Error("expected token to still exist after 401 on --id path") } }, }, { Name: "revoke by ID legacy token without APITokenID", Args: "revoke --id id-legacy --token 123", API: &mock.API{DeleteTokenFn: deleteTokenOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "legacy", Tokens: config.AuthTokens{ "legacy": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-legacy"}, }, }, }, WantOutputs: []string{"Revoked token 'id-legacy'", "local cleanup skipped"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("legacy") == nil { t.Error("expected legacy token to still exist (no APITokenID)") } }, }, // --file { Name: "revoke by file success", Args: fmt.Sprintf("revoke --file %s --token 123", writeTokenIDFile(t, "id-1\nid-2\n")), API: &mock.API{BatchDeleteTokensFn: batchDeleteTokensOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "tok1", Tokens: config.AuthTokens{ "tok1": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t1", APITokenID: "id-1"}, "tok2": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t2", APITokenID: "id-2"}, "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t3", APITokenID: "id-other"}, }, }, }, WantOutputs: []string{"Revoked 2 token(s)", "Removed local token entry"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() if opts.Config.GetAuthToken("tok1") != nil { t.Error("expected tok1 to be removed") } if opts.Config.GetAuthToken("tok2") != nil { t.Error("expected tok2 to be removed") } if opts.Config.GetAuthToken("other") == nil { t.Error("expected other to still exist") } }, }, { Name: "revoke by file unreadable", Args: "revoke --file /nonexistent/path/tokens.txt --token 123", WantError: "failed to open", WantRemediation: "file path and permissions", }, { Name: "revoke by file empty", Args: fmt.Sprintf("revoke --file %s --token 123", writeTokenIDFile(t, "\n\n")), WantError: "contains no token IDs", WantRemediation: "one token ID per line", }, { Name: "revoke --current with unknown --profile rejected", Args: "revoke --current --profile bogus", WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "revoke --name with unknown --profile rejected", Args: "revoke --name secondary --profile bogus", WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "revoke --token-value with unknown --profile rejected", Args: "revoke --token-value tok-secondary --profile bogus", WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "revoke --id with unknown --profile rejected", Args: "revoke --id some-id --profile bogus", WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "revoke --file with unknown --profile rejected", Args: fmt.Sprintf("revoke --file %s --profile bogus", writeTokenIDFile(t, "id-1\n")), WantError: `profile "bogus"`, WantRemediation: "fastly auth", }, { Name: "revoke --current with --token flag wins over unknown --profile", Args: "revoke --current --token tok-stored --profile bogus", API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, }, }, }, Stdin: []string{"y"}, WantOutputs: []string{"Revoked current token"}, }, // API client factory failure { Name: "API client factory failure on --name", Args: "revoke --name secondary", Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.APIClientFactory = func(_, _ string, _ bool) (api.Interface, error) { return nil, fmt.Errorf("connection refused") } }, ConfigFile: &config.File{ Auth: config.Auth{ Default: "primary", Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, }, }, }, WantError: "connection refused", WantRemediation: "network connection", }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) } func writeTokenIDFile(t *testing.T, content string) string { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "token-ids.txt") if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } return p } ================================================ FILE: pkg/commands/auth/root.go ================================================ package auth import ( "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" ) type RootCommand struct { argparser.Base } const CommandName = "auth" func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { var c RootCommand c.Globals = g c.CmdClause = parent.Command(CommandName, "Manage stored Fastly API tokens and token policies") return &c } func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/auth/show.go ================================================ package auth import ( "fmt" "io" "time" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/env" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/lookup" "github.com/fastly/cli/pkg/text" ) // ShowCommand shows token details. type ShowCommand struct { argparser.Base name string reveal bool } func NewShowCommand(parent argparser.Registerer, g *global.Data) *ShowCommand { var c ShowCommand c.Globals = g c.CmdClause = parent.Command("show", "Show details for a stored token") // Optional. c.CmdClause.Arg("name", "Name of the token to show (defaults to the current token)").StringVar(&c.name) // Optional. c.CmdClause.Flag("reveal", "Show the full token value (use with care)").BoolVar(&c.reveal) return &c } func (c *ShowCommand) Exec(_ io.Reader, out io.Writer) error { if err := c.Globals.ValidateProfileFlag(); err != nil { return err } if c.name == "" { _, src := c.Globals.Token() switch src { case lookup.SourceUndefined: return fmt.Errorf("no token configured; run `fastly auth login` or pass a token name") case lookup.SourceFlag, lookup.SourceEnvironment: return fmt.Errorf("current token is not stored (provided via --token or %s); use `fastly auth add` or `fastly auth show `", env.APIToken) case lookup.SourceFile, lookup.SourceDefault, lookup.SourceAuth: c.name = c.Globals.AuthTokenName() if c.name == "" { c.name = c.Globals.Config.Auth.Default } } } entry := c.Globals.Config.GetAuthToken(c.name) if entry == nil { return fmt.Errorf("token %q not found", c.name) } isDefault := c.name == c.Globals.Config.Auth.Default defaultStr := "" if isDefault { defaultStr = " (default)" } text.Output(out, "Name: %s%s\n", c.name, defaultStr) text.Output(out, "Type: %s\n", entry.Type) if entry.Email != "" { text.Output(out, "Email: %s\n", entry.Email) } if entry.AccountID != "" { text.Output(out, "Account ID: %s\n", entry.AccountID) } if entry.Label != "" { text.Output(out, "Label: %s\n", entry.Label) } if entry.APITokenName != "" { text.Output(out, "API token name: %s\n", entry.APITokenName) } if entry.APITokenScope != "" { text.Output(out, "API token scope: %s\n", entry.APITokenScope) } now := time.Now() status, expires, parseErr := GetExpirationStatus(entry, now) if parseErr != nil && c.Globals.ErrLog != nil { c.Globals.ErrLog.Add(parseErr) } if entry.APITokenExpiresAt != "" { line := "API token expires at: " + entry.APITokenExpiresAt if summary := apiTokenExpirySummary(entry, expires, now); summary != "" { line += " (" + summary + ")" } text.Output(out, "%s\n", line) } if entry.APITokenID != "" { text.Output(out, "API token ID: %s\n", entry.APITokenID) } // For SSO tokens, show the session (refresh) expiry as the user-actionable deadline. if entry.Type == config.AuthTokenTypeSSO && entry.RefreshExpiresAt != "" && !entry.NeedsReauth { line := "SSO session expires at: " + entry.RefreshExpiresAt if summary := ExpirationSummary(status, expires, now); summary != "" { line += " (" + summary + ")" } text.Output(out, "%s\n", line) } if c.reveal { text.Output(out, "Token: %s\n", entry.Token) } else { if len(entry.Token) > 8 { text.Output(out, "Token: %s...%s\n", entry.Token[:4], entry.Token[len(entry.Token)-4:]) } else { text.Output(out, "Token: ****\n") } } if entry.NeedsReauth { text.Warning(out, "This token needs re-authentication. %s\n", ExpirationRemediation(entry.Type)) } else if status == StatusExpired { text.Warning(out, "This token has expired. %s\n", ExpirationRemediation(entry.Type)) } return nil } // apiTokenExpirySummary returns a relative-time string for the APITokenExpiresAt // field specifically. For static tokens this uses the main expiry status; for SSO // tokens the APITokenExpiresAt is secondary so we parse it independently. func apiTokenExpirySummary(entry *config.AuthToken, mainExpires time.Time, now time.Time) string { if entry.Type == config.AuthTokenTypeStatic { // For static tokens, APITokenExpiresAt IS the effective expiry. if mainExpires.IsZero() { return "" } if now.After(mainExpires) { return "expired " + humanDuration(now.Sub(mainExpires)) + " ago" } return "in " + humanDuration(mainExpires.Sub(now)) } // For SSO tokens, parse APITokenExpiresAt independently since the main // expiry status tracks RefreshExpiresAt. if entry.APITokenExpiresAt == "" { return "" } apiExpires, err := time.Parse(time.RFC3339, entry.APITokenExpiresAt) if err != nil { return "" } if now.After(apiExpires) { return "expired " + humanDuration(now.Sub(apiExpires)) + " ago" } return "in " + humanDuration(apiExpires.Sub(now)) } ================================================ FILE: pkg/commands/auth/sso.go ================================================ package auth import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "time" "github.com/fastly/cli/pkg/api/undocumented" "github.com/fastly/cli/pkg/auth" "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/env" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/cli/pkg/useragent" ) // RunSSO executes the SSO authentication flow as a plain function. // It derives the token name from the current context via identifyTokenName, // which preserves naming behavior for re-auth flows (refresh/expired tokens). func RunSSO(in io.Reader, out io.Writer, g *global.Data, forceReAuth bool, skipPrompt bool) error { return RunSSOWithTokenName(in, out, g, forceReAuth, skipPrompt, identifyTokenName(g)) } // RunSSOWithTokenName is like RunSSO but accepts an explicit token name // instead of deriving it from the current context. func RunSSOWithTokenName(in io.Reader, out io.Writer, g *global.Data, forceReAuth bool, skipPrompt bool, tokenName string) error { if forceReAuth { g.AuthServer.SetParam("prompt", "login select_account") } else { if at := g.Config.GetAuthToken(tokenName); at != nil { g.AuthServer.SetParam("prompt", "login") if at.Email != "" { g.AuthServer.SetParam("login_hint", at.Email) } if at.AccountID != "" { g.AuthServer.SetParam("account_hint", at.AccountID) } } else { g.AuthServer.SetParam("prompt", "login select_account") } } if !skipPrompt && !g.Flags.AutoYes && !g.Flags.NonInteractive { msg := fmt.Sprintf("We're going to authenticate the '%s' token", tokenName) 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 := g.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 := g.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 = g.Opener(authorizationURL) if err != nil { return fmt.Errorf("failed to open your default browser: %w", err) } ar := <-g.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, } } customerID, customerName, err := processCustomer(g, ar) if err != nil { return fmt.Errorf("failed to use session token to get customer data: %w", err) } err = storeAuthToken(g, ar, tokenName, customerID, customerName) if err != nil { g.ErrLog.Add(err) return fmt.Errorf("failed to store auth token: %w", err) } msg := fmt.Sprintf("Session token '%s' has been stored.", tokenName) if !env.AuthCommandDisabled() { msg += " Use 'fastly auth list' to view tokens." } text.Success(out, msg) text.Info(out, "Token saved to %s", g.ConfigPath) return nil } // identifyTokenName determines which auth token name to use for SSO. func identifyTokenName(g *global.Data) string { if g.Flags.Token != "" { return g.Flags.Token } if g.Manifest != nil && g.Manifest.File.Profile != "" { return g.Manifest.File.Profile } if name, _ := g.Config.GetDefaultAuthToken(); name != "" { return name } return "default" } // CurrentCustomerResponse models the Fastly API response for the // /current_customer endpoint. type CurrentCustomerResponse struct { ID string `json:"id"` Name string `json:"name"` } func processCustomer(g *global.Data, ar auth.AuthorizationResult) (customerID, customerName string, err error) { debugMode, _ := strconv.ParseBool(g.Env.DebugMode) apiEndpoint, _ := g.APIEndpoint() data, err := undocumented.Call(undocumented.CallOptions{ APIEndpoint: apiEndpoint, HTTPClient: g.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 { g.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 { g.ErrLog.Add(err) return "", "", fmt.Errorf("error decoding current_customer API response: %w", err) } return response.ID, response.Name, nil } func storeAuthToken(g *global.Data, ar auth.AuthorizationResult, tokenName, customerID, customerName string) error { now := time.Now() label := customerName if ar.Email != "" { label = fmt.Sprintf("%s (%s)", customerName, ar.Email) } at := &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: ar.SessionToken, Label: label, AccountID: customerID, Email: ar.Email, AccessToken: ar.Jwt.AccessToken, RefreshToken: ar.Jwt.RefreshToken, AccessExpiresAt: now.Add(time.Duration(ar.Jwt.ExpiresIn) * time.Second).Format(time.RFC3339), RefreshExpiresAt: now.Add(time.Duration(ar.Jwt.RefreshExpiresIn) * time.Second).Format(time.RFC3339), } EnrichWithTokenSelf(g, at) g.Config.SetAuthToken(tokenName, at) if g.Config.Auth.Default == "" { g.Config.Auth.Default = tokenName } if err := g.Config.Write(g.ConfigPath); err != nil { return fmt.Errorf("failed to update config file: %w", err) } return nil } ================================================ FILE: pkg/commands/auth/sso_test.go ================================================ package auth_test import ( "context" "errors" "strings" "testing" "time" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/auth" authcmd "github.com/fastly/cli/pkg/commands/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: "auth login --sso --token testname", Stdin: []string{ "N", // when prompted to open a web browser to start authentication }, WantError: "will not continue", }, // 1. Error opening web browser { Args: "auth login --sso --token testname", 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: "auth login --sso --token testname", 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: "auth login --sso --token testname", 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. auth login --sso without --token fails with remediation error { Args: "auth login --sso", WantError: "SSO login requires a token name via --token", WantRemediation: "Provide a name for the stored token, e.g.: fastly auth login --sso --token work-sso", }, // 5. Success processing OAuth flow targeting specific auth token via --token flag { Args: "auth login --sso --token test_user", ConfigFile: &config.File{ Auth: config.Auth{ Default: "test_user", Tokens: config.AuthTokens{ "test_user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "old-token", Email: "test@example.com", }, }, }, }, 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' token", "We need to open your browser to authenticate you.", "has been stored. Use 'fastly auth list' to view tokens.", "Token saved to", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { const expectedToken = "123" at := opts.Config.GetAuthToken("test_user") if at == nil { t.Fatal("expected auth token 'test_user' to exist") } if at.Token != expectedToken { t.Errorf("want token: %s, got token: %s", expectedToken, at.Token) } }, }, // 6. Success processing `pops` command with a static (non-SSO) auth token. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", Email: "test@example.com", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { const expectedToken = "mock-token" at := opts.Config.GetAuthToken("user") if at == nil { t.Fatal("expected auth token 'user' to exist") } if at.Token != expectedToken { t.Errorf("want token: %s, got token: %s", expectedToken, at.Token) } }, }, // 7. SSO token with both access and refresh expired -> triggers re-auth prompt. // The user declines, so the command does not execute. { Args: "whoami", ConfigFile: &config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "mock-token", Email: "test@example.com", RefreshToken: "mock-refresh", AccessExpiresAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339), RefreshExpiresAt: time.Now().Add(-5 * time.Minute).Format(time.RFC3339), }, }, }, }, Stdin: []string{ "N", // decline re-authentication }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutput: "Your auth token has expired and needs re-authentication", DontWantOutput: "{Latitude:1 Longitude:2 X:3 Y:4}", }, // 8. SSO token with both access and refresh expired -> user accepts re-auth. // The SSO flow succeeds and the command executes afterward. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "mock-token", Email: "test@example.com", RefreshToken: "mock-refresh", AccessExpiresAt: time.Now().Add(-10 * time.Minute).Format(time.RFC3339), RefreshExpiresAt: time.Now().Add(-5 * time.Minute).Format(time.RFC3339), }, }, }, }, Stdin: []string{ "Y", // accept re-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: "new-123", } }() opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "Your auth token has expired and needs re-authentication", "Starting a local server to handle the authentication flow.", "has been stored. Use 'fastly auth list' to view tokens.", "Token saved to", "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { const expectedToken = "new-123" at := opts.Config.GetAuthToken("user") if at == nil { t.Fatal("expected auth token 'user' to exist") } if at.Token != expectedToken { t.Errorf("want token: %s, got token: %s", expectedToken, at.Token) } }, }, // 9. Migration before token resolution: legacy profiles are migrated to // [auth] before processToken() runs, so the token resolves correctly. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ "legacy": &config.Profile{ Default: true, Email: "legacy@example.com", Token: "legacy-token", }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("legacy") if at == nil { t.Fatal("expected migrated auth token 'legacy' to exist") } if at.Token != "legacy-token" { t.Errorf("want token: legacy-token, got token: %s", at.Token) } if opts.Config.Auth.Default != "legacy" { t.Errorf("want default auth: legacy, got: %s", opts.Config.Auth.Default) } }, }, // 10. Mixed config: both [auth] and [profile] present. // Profile-only entries must be merged into [auth], not dropped. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ "profile-only": &config.Profile{ Email: "profile@example.com", Token: "profile-token", }, }, Auth: config.Auth{ Default: "existing", Tokens: config.AuthTokens{ "existing": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "existing-token", Email: "existing@example.com", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { // The existing auth token must be preserved. at := opts.Config.GetAuthToken("existing") if at == nil { t.Fatal("expected 'existing' auth token to be preserved") } if at.Token != "existing-token" { t.Errorf("want token: existing-token, got: %s", at.Token) } // The profile-only entry must be merged in. merged := opts.Config.GetAuthToken("profile-only") if merged == nil { t.Fatal("expected 'profile-only' profile to be merged into auth tokens") } if merged.Token != "profile-token" { t.Errorf("want token: profile-token, got: %s", merged.Token) } // Default must remain unchanged. if opts.Config.Auth.Default != "existing" { t.Errorf("want default: existing, got: %s", opts.Config.Auth.Default) } // Profiles must be cleared. if len(opts.Config.Profiles) > 0 { t.Errorf("expected Profiles to be cleared, got %d entries", len(opts.Config.Profiles)) } }, }, // 11. auth login --sso --token newname creates a new token that doesn't exist yet. { Args: "auth login --sso --token brandnew", ConfigFile: &config.File{ Auth: config.Auth{ Default: "existing", Tokens: config.AuthTokens{ "existing": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "existing-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: "brand-new-token", } }() opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "We're going to authenticate the 'brandnew' token", "Session token 'brandnew' has been stored.", "Token saved to", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("brandnew") if at == nil { t.Fatal("expected auth token 'brandnew' to exist") } if at.Token != "brand-new-token" { t.Errorf("want token: brand-new-token, got token: %s", at.Token) } if opts.Config.Auth.Default != "brandnew" { t.Errorf("want default auth: brandnew, got: %s", opts.Config.Auth.Default) } }, }, // 12. Missing manifest profile emits a single warning. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Manifest.File.Profile = "nonexistent" opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ `profile "nonexistent" not found in auth config, using default token "user"`, "{Latitude:1 Longitude:2 X:3 Y:4}", }, }, // 13. Missing manifest profile warning is suppressed under --quiet. { Args: "pops --quiet", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Manifest.File.Profile = "nonexistent" opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, DontWantOutput: "not found in auth config", }, // 14. Missing manifest profile warning suppressed when --token overrides. { Args: "pops --token override-token", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Manifest.File.Profile = "nonexistent" opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, DontWantOutput: "not found in auth config", }, // 15. Auto-prompt: directly prompts for a static API token. { Name: "auto-prompt static token", Args: "whoami", API: &mock.API{ GetCurrentUserFn: func(_ context.Context) (*fastly.User, error) { return &fastly.User{ Login: fastly.ToPointer("alice@example.com"), CustomerID: fastly.ToPointer("abc123"), }, nil }, GetTokenSelfFn: testTokenSelfFull, }, ConfigFile: &config.File{}, Stdin: []string{ "my-static-token", }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.WhoamiVerifyClient(testutil.WhoamiBasicResponse) }, WantOutputs: []string{ "This command requires authentication to access your Fastly account.", "Paste your API token", `Authenticated as alice@example.com (token stored as "my-api-token")`, "Token saved to", }, DontWantOutput: "Log in with browser", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("my-api-token") if at == nil { t.Fatal("expected auth token 'my-api-token' to exist") } if at.Token != "my-static-token" { t.Errorf("want token: my-static-token, got token: %s", at.Token) } if at.Type != config.AuthTokenTypeStatic { t.Errorf("want type: static, got type: %s", at.Type) } if opts.Config.Auth.Default != "my-api-token" { t.Errorf("want Auth.Default my-api-token, got %s", opts.Config.Auth.Default) } }, }, // 16. Non-interactive: returns ErrNonInteractiveNoToken without prompting. { Name: "non-interactive no prompt", Args: "whoami -i", ConfigFile: &config.File{}, DontWantOutput: "requires authentication", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 17. Auto-yes: errors immediately from processToken (no prompt). { Name: "auto-prompt auto-yes", Args: "pops -y", ConfigFile: &config.File{}, DontWantOutput: "requires authentication", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 18. Accept-defaults: errors immediately from processToken (no prompt). { Name: "auto-prompt accept-defaults", Args: "pops -d", ConfigFile: &config.File{}, DontWantOutput: "requires authentication", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 19. FASTLY_USE_SSO=1 triggers SSO flow instead of token prompt. { Name: "auto-prompt use-sso env", Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{}, Stdin: []string{ "y", }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Env.UseSSO = "1" result := make(chan auth.AuthorizationResult) opts.AuthServer = testutil.MockAuthServer{ Result: result, } go func() { result <- auth.AuthorizationResult{ SessionToken: "sso-env-token", } }() opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "Do you want to continue", "has been stored", "Token saved to", "{Latitude:1 Longitude:2 X:3 Y:4}", }, DontWantOutput: "Paste your API token", Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("default") if at == nil { t.Fatal("expected auth token 'default' to exist") } if at.Token != "sso-env-token" { t.Errorf("want token: sso-env-token, got token: %s", at.Token) } }, }, // 21. FASTLY_USE_SSO=1 ignored under --non-interactive. { Name: "use-sso ignored non-interactive", Args: "whoami -i", ConfigFile: &config.File{}, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Env.UseSSO = "1" }, DontWantOutput: "requires authentication", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 22. FASTLY_USE_SSO=1 ignored under --auto-yes. { Name: "use-sso ignored auto-yes", Args: "pops -y", ConfigFile: &config.File{}, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Env.UseSSO = "1" }, DontWantOutput: "Do you want to continue", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 23. FASTLY_USE_SSO=1 ignored under --accept-defaults. { Name: "use-sso ignored accept-defaults", Args: "pops -d", ConfigFile: &config.File{}, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.Env.UseSSO = "1" }, DontWantOutput: "Do you want to continue", WantError: "no token provided", WantRemediation: "Interactive authentication is not available", }, // 24. SSO login with --token sets default to that name but does not overwrite an existing static token's value. { Name: "sso login switches default preserves static token", Args: "auth login --sso --token work-sso", ConfigFile: &config.File{ Auth: config.Auth{ Default: "mytoken", Tokens: config.AuthTokens{ "mytoken": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "static-secret", Email: "static@example.com", }, }, }, }, Stdin: []string{ "Y", }, 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: "sso-new-token", } }() opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "We're going to authenticate the 'work-sso' token", "Session token 'work-sso' has been stored.", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() // SSO token was created ssoAt := opts.Config.GetAuthToken("work-sso") if ssoAt == nil { t.Fatal("expected auth token 'work-sso' to exist") } if ssoAt.Token != "sso-new-token" { t.Errorf("want sso token: sso-new-token, got: %s", ssoAt.Token) } // Static token is untouched staticAt := opts.Config.GetAuthToken("mytoken") if staticAt == nil { t.Fatal("expected auth token 'mytoken' to still exist") } if staticAt.Token != "static-secret" { t.Errorf("want static token: static-secret, got: %s", staticAt.Token) } // Default switched to the SSO token (login always sets default) if opts.Config.Auth.Default != "work-sso" { t.Errorf("want default: work-sso, got: %s", opts.Config.Auth.Default) } }, }, // 25. SSO login stores API token metadata via EnrichWithTokenSelf. { Name: "sso stores api token metadata", Args: "auth login --sso --token sso-meta", API: &mock.API{ GetTokenSelfFn: func(_ context.Context) (*fastly.Token, error) { scope := fastly.GlobalScope return &fastly.Token{ TokenID: fastly.ToPointer("sso-tok-id"), Name: fastly.ToPointer("sso-api-token"), Scope: &scope, }, nil }, }, Stdin: []string{ "Y", }, 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: "sso-session-token", Email: "sso@example.com", } }() opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{"has been stored", "Token saved to"}, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { t.Helper() at := opts.Config.GetAuthToken("sso-meta") if at == nil { t.Fatal("expected auth token 'sso-meta' to exist") } if at.APITokenName != "sso-api-token" { t.Errorf("want APITokenName sso-api-token, got %s", at.APITokenName) } if at.APITokenScope != "global" { t.Errorf("want APITokenScope global, got %s", at.APITokenScope) } if at.APITokenID != "sso-tok-id" { t.Errorf("want APITokenID sso-tok-id, got %s", at.APITokenID) } }, }, } testutil.RunCLIScenarios(t, []string{}, scenarios) } // TestSSOSuccessMessageDisableAuthCommand verifies RunSSO omits the // "fastly auth list" hint when FASTLY_DISABLE_AUTH_COMMAND is set. func TestSSOSuccessMessageDisableAuthCommand(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") var stdout threadsafe.Buffer data := testutil.MockGlobalData([]string{"fastly"}, &stdout) result := make(chan auth.AuthorizationResult) data.AuthServer = testutil.MockAuthServer{Result: result} go func() { result <- auth.AuthorizationResult{SessionToken: "test-token"} }() data.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) data.Flags.AutoYes = true // skip interactive prompt err := authcmd.RunSSO(nil, &stdout, data, false, true) if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, "has been stored.") { t.Errorf("expected success message, got: %s", out) } if strings.Contains(out, "fastly auth list") { t.Errorf("expected no 'fastly auth list' hint when env var set, got: %s", out) } if !strings.Contains(out, "Token saved to") { t.Errorf("expected 'Token saved to' message, got: %s", out) } } ================================================ FILE: pkg/commands/auth/token.go ================================================ package auth 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/lookup" "github.com/fastly/cli/pkg/text" ) // TokenCommand prints the active API token to non-terminal stdout. type TokenCommand struct { argparser.Base } // NewTokenCommand returns a new command registered under the parent. func NewTokenCommand(parent argparser.Registerer, g *global.Data) *TokenCommand { var c TokenCommand c.Globals = g c.CmdClause = parent.Command("token", "Output the active API token (for use in shell substitutions)") return &c } // Exec implements the command interface. func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) error { if text.IsTTY(out) { return fsterr.RemediationError{ Inner: fmt.Errorf("refusing to print token to a terminal"), Remediation: "Use this command in a shell substitution or pipe, e.g. $(fastly auth token).", } } if err := c.Globals.ValidateProfileFlag(); err != nil { return err } token, src := c.Globals.Token() if src == lookup.SourceUndefined || token == "" { return fsterr.RemediationError{ Inner: fmt.Errorf("no API token configured"), Remediation: fsterr.ProfileRemediation(), } } fmt.Fprint(out, token) return nil } ================================================ FILE: pkg/commands/auth/token_test.go ================================================ package auth_test import ( "bytes" "errors" "strings" "testing" "github.com/fastly/kingpin" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/config" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" ) func newTokenCommand(g *global.Data) *authcmd.TokenCommand { app := kingpin.New("fastly", "test") parent := app.Command("auth", "test auth") return authcmd.NewTokenCommand(parent, g) } func globalDataWithToken(token string) *global.Data { return &global.Data{ Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: token, }, }, }, }, } } func TestToken_NonTTY_Success(t *testing.T) { var buf bytes.Buffer cmd := newTokenCommand(globalDataWithToken("test-api-token-value")) err := cmd.Exec(nil, &buf) if err != nil { t.Fatalf("expected no error, got: %v", err) } if got := buf.String(); got != "test-api-token-value" { t.Errorf("expected token %q, got %q", "test-api-token-value", got) } if got := buf.Bytes(); got[len(got)-1] == '\n' { t.Error("output should not have a trailing newline") } } func TestToken_NonTTY_NoToken(t *testing.T) { var buf bytes.Buffer g := &global.Data{ Config: config.File{}, } cmd := newTokenCommand(g) err := cmd.Exec(nil, &buf) if err == nil { t.Fatal("expected error for missing token") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T: %v", err, err) } if re.Inner == nil || re.Inner.Error() != "no API token configured" { t.Errorf("unexpected inner error: %v", re.Inner) } } func TestToken_NonTTY_ProfileFlagUnknown(t *testing.T) { var buf bytes.Buffer g := globalDataWithToken("test-api-token-value") g.Flags.Profile = "bogus" cmd := newTokenCommand(g) err := cmd.Exec(nil, &buf) if err == nil { t.Fatal("expected error for unknown --profile") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T: %v", err, err) } if re.Inner == nil || !strings.Contains(re.Inner.Error(), `"bogus"`) { t.Errorf("unexpected inner error: %v", re.Inner) } if buf.Len() != 0 { t.Errorf("expected no token to be written, got: %q", buf.String()) } } func TestToken_NonTTY_ProfileFlagKnown(t *testing.T) { var buf bytes.Buffer g := globalDataWithToken("default-token") g.Config.Auth.Tokens["alt"] = &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"} g.Flags.Profile = "alt" cmd := newTokenCommand(g) err := cmd.Exec(nil, &buf) if err != nil { t.Fatalf("expected no error, got: %v", err) } if got := buf.String(); got != "alt-token" { t.Errorf("expected token %q, got %q", "alt-token", got) } } func TestToken_NonTTY_TokenFlagBeatsProfileFlag(t *testing.T) { var buf bytes.Buffer g := globalDataWithToken("default-token") g.Flags.Token = "raw-xyz" g.Flags.Profile = "bogus" cmd := newTokenCommand(g) err := cmd.Exec(nil, &buf) if err != nil { t.Fatalf("expected no error, got: %v", err) } if got := buf.String(); got != "raw-xyz" { t.Errorf("expected token %q, got %q", "raw-xyz", got) } } ================================================ FILE: pkg/commands/auth/token_tty_unix_test.go ================================================ //go:build !windows package auth_test import ( "errors" "testing" "github.com/creack/pty" fsterr "github.com/fastly/cli/pkg/errors" ) func TestToken_TTY_Refused(t *testing.T) { // Create a PTY pair so we have a writable *os.File that // term.IsTerminal recognises as a terminal. This runs reliably // on Unix CI (no /dev/tty required) and, unlike os.Stdout, never // risks leaking a token to the developer's real terminal. ptm, pts, err := pty.Open() if err != nil { t.Fatalf("failed to open pty: %v", err) } defer ptm.Close() defer pts.Close() cmd := newTokenCommand(globalDataWithToken("secret-token")) err = cmd.Exec(nil, pts) if err == nil { t.Fatal("expected error when stdout is a terminal") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T: %v", err, err) } if re.Inner == nil || re.Inner.Error() != "refusing to print token to a terminal" { t.Errorf("unexpected inner error: %v", re.Inner) } } ================================================ FILE: pkg/commands/auth/use.go ================================================ package auth import ( "fmt" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UseCommand switches the default token. type UseCommand struct { argparser.Base name string } func NewUseCommand(parent argparser.Registerer, g *global.Data) *UseCommand { var c UseCommand c.Globals = g c.CmdClause = parent.Command("use", "Set the default stored token for CLI commands") // Required. c.CmdClause.Arg("name", "Name of the token to use as default").Required().StringVar(&c.name) return &c } func (c *UseCommand) Exec(_ io.Reader, out io.Writer) error { if err := c.Globals.Config.SetDefaultAuthToken(c.name); err != nil { return err } if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config: %w", err) } text.Success(out, "Default token switched to %q", c.name) return nil } ================================================ FILE: pkg/commands/authtoken/authtoken_test.go ================================================ package authtoken_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context) error { return testutil.Err }, }, Args: "--current --token 123", WantError: testutil.Err.Error(), }, { Name: "validate BatchDeleteTokens API error with --file", API: &mock.API{ BatchDeleteTokensFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context) error { return nil }, }, Args: "--current --token 123", WantOutput: "Deleted current token", }, { Name: "validate BatchDeleteTokens API success with --file", API: &mock.API{ BatchDeleteTokensFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context) (*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(_ context.Context, _ *fastly.ListTokensInput) ([]*fastly.Token, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: "validate ListCustomerTokens API error", API: &mock.API{ ListCustomerTokensFn: func(_ context.Context, _ *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(_ context.Context) (*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(ctx context.Context, _ *fastly.ListTokensInput) ([]*fastly.Token, error) { t := testutil.Date token, _ := getToken(ctx) 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(ctx context.Context, _ *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) { return listTokens(ctx, 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 (auth: 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) } ================================================ FILE: pkg/commands/authtoken/create.go ================================================ package authtoken import ( "context" "io" "strings" "time" "github.com/fastly/go-fastly/v15/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 (deprecated: use the Fastly API directly)").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 { if !c.Globals.Flags.Quiet { text.Deprecated("The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n") } input := c.constructInput() r, err := c.Globals.APIClient.CreateToken(context.TODO(), 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 } ================================================ FILE: pkg/commands/authtoken/delete.go ================================================ package authtoken import ( "bufio" "context" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/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 (deprecated: use the Fastly API directly)").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.Globals.Flags.Quiet { text.Deprecated("The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n") } 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(context.TODO()) 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(context.TODO(), 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(context.TODO(), 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() } ================================================ FILE: pkg/commands/authtoken/describe.go ================================================ package authtoken import ( "context" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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 current API token (deprecated: use the Fastly API directly)").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.Flags.Quiet && !c.JSONOutput.Enabled { text.Deprecated("The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n") } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } o, err := c.Globals.APIClient.GetTokenSelf(context.TODO()) 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 } ================================================ FILE: pkg/commands/authtoken/doc.go ================================================ // Package authtoken contains commands to manage API tokens for Fastly service // users. package authtoken ================================================ FILE: pkg/commands/authtoken/list.go ================================================ package authtoken import ( "context" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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 (deprecated: use the Fastly API directly)") 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.Flags.Quiet && !c.JSONOutput.Enabled { text.Deprecated("The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n") } 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 && !c.JSONOutput.Enabled { text.Info(out, "Listing customer tokens for the FASTLY_CUSTOMER_ID environment variable\n\n") } input := c.constructInput() o, err = c.Globals.APIClient.ListCustomerTokens(context.TODO(), input) if err != nil { c.Globals.ErrLog.Add(err) return err } } else { o, err = c.Globals.APIClient.ListTokens(context.TODO(), &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 } ================================================ FILE: pkg/commands/authtoken/root.go ================================================ 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 (deprecated: use 'fastly auth' subcommands instead)").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/authtoken/testdata/tokens ================================================ abc def xyz ================================================ FILE: pkg/commands/commands.go ================================================ package commands import ( "io" "github.com/fastly/kingpin" "github.com/fastly/cli/pkg/argparser" aliasacl "github.com/fastly/cli/pkg/commands/alias/acl" aliasaclentry "github.com/fastly/cli/pkg/commands/alias/aclentry" aliasalerts "github.com/fastly/cli/pkg/commands/alias/alerts" aliasbackend "github.com/fastly/cli/pkg/commands/alias/backend" aliasdictionary "github.com/fastly/cli/pkg/commands/alias/dictionary" aliasdictionaryentry "github.com/fastly/cli/pkg/commands/alias/dictionaryentry" aliashealthcheck "github.com/fastly/cli/pkg/commands/alias/healthcheck" aliasimageoptimizerdefaults "github.com/fastly/cli/pkg/commands/alias/imageoptimizerdefaults" aliaslogging "github.com/fastly/cli/pkg/commands/alias/logging" aliasazureblob "github.com/fastly/cli/pkg/commands/alias/logging/azureblob" aliasbigquery "github.com/fastly/cli/pkg/commands/alias/logging/bigquery" aliascloudfiles "github.com/fastly/cli/pkg/commands/alias/logging/cloudfiles" aliasdatadog "github.com/fastly/cli/pkg/commands/alias/logging/datadog" aliasdigitalocean "github.com/fastly/cli/pkg/commands/alias/logging/digitalocean" aliaselasticsearch "github.com/fastly/cli/pkg/commands/alias/logging/elasticsearch" aliasftp "github.com/fastly/cli/pkg/commands/alias/logging/ftp" aliasgcs "github.com/fastly/cli/pkg/commands/alias/logging/gcs" aliasgooglepubsub "github.com/fastly/cli/pkg/commands/alias/logging/googlepubsub" aliasgrafanacloudlogs "github.com/fastly/cli/pkg/commands/alias/logging/grafanacloudlogs" aliasheroku "github.com/fastly/cli/pkg/commands/alias/logging/heroku" aliashoneycomb "github.com/fastly/cli/pkg/commands/alias/logging/honeycomb" aliashttps "github.com/fastly/cli/pkg/commands/alias/logging/https" aliaskafka "github.com/fastly/cli/pkg/commands/alias/logging/kafka" aliaskinesis "github.com/fastly/cli/pkg/commands/alias/logging/kinesis" aliasloggly "github.com/fastly/cli/pkg/commands/alias/logging/loggly" aliaslogshuttle "github.com/fastly/cli/pkg/commands/alias/logging/logshuttle" aliasnewrelic "github.com/fastly/cli/pkg/commands/alias/logging/newrelic" aliasnewrelicotlp "github.com/fastly/cli/pkg/commands/alias/logging/newrelicotlp" aliasopenstack "github.com/fastly/cli/pkg/commands/alias/logging/openstack" aliaspapertrail "github.com/fastly/cli/pkg/commands/alias/logging/papertrail" aliass3 "github.com/fastly/cli/pkg/commands/alias/logging/s3" aliasscalyr "github.com/fastly/cli/pkg/commands/alias/logging/scalyr" aliassftp "github.com/fastly/cli/pkg/commands/alias/logging/sftp" aliassplunk "github.com/fastly/cli/pkg/commands/alias/logging/splunk" aliassumologic "github.com/fastly/cli/pkg/commands/alias/logging/sumologic" aliassyslog "github.com/fastly/cli/pkg/commands/alias/logging/syslog" aliaspurge "github.com/fastly/cli/pkg/commands/alias/purge" aliasratelimit "github.com/fastly/cli/pkg/commands/alias/ratelimit" aliasresourcelink "github.com/fastly/cli/pkg/commands/alias/resourcelink" aliasserviceauth "github.com/fastly/cli/pkg/commands/alias/serviceauth" aliasserviceversion "github.com/fastly/cli/pkg/commands/alias/serviceversion" aliasvcl "github.com/fastly/cli/pkg/commands/alias/vcl" aliasvclcondition "github.com/fastly/cli/pkg/commands/alias/vcl/condition" aliasvclcustom "github.com/fastly/cli/pkg/commands/alias/vcl/custom" aliasvclsnippet "github.com/fastly/cli/pkg/commands/alias/vcl/snippet" "github.com/fastly/cli/pkg/commands/apisecurity" "github.com/fastly/cli/pkg/commands/apisecurity/discoveredoperations" "github.com/fastly/cli/pkg/commands/apisecurity/operations" "github.com/fastly/cli/pkg/commands/apisecurity/tags" authcmd "github.com/fastly/cli/pkg/commands/auth" "github.com/fastly/cli/pkg/commands/authtoken" "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/domain" "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/logtail" "github.com/fastly/cli/pkg/commands/ngwaf" "github.com/fastly/cli/pkg/commands/ngwaf/countrylist" "github.com/fastly/cli/pkg/commands/ngwaf/customsignal" "github.com/fastly/cli/pkg/commands/ngwaf/iplist" "github.com/fastly/cli/pkg/commands/ngwaf/rule" "github.com/fastly/cli/pkg/commands/ngwaf/signallist" "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" "github.com/fastly/cli/pkg/commands/ngwaf/wildcardlist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" workspaceAlertDatadog "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/datadog" workspaceAlertJira "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/jira" workspaceAlertMailinglist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/mailinglist" workspaceAlertMicrosoftteams "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/microsoftteams" workspaceAlertOpsgenie "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/opsgenie" workspaceAlertPagerduty "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/pagerduty" workspaceAlertSlack "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/slack" workspaceAlertWebhook "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/webhook" wscountrylist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/countrylist" wscustomsignal "github.com/fastly/cli/pkg/commands/ngwaf/workspace/customsignal" wsiplist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/iplist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/redaction" workspaceRule "github.com/fastly/cli/pkg/commands/ngwaf/workspace/rule" wssignallistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/signallist" wsstringlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/stringlist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/virtualpatch" wswildcardlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/wildcardlist" "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/secretstore" "github.com/fastly/cli/pkg/commands/secretstoreentry" "github.com/fastly/cli/pkg/commands/service" serviceacl "github.com/fastly/cli/pkg/commands/service/acl" serviceaclentry "github.com/fastly/cli/pkg/commands/service/aclentry" servicealert "github.com/fastly/cli/pkg/commands/service/alert" serviceauth "github.com/fastly/cli/pkg/commands/service/auth" servicebackend "github.com/fastly/cli/pkg/commands/service/backend" servicedictionary "github.com/fastly/cli/pkg/commands/service/dictionary" servicedictionaryentry "github.com/fastly/cli/pkg/commands/service/dictionaryentry" servicedomain "github.com/fastly/cli/pkg/commands/service/domain" servicehealthcheck "github.com/fastly/cli/pkg/commands/service/healthcheck" serviceimageoptimizerdefaults "github.com/fastly/cli/pkg/commands/service/imageoptimizerdefaults" servicelogging "github.com/fastly/cli/pkg/commands/service/logging" serviceloggingazureblob "github.com/fastly/cli/pkg/commands/service/logging/azureblob" serviceloggingbigquery "github.com/fastly/cli/pkg/commands/service/logging/bigquery" serviceloggingcloudfiles "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" serviceloggingdatadog "github.com/fastly/cli/pkg/commands/service/logging/datadog" serviceloggingdebug "github.com/fastly/cli/pkg/commands/service/logging/debug" serviceloggingdigitalocean "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" serviceloggingelasticsearch "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" serviceloggingftp "github.com/fastly/cli/pkg/commands/service/logging/ftp" servicelogginggcs "github.com/fastly/cli/pkg/commands/service/logging/gcs" servicelogginggooglepubsub "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" servicelogginggrafanacloudlogs "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" serviceloggingheroku "github.com/fastly/cli/pkg/commands/service/logging/heroku" servicelogginghoneycomb "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" servicelogginghttps "github.com/fastly/cli/pkg/commands/service/logging/https" serviceloggingkafka "github.com/fastly/cli/pkg/commands/service/logging/kafka" serviceloggingkinesis "github.com/fastly/cli/pkg/commands/service/logging/kinesis" serviceloggingloggly "github.com/fastly/cli/pkg/commands/service/logging/loggly" servicelogginglogshuttle "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" serviceloggingnewrelic "github.com/fastly/cli/pkg/commands/service/logging/newrelic" serviceloggingnewrelicotlp "github.com/fastly/cli/pkg/commands/service/logging/newrelicotlp" serviceloggingopenstack "github.com/fastly/cli/pkg/commands/service/logging/openstack" serviceloggingpapertrail "github.com/fastly/cli/pkg/commands/service/logging/papertrail" serviceloggings3 "github.com/fastly/cli/pkg/commands/service/logging/s3" serviceloggingscalyr "github.com/fastly/cli/pkg/commands/service/logging/scalyr" serviceloggingsftp "github.com/fastly/cli/pkg/commands/service/logging/sftp" serviceloggingsplunk "github.com/fastly/cli/pkg/commands/service/logging/splunk" serviceloggingsumologic "github.com/fastly/cli/pkg/commands/service/logging/sumologic" serviceloggingsyslog "github.com/fastly/cli/pkg/commands/service/logging/syslog" servicepurge "github.com/fastly/cli/pkg/commands/service/purge" serviceratelimit "github.com/fastly/cli/pkg/commands/service/ratelimit" serviceresourcelink "github.com/fastly/cli/pkg/commands/service/resourcelink" servicevcl "github.com/fastly/cli/pkg/commands/service/vcl" servicevclcondition "github.com/fastly/cli/pkg/commands/service/vcl/condition" servicevclcustom "github.com/fastly/cli/pkg/commands/service/vcl/custom" servicevclsnippet "github.com/fastly/cli/pkg/commands/service/vcl/snippet" serviceversion "github.com/fastly/cli/pkg/commands/service/version" "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/tools" domainTools "github.com/fastly/cli/pkg/commands/tools/domain" "github.com/fastly/cli/pkg/commands/update" "github.com/fastly/cli/pkg/commands/user" "github.com/fastly/cli/pkg/commands/version" "github.com/fastly/cli/pkg/commands/whoami" "github.com/fastly/cli/pkg/env" "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) disableAuthCmd := env.AuthCommandDisabled() // All authentication-related commands (auth, auth-token, sso, profile, // whoami) are skipped when FASTLY_DISABLE_AUTH_COMMAND is set. var authCommands []argparser.Command var ssoCommands []argparser.Command var authtokenCommands []argparser.Command var profileCommands []argparser.Command var whoamiCommands []argparser.Command if !disableAuthCmd { ssoCmdRoot := sso.NewRootCommand(app, data) ssoCommands = []argparser.Command{ssoCmdRoot} authCmdRoot := authcmd.NewRootCommand(app, data) authLogin := authcmd.NewLoginCommand(authCmdRoot.CmdClause, data) authAdd := authcmd.NewAddCommand(authCmdRoot.CmdClause, data) authDelete := authcmd.NewDeleteCommand(authCmdRoot.CmdClause, data) authList := authcmd.NewListCommand(authCmdRoot.CmdClause, data) authShow := authcmd.NewShowCommand(authCmdRoot.CmdClause, data) authUse := authcmd.NewUseCommand(authCmdRoot.CmdClause, data) authRevoke := authcmd.NewRevokeCommand(authCmdRoot.CmdClause, data) authToken := authcmd.NewTokenCommand(authCmdRoot.CmdClause, data) authCommands = []argparser.Command{ authCmdRoot, authLogin, authAdd, authDelete, authList, authShow, authUse, authRevoke, authToken, } 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) authtokenCommands = []argparser.Command{ authtokenCmdRoot, authtokenCreate, authtokenDelete, authtokenDescribe, authtokenList, } } // API Security commands apisecurityRoot := apisecurity.NewRootCommand(app, data) discoveredoperationsRoot := discoveredoperations.NewRootCommand(apisecurityRoot.CmdClause, data) discoveredoperationsList := discoveredoperations.NewListCommand(discoveredoperationsRoot.CmdClause, data) discoveredoperationsUpdate := discoveredoperations.NewUpdateCommand(discoveredoperationsRoot.CmdClause, data) operationsRoot := operations.NewRootCommand(apisecurityRoot.CmdClause, data) operationsList := operations.NewListCommand(operationsRoot.CmdClause, data) operationsCreate := operations.NewCreateCommand(operationsRoot.CmdClause, data) operationsDescribe := operations.NewDescribeCommand(operationsRoot.CmdClause, data) operationsUpdate := operations.NewUpdateCommand(operationsRoot.CmdClause, data) operationsDelete := operations.NewDeleteCommand(operationsRoot.CmdClause, data) operationsAddTags := operations.NewAddTagsCommand(operationsRoot.CmdClause, data) tagsRoot := tags.NewRootCommand(apisecurityRoot.CmdClause, data) tagsCreate := tags.NewCreateCommand(tagsRoot.CmdClause, data) tagsDelete := tags.NewDeleteCommand(tagsRoot.CmdClause, data) tagsGet := tags.NewGetCommand(tagsRoot.CmdClause, data) tagsList := tags.NewListCommand(tagsRoot.CmdClause, data) tagsUpdate := tags.NewUpdateCommand(tagsRoot.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) 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) 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) 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) kvstoreentryGet := kvstoreentry.NewGetCommand(kvstoreentryCmdRoot.CmdClause, data) kvstoreentryDescribe := kvstoreentry.NewDescribeCommand(kvstoreentryCmdRoot.CmdClause, data) kvstoreentryList := kvstoreentry.NewListCommand(kvstoreentryCmdRoot.CmdClause, data) logtailCmdRoot := logtail.NewRootCommand(app, data) ngwafRoot := ngwaf.NewRootCommand(app, data) ngwafWorkspaceRoot := workspace.NewRootCommand(ngwafRoot.CmdClause, data) ngwafWorkspaceCreate := workspace.NewCreateCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceDelete := workspace.NewDeleteCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceGet := workspace.NewGetCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceList := workspace.NewListCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceUpdate := workspace.NewUpdateCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafRedactionRoot := redaction.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafRedactionCreate := redaction.NewCreateCommand(ngwafRedactionRoot.CmdClause, data) ngwafRedactionDelete := redaction.NewDeleteCommand(ngwafRedactionRoot.CmdClause, data) ngwafRedactionList := redaction.NewListCommand(ngwafRedactionRoot.CmdClause, data) ngwafRedactionRetrieve := redaction.NewRetrieveCommand(ngwafRedactionRoot.CmdClause, data) ngwafRedactionUpdate := redaction.NewUpdateCommand(ngwafRedactionRoot.CmdClause, data) ngwafCountryListRoot := countrylist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafCountryListCreate := countrylist.NewCreateCommand(ngwafCountryListRoot.CmdClause, data) ngwafCountryListDelete := countrylist.NewDeleteCommand(ngwafCountryListRoot.CmdClause, data) ngwafCountryListGet := countrylist.NewGetCommand(ngwafCountryListRoot.CmdClause, data) ngwafCountryListList := countrylist.NewListCommand(ngwafCountryListRoot.CmdClause, data) ngwafCountryListUpdate := countrylist.NewUpdateCommand(ngwafCountryListRoot.CmdClause, data) ngwafCustomSignalRoot := customsignal.NewRootCommand(ngwafRoot.CmdClause, data) ngwafCustomSignalCreate := customsignal.NewCreateCommand(ngwafCustomSignalRoot.CmdClause, data) ngwafCustomSignalDelete := customsignal.NewDeleteCommand(ngwafCustomSignalRoot.CmdClause, data) ngwafCustomSignalGet := customsignal.NewGetCommand(ngwafCustomSignalRoot.CmdClause, data) ngwafCustomSignalList := customsignal.NewListCommand(ngwafCustomSignalRoot.CmdClause, data) ngwafCustomSignalUpdate := customsignal.NewUpdateCommand(ngwafCustomSignalRoot.CmdClause, data) ngwafIPListRoot := iplist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafIPListCreate := iplist.NewCreateCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListDelete := iplist.NewDeleteCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListGet := iplist.NewGetCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListList := iplist.NewListCommand(ngwafIPListRoot.CmdClause, data) ngwafIPListUpdate := iplist.NewUpdateCommand(ngwafIPListRoot.CmdClause, data) ngwafRuleRoot := rule.NewRootCommand(ngwafRoot.CmdClause, data) ngwafRuleCreate := rule.NewCreateCommand(ngwafRuleRoot.CmdClause, data) ngwafRuleDelete := rule.NewDeleteCommand(ngwafRuleRoot.CmdClause, data) ngwafRuleGet := rule.NewGetCommand(ngwafRuleRoot.CmdClause, data) ngwafRuleList := rule.NewListCommand(ngwafRuleRoot.CmdClause, data) ngwafRuleUpdate := rule.NewUpdateCommand(ngwafRuleRoot.CmdClause, data) ngwafSignalListRoot := signallist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafSignalListCreate := signallist.NewCreateCommand(ngwafSignalListRoot.CmdClause, data) ngwafSignalListDelete := signallist.NewDeleteCommand(ngwafSignalListRoot.CmdClause, data) ngwafSignalListGet := signallist.NewGetCommand(ngwafSignalListRoot.CmdClause, data) ngwafSignalListList := signallist.NewListCommand(ngwafSignalListRoot.CmdClause, data) ngwafSignalListUpdate := signallist.NewUpdateCommand(ngwafSignalListRoot.CmdClause, data) ngwafStringListRoot := stringlist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafStringListCreate := stringlist.NewCreateCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListDelete := stringlist.NewDeleteCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListGet := stringlist.NewGetCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListList := stringlist.NewListCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListUpdate := stringlist.NewUpdateCommand(ngwafStringListRoot.CmdClause, data) ngwafWildcardListRoot := wildcardlist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafWildcardListCreate := wildcardlist.NewCreateCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWildcardListDelete := wildcardlist.NewDeleteCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWildcardListGet := wildcardlist.NewGetCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWildcardListList := wildcardlist.NewListCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWildcardListUpdate := wildcardlist.NewUpdateCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWorkspaceCountryListRoot := wscountrylist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceCountryListCreate := wscountrylist.NewCreateCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListDelete := wscountrylist.NewDeleteCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListGet := wscountrylist.NewGetCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListList := wscountrylist.NewListCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListUpdate := wscountrylist.NewUpdateCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCustomSignalRoot := wscustomsignal.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceCustomSignalCreate := wscustomsignal.NewCreateCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceCustomSignalDelete := wscustomsignal.NewDeleteCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceCustomSignalGet := wscustomsignal.NewGetCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceCustomSignalList := wscustomsignal.NewListCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceCustomSignalUpdate := wscustomsignal.NewUpdateCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceIPListRoot := wsiplist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceIPListCreate := wsiplist.NewCreateCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListDelete := wsiplist.NewDeleteCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListGet := wsiplist.NewGetCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListList := wsiplist.NewListCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceIPListUpdate := wsiplist.NewUpdateCommand(ngwafWorkspaceIPListRoot.CmdClause, data) ngwafWorkspaceRuleRoot := workspaceRule.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceRuleCreate := workspaceRule.NewCreateCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceRuleDelete := workspaceRule.NewDeleteCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceRuleGet := workspaceRule.NewGetCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceRuleList := workspaceRule.NewListCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceRuleUpdate := workspaceRule.NewUpdateCommand(ngwafWorkspaceRuleRoot.CmdClause, data) ngwafWorkspaceSignalListRoot := wssignallistlist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceSignalListCreate := wssignallistlist.NewCreateCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceSignalListDelete := wssignallistlist.NewDeleteCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceSignalListGet := wssignallistlist.NewGetCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceSignalListList := wssignallistlist.NewListCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceSignalListUpdate := wssignallistlist.NewUpdateCommand(ngwafWorkspaceSignalListRoot.CmdClause, data) ngwafWorkspaceStringListRoot := wsstringlistlist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceStringListCreate := wsstringlistlist.NewCreateCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListDelete := wsstringlistlist.NewDeleteCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListGet := wsstringlistlist.NewGetCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListList := wsstringlistlist.NewListCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListUpdate := wsstringlistlist.NewUpdateCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceThresholdRoot := threshold.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceThresholdCreate := threshold.NewCreateCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceThresholdDelete := threshold.NewDeleteCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceThresholdGet := threshold.NewGetCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceThresholdList := threshold.NewListCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceThresholdUpdate := threshold.NewUpdateCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceWildcardListRoot := wildcardlist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceWildcardListCreate := wswildcardlistlist.NewCreateCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafWorkspaceWildcardListDelete := wswildcardlistlist.NewDeleteCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafWorkspaceWildcardListGet := wswildcardlistlist.NewGetCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafWorkspaceWildcardListList := wswildcardlistlist.NewListCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafWorkspaceWildcardListUpdate := wswildcardlistlist.NewUpdateCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafVirtualpatchRoot := virtualpatch.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafVirtualpatchList := virtualpatch.NewListCommand(ngwafVirtualpatchRoot.CmdClause, data) ngwafVirtualpatchUpdate := virtualpatch.NewUpdateCommand(ngwafVirtualpatchRoot.CmdClause, data) ngwafVirtualpatchRetrieve := virtualpatch.NewRetrieveCommand(ngwafVirtualpatchRoot.CmdClause, data) ngwafWorkspaceAlertRoot := alert.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceAlertDatadogRoot := workspaceAlertDatadog.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertDatadogCreate := workspaceAlertDatadog.NewCreateCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) ngwafWorkspaceAlertDatadogDelete := workspaceAlertDatadog.NewDeleteCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) ngwafWorkspaceAlertDatadogGet := workspaceAlertDatadog.NewGetCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) ngwafWorkspaceAlertDatadogList := workspaceAlertDatadog.NewListCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) ngwafWorkspaceAlertDatadogUpdate := workspaceAlertDatadog.NewUpdateCommand(ngwafWorkspaceAlertDatadogRoot.CmdClause, data) ngwafWorkspaceAlertJiraRoot := workspaceAlertJira.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertJiraCreate := workspaceAlertJira.NewCreateCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) ngwafWorkspaceAlertJiraDelete := workspaceAlertJira.NewDeleteCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) ngwafWorkspaceAlertJiraGet := workspaceAlertJira.NewGetCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) ngwafWorkspaceAlertJiraList := workspaceAlertJira.NewListCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) ngwafWorkspaceAlertJiraUpdate := workspaceAlertJira.NewUpdateCommand(ngwafWorkspaceAlertJiraRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistRoot := workspaceAlertMailinglist.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistCreate := workspaceAlertMailinglist.NewCreateCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistDelete := workspaceAlertMailinglist.NewDeleteCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistGet := workspaceAlertMailinglist.NewGetCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistList := workspaceAlertMailinglist.NewListCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) ngwafWorkspaceAlertMailinglistUpdate := workspaceAlertMailinglist.NewUpdateCommand(ngwafWorkspaceAlertMailinglistRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsRoot := workspaceAlertMicrosoftteams.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsCreate := workspaceAlertMicrosoftteams.NewCreateCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsDelete := workspaceAlertMicrosoftteams.NewDeleteCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsGet := workspaceAlertMicrosoftteams.NewGetCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsList := workspaceAlertMicrosoftteams.NewListCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) ngwafWorkspaceAlertMicrosoftteamsUpdate := workspaceAlertMicrosoftteams.NewUpdateCommand(ngwafWorkspaceAlertMicrosoftteamsRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieRoot := workspaceAlertOpsgenie.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieCreate := workspaceAlertOpsgenie.NewCreateCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieDelete := workspaceAlertOpsgenie.NewDeleteCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieGet := workspaceAlertOpsgenie.NewGetCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieList := workspaceAlertOpsgenie.NewListCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) ngwafWorkspaceAlertOpsgenieUpdate := workspaceAlertOpsgenie.NewUpdateCommand(ngwafWorkspaceAlertOpsgenieRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyRoot := workspaceAlertPagerduty.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyCreate := workspaceAlertPagerduty.NewCreateCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyDelete := workspaceAlertPagerduty.NewDeleteCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyGet := workspaceAlertPagerduty.NewGetCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyList := workspaceAlertPagerduty.NewListCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) ngwafWorkspaceAlertPagerdutyUpdate := workspaceAlertPagerduty.NewUpdateCommand(ngwafWorkspaceAlertPagerdutyRoot.CmdClause, data) ngwafWorkspaceAlertSlackRoot := workspaceAlertSlack.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertSlackCreate := workspaceAlertSlack.NewCreateCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) ngwafWorkspaceAlertSlackDelete := workspaceAlertSlack.NewDeleteCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) ngwafWorkspaceAlertSlackGet := workspaceAlertSlack.NewGetCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) ngwafWorkspaceAlertSlackList := workspaceAlertSlack.NewListCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) ngwafWorkspaceAlertSlackUpdate := workspaceAlertSlack.NewUpdateCommand(ngwafWorkspaceAlertSlackRoot.CmdClause, data) ngwafWorkspaceAlertWebhookRoot := workspaceAlertWebhook.NewRootCommand(ngwafWorkspaceAlertRoot.CmdClause, data) ngwafWorkspaceAlertWebhookCreate := workspaceAlertWebhook.NewCreateCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookDelete := workspaceAlertWebhook.NewDeleteCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookGet := workspaceAlertWebhook.NewGetCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookGetSigningKey := workspaceAlertWebhook.NewGetSigningKeyCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookList := workspaceAlertWebhook.NewListCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookRotateSigningKey := workspaceAlertWebhook.NewRotateSigningKeyCommand(ngwafWorkspaceAlertWebhookRoot.CmdClause, data) ngwafWorkspaceAlertWebhookUpdate := workspaceAlertWebhook.NewUpdateCommand(ngwafWorkspaceAlertWebhookRoot.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) if !disableAuthCmd { profileCmdRoot := profile.NewRootCommand(app, data) profileCreate := profile.NewCreateCommand(profileCmdRoot.CmdClause, data) profileDelete := profile.NewDeleteCommand(profileCmdRoot.CmdClause, data) profileList := profile.NewListCommand(profileCmdRoot.CmdClause, data) profileSwitch := profile.NewSwitchCommand(profileCmdRoot.CmdClause, data) profileToken := profile.NewTokenCommand(profileCmdRoot.CmdClause, data) profileUpdate := profile.NewUpdateCommand(profileCmdRoot.CmdClause, data) profileCommands = []argparser.Command{ profileCmdRoot, profileCreate, profileDelete, profileList, profileSwitch, profileToken, profileUpdate, } } 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) servicePurge := servicepurge.NewPurgeCommand(serviceCmdRoot.CmdClause, data) servicealertCmdRoot := servicealert.NewRootCommand(serviceCmdRoot.CmdClause, data) servicealertCreate := servicealert.NewCreateCommand(servicealertCmdRoot.CmdClause, data) servicealertDelete := servicealert.NewDeleteCommand(servicealertCmdRoot.CmdClause, data) servicealertDescribe := servicealert.NewDescribeCommand(servicealertCmdRoot.CmdClause, data) servicealertList := servicealert.NewListCommand(servicealertCmdRoot.CmdClause, data) servicealertListHistory := servicealert.NewListHistoryCommand(servicealertCmdRoot.CmdClause, data) servicealertUpdate := servicealert.NewUpdateCommand(servicealertCmdRoot.CmdClause, data) serviceaclCmdRoot := serviceacl.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceaclCreate := serviceacl.NewCreateCommand(serviceaclCmdRoot.CmdClause, data) serviceaclDelete := serviceacl.NewDeleteCommand(serviceaclCmdRoot.CmdClause, data) serviceaclDescribe := serviceacl.NewDescribeCommand(serviceaclCmdRoot.CmdClause, data) serviceaclList := serviceacl.NewListCommand(serviceaclCmdRoot.CmdClause, data) serviceaclUpdate := serviceacl.NewUpdateCommand(serviceaclCmdRoot.CmdClause, data) serviceaclentryCmdRoot := serviceaclentry.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceaclentryCreate := serviceaclentry.NewCreateCommand(serviceaclentryCmdRoot.CmdClause, data) serviceaclentryDelete := serviceaclentry.NewDeleteCommand(serviceaclentryCmdRoot.CmdClause, data) serviceaclentryDescribe := serviceaclentry.NewDescribeCommand(serviceaclentryCmdRoot.CmdClause, data) serviceaclentryList := serviceaclentry.NewListCommand(serviceaclentryCmdRoot.CmdClause, data) serviceaclentryUpdate := serviceaclentry.NewUpdateCommand(serviceaclentryCmdRoot.CmdClause, data) serviceauthCmdRoot := serviceauth.NewRootCommand(serviceCmdRoot.CmdClause, 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) servicedictionaryCmdRoot := servicedictionary.NewRootCommand(serviceCmdRoot.CmdClause, data) servicedictionaryCreate := servicedictionary.NewCreateCommand(servicedictionaryCmdRoot.CmdClause, data) servicedictionaryDelete := servicedictionary.NewDeleteCommand(servicedictionaryCmdRoot.CmdClause, data) servicedictionaryDescribe := servicedictionary.NewDescribeCommand(servicedictionaryCmdRoot.CmdClause, data) servicedictionaryList := servicedictionary.NewListCommand(servicedictionaryCmdRoot.CmdClause, data) servicedictionaryUpdate := servicedictionary.NewUpdateCommand(servicedictionaryCmdRoot.CmdClause, data) servicevclCmdRoot := servicevcl.NewRootCommand(serviceCmdRoot.CmdClause, data) servicevclDescribe := servicevcl.NewDescribeCommand(servicevclCmdRoot.CmdClause, data) servicevclConditionCmdRoot := servicevclcondition.NewRootCommand(servicevclCmdRoot.CmdClause, data) servicevclConditionCreate := servicevclcondition.NewCreateCommand(servicevclConditionCmdRoot.CmdClause, data) servicevclConditionDelete := servicevclcondition.NewDeleteCommand(servicevclConditionCmdRoot.CmdClause, data) servicevclConditionDescribe := servicevclcondition.NewDescribeCommand(servicevclConditionCmdRoot.CmdClause, data) servicevclConditionList := servicevclcondition.NewListCommand(servicevclConditionCmdRoot.CmdClause, data) servicevclConditionUpdate := servicevclcondition.NewUpdateCommand(servicevclConditionCmdRoot.CmdClause, data) servicevclCustomCmdRoot := servicevclcustom.NewRootCommand(servicevclCmdRoot.CmdClause, data) servicevclCustomCreate := servicevclcustom.NewCreateCommand(servicevclCustomCmdRoot.CmdClause, data) servicevclCustomDelete := servicevclcustom.NewDeleteCommand(servicevclCustomCmdRoot.CmdClause, data) servicevclCustomDescribe := servicevclcustom.NewDescribeCommand(servicevclCustomCmdRoot.CmdClause, data) servicevclCustomList := servicevclcustom.NewListCommand(servicevclCustomCmdRoot.CmdClause, data) servicevclCustomUpdate := servicevclcustom.NewUpdateCommand(servicevclCustomCmdRoot.CmdClause, data) servicevclSnippetCmdRoot := servicevclsnippet.NewRootCommand(servicevclCmdRoot.CmdClause, data) servicevclSnippetCreate := servicevclsnippet.NewCreateCommand(servicevclSnippetCmdRoot.CmdClause, data) servicevclSnippetDelete := servicevclsnippet.NewDeleteCommand(servicevclSnippetCmdRoot.CmdClause, data) servicevclSnippetDescribe := servicevclsnippet.NewDescribeCommand(servicevclSnippetCmdRoot.CmdClause, data) servicevclSnippetList := servicevclsnippet.NewListCommand(servicevclSnippetCmdRoot.CmdClause, data) servicevclSnippetUpdate := servicevclsnippet.NewUpdateCommand(servicevclSnippetCmdRoot.CmdClause, data) serviceloggingCmdRoot := servicelogging.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceloggingDebugCmd := serviceloggingdebug.NewDebugCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingAzureblobCmdRoot := serviceloggingazureblob.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingAzureblobCreate := serviceloggingazureblob.NewCreateCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingAzureblobDelete := serviceloggingazureblob.NewDeleteCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingAzureblobDescribe := serviceloggingazureblob.NewDescribeCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingAzureblobList := serviceloggingazureblob.NewListCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingAzureblobUpdate := serviceloggingazureblob.NewUpdateCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingBigQueryCmdRoot := serviceloggingbigquery.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingBigQueryCreate := serviceloggingbigquery.NewCreateCommand(serviceloggingBigQueryCmdRoot.CmdClause, data) serviceloggingBigQueryDelete := serviceloggingbigquery.NewDeleteCommand(serviceloggingBigQueryCmdRoot.CmdClause, data) serviceloggingBigQueryDescribe := serviceloggingbigquery.NewDescribeCommand(serviceloggingBigQueryCmdRoot.CmdClause, data) serviceloggingBigQueryList := serviceloggingbigquery.NewListCommand(serviceloggingBigQueryCmdRoot.CmdClause, data) serviceloggingBigQueryUpdate := serviceloggingbigquery.NewUpdateCommand(serviceloggingBigQueryCmdRoot.CmdClause, data) serviceloggingCloudfilesCmdRoot := serviceloggingcloudfiles.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingCloudfilesCreate := serviceloggingcloudfiles.NewCreateCommand(serviceloggingCloudfilesCmdRoot.CmdClause, data) serviceloggingCloudfilesDelete := serviceloggingcloudfiles.NewDeleteCommand(serviceloggingCloudfilesCmdRoot.CmdClause, data) serviceloggingCloudfilesDescribe := serviceloggingcloudfiles.NewDescribeCommand(serviceloggingCloudfilesCmdRoot.CmdClause, data) serviceloggingCloudfilesList := serviceloggingcloudfiles.NewListCommand(serviceloggingCloudfilesCmdRoot.CmdClause, data) serviceloggingCloudfilesUpdate := serviceloggingcloudfiles.NewUpdateCommand(serviceloggingCloudfilesCmdRoot.CmdClause, data) serviceloggingDatadogCmdRoot := serviceloggingdatadog.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingDatadogCreate := serviceloggingdatadog.NewCreateCommand(serviceloggingDatadogCmdRoot.CmdClause, data) serviceloggingDatadogDelete := serviceloggingdatadog.NewDeleteCommand(serviceloggingDatadogCmdRoot.CmdClause, data) serviceloggingDatadogDescribe := serviceloggingdatadog.NewDescribeCommand(serviceloggingDatadogCmdRoot.CmdClause, data) serviceloggingDatadogList := serviceloggingdatadog.NewListCommand(serviceloggingDatadogCmdRoot.CmdClause, data) serviceloggingDatadogUpdate := serviceloggingdatadog.NewUpdateCommand(serviceloggingDatadogCmdRoot.CmdClause, data) serviceloggingDigitaloceanCmdRoot := serviceloggingdigitalocean.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingDigitaloceanCreate := serviceloggingdigitalocean.NewCreateCommand(serviceloggingDigitaloceanCmdRoot.CmdClause, data) serviceloggingDigitaloceanDelete := serviceloggingdigitalocean.NewDeleteCommand(serviceloggingDigitaloceanCmdRoot.CmdClause, data) serviceloggingDigitaloceanDescribe := serviceloggingdigitalocean.NewDescribeCommand(serviceloggingDigitaloceanCmdRoot.CmdClause, data) serviceloggingDigitaloceanList := serviceloggingdigitalocean.NewListCommand(serviceloggingDigitaloceanCmdRoot.CmdClause, data) serviceloggingDigitaloceanUpdate := serviceloggingdigitalocean.NewUpdateCommand(serviceloggingDigitaloceanCmdRoot.CmdClause, data) serviceloggingElasticsearchCmdRoot := serviceloggingelasticsearch.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingElasticsearchCreate := serviceloggingelasticsearch.NewCreateCommand(serviceloggingElasticsearchCmdRoot.CmdClause, data) serviceloggingElasticsearchDelete := serviceloggingelasticsearch.NewDeleteCommand(serviceloggingElasticsearchCmdRoot.CmdClause, data) serviceloggingElasticsearchDescribe := serviceloggingelasticsearch.NewDescribeCommand(serviceloggingElasticsearchCmdRoot.CmdClause, data) serviceloggingElasticsearchList := serviceloggingelasticsearch.NewListCommand(serviceloggingElasticsearchCmdRoot.CmdClause, data) serviceloggingElasticsearchUpdate := serviceloggingelasticsearch.NewUpdateCommand(serviceloggingElasticsearchCmdRoot.CmdClause, data) serviceloggingFtpCmdRoot := serviceloggingftp.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingFtpCreate := serviceloggingftp.NewCreateCommand(serviceloggingFtpCmdRoot.CmdClause, data) serviceloggingFtpDelete := serviceloggingftp.NewDeleteCommand(serviceloggingFtpCmdRoot.CmdClause, data) serviceloggingFtpDescribe := serviceloggingftp.NewDescribeCommand(serviceloggingFtpCmdRoot.CmdClause, data) serviceloggingFtpList := serviceloggingftp.NewListCommand(serviceloggingFtpCmdRoot.CmdClause, data) serviceloggingFtpUpdate := serviceloggingftp.NewUpdateCommand(serviceloggingFtpCmdRoot.CmdClause, data) serviceloggingGcsCmdRoot := servicelogginggcs.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingGcsCreate := servicelogginggcs.NewCreateCommand(serviceloggingGcsCmdRoot.CmdClause, data) serviceloggingGcsDelete := servicelogginggcs.NewDeleteCommand(serviceloggingGcsCmdRoot.CmdClause, data) serviceloggingGcsDescribe := servicelogginggcs.NewDescribeCommand(serviceloggingGcsCmdRoot.CmdClause, data) serviceloggingGcsList := servicelogginggcs.NewListCommand(serviceloggingGcsCmdRoot.CmdClause, data) serviceloggingGcsUpdate := servicelogginggcs.NewUpdateCommand(serviceloggingGcsCmdRoot.CmdClause, data) serviceloggingGooglepubsubCmdRoot := servicelogginggooglepubsub.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingGooglepubsubCreate := servicelogginggooglepubsub.NewCreateCommand(serviceloggingGooglepubsubCmdRoot.CmdClause, data) serviceloggingGooglepubsubDelete := servicelogginggooglepubsub.NewDeleteCommand(serviceloggingGooglepubsubCmdRoot.CmdClause, data) serviceloggingGooglepubsubDescribe := servicelogginggooglepubsub.NewDescribeCommand(serviceloggingGooglepubsubCmdRoot.CmdClause, data) serviceloggingGooglepubsubList := servicelogginggooglepubsub.NewListCommand(serviceloggingGooglepubsubCmdRoot.CmdClause, data) serviceloggingGooglepubsubUpdate := servicelogginggooglepubsub.NewUpdateCommand(serviceloggingGooglepubsubCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsCmdRoot := servicelogginggrafanacloudlogs.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsCreate := servicelogginggrafanacloudlogs.NewCreateCommand(serviceloggingGrafanacloudlogsCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsDelete := servicelogginggrafanacloudlogs.NewDeleteCommand(serviceloggingGrafanacloudlogsCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsDescribe := servicelogginggrafanacloudlogs.NewDescribeCommand(serviceloggingGrafanacloudlogsCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsList := servicelogginggrafanacloudlogs.NewListCommand(serviceloggingGrafanacloudlogsCmdRoot.CmdClause, data) serviceloggingGrafanacloudlogsUpdate := servicelogginggrafanacloudlogs.NewUpdateCommand(serviceloggingGrafanacloudlogsCmdRoot.CmdClause, data) serviceloggingHerokuCmdRoot := serviceloggingheroku.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingHerokuCreate := serviceloggingheroku.NewCreateCommand(serviceloggingHerokuCmdRoot.CmdClause, data) serviceloggingHerokuDelete := serviceloggingheroku.NewDeleteCommand(serviceloggingHerokuCmdRoot.CmdClause, data) serviceloggingHerokuDescribe := serviceloggingheroku.NewDescribeCommand(serviceloggingHerokuCmdRoot.CmdClause, data) serviceloggingHerokuList := serviceloggingheroku.NewListCommand(serviceloggingHerokuCmdRoot.CmdClause, data) serviceloggingHerokuUpdate := serviceloggingheroku.NewUpdateCommand(serviceloggingHerokuCmdRoot.CmdClause, data) serviceloggingHoneycombCmdRoot := servicelogginghoneycomb.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingHoneycombCreate := servicelogginghoneycomb.NewCreateCommand(serviceloggingHoneycombCmdRoot.CmdClause, data) serviceloggingHoneycombDelete := servicelogginghoneycomb.NewDeleteCommand(serviceloggingHoneycombCmdRoot.CmdClause, data) serviceloggingHoneycombDescribe := servicelogginghoneycomb.NewDescribeCommand(serviceloggingHoneycombCmdRoot.CmdClause, data) serviceloggingHoneycombList := servicelogginghoneycomb.NewListCommand(serviceloggingHoneycombCmdRoot.CmdClause, data) serviceloggingHoneycombUpdate := servicelogginghoneycomb.NewUpdateCommand(serviceloggingHoneycombCmdRoot.CmdClause, data) serviceloggingHTTPSCmdRoot := servicelogginghttps.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingHTTPSCreate := servicelogginghttps.NewCreateCommand(serviceloggingHTTPSCmdRoot.CmdClause, data) serviceloggingHTTPSDelete := servicelogginghttps.NewDeleteCommand(serviceloggingHTTPSCmdRoot.CmdClause, data) serviceloggingHTTPSDescribe := servicelogginghttps.NewDescribeCommand(serviceloggingHTTPSCmdRoot.CmdClause, data) serviceloggingHTTPSList := servicelogginghttps.NewListCommand(serviceloggingHTTPSCmdRoot.CmdClause, data) serviceloggingHTTPSUpdate := servicelogginghttps.NewUpdateCommand(serviceloggingHTTPSCmdRoot.CmdClause, data) serviceloggingKafkaCmdRoot := serviceloggingkafka.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingKafkaCreate := serviceloggingkafka.NewCreateCommand(serviceloggingKafkaCmdRoot.CmdClause, data) serviceloggingKafkaDelete := serviceloggingkafka.NewDeleteCommand(serviceloggingKafkaCmdRoot.CmdClause, data) serviceloggingKafkaDescribe := serviceloggingkafka.NewDescribeCommand(serviceloggingKafkaCmdRoot.CmdClause, data) serviceloggingKafkaList := serviceloggingkafka.NewListCommand(serviceloggingKafkaCmdRoot.CmdClause, data) serviceloggingKafkaUpdate := serviceloggingkafka.NewUpdateCommand(serviceloggingKafkaCmdRoot.CmdClause, data) serviceloggingKinesisCmdRoot := serviceloggingkinesis.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingKinesisCreate := serviceloggingkinesis.NewCreateCommand(serviceloggingKinesisCmdRoot.CmdClause, data) serviceloggingKinesisDelete := serviceloggingkinesis.NewDeleteCommand(serviceloggingKinesisCmdRoot.CmdClause, data) serviceloggingKinesisDescribe := serviceloggingkinesis.NewDescribeCommand(serviceloggingKinesisCmdRoot.CmdClause, data) serviceloggingKinesisList := serviceloggingkinesis.NewListCommand(serviceloggingKinesisCmdRoot.CmdClause, data) serviceloggingKinesisUpdate := serviceloggingkinesis.NewUpdateCommand(serviceloggingKinesisCmdRoot.CmdClause, data) serviceloggingLogglyCmdRoot := serviceloggingloggly.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingLogglyCreate := serviceloggingloggly.NewCreateCommand(serviceloggingLogglyCmdRoot.CmdClause, data) serviceloggingLogglyDelete := serviceloggingloggly.NewDeleteCommand(serviceloggingLogglyCmdRoot.CmdClause, data) serviceloggingLogglyDescribe := serviceloggingloggly.NewDescribeCommand(serviceloggingLogglyCmdRoot.CmdClause, data) serviceloggingLogglyList := serviceloggingloggly.NewListCommand(serviceloggingLogglyCmdRoot.CmdClause, data) serviceloggingLogglyUpdate := serviceloggingloggly.NewUpdateCommand(serviceloggingLogglyCmdRoot.CmdClause, data) serviceloggingLogshuttleCmdRoot := servicelogginglogshuttle.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingLogshuttleCreate := servicelogginglogshuttle.NewCreateCommand(serviceloggingLogshuttleCmdRoot.CmdClause, data) serviceloggingLogshuttleDelete := servicelogginglogshuttle.NewDeleteCommand(serviceloggingLogshuttleCmdRoot.CmdClause, data) serviceloggingLogshuttleDescribe := servicelogginglogshuttle.NewDescribeCommand(serviceloggingLogshuttleCmdRoot.CmdClause, data) serviceloggingLogshuttleList := servicelogginglogshuttle.NewListCommand(serviceloggingLogshuttleCmdRoot.CmdClause, data) serviceloggingLogshuttleUpdate := servicelogginglogshuttle.NewUpdateCommand(serviceloggingLogshuttleCmdRoot.CmdClause, data) serviceloggingNewRelicCmdRoot := serviceloggingnewrelic.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingNewRelicCreate := serviceloggingnewrelic.NewCreateCommand(serviceloggingNewRelicCmdRoot.CmdClause, data) serviceloggingNewRelicDelete := serviceloggingnewrelic.NewDeleteCommand(serviceloggingNewRelicCmdRoot.CmdClause, data) serviceloggingNewRelicDescribe := serviceloggingnewrelic.NewDescribeCommand(serviceloggingNewRelicCmdRoot.CmdClause, data) serviceloggingNewRelicList := serviceloggingnewrelic.NewListCommand(serviceloggingNewRelicCmdRoot.CmdClause, data) serviceloggingNewRelicUpdate := serviceloggingnewrelic.NewUpdateCommand(serviceloggingNewRelicCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPCmdRoot := serviceloggingnewrelicotlp.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPCreate := serviceloggingnewrelicotlp.NewCreateCommand(serviceloggingNewRelicOTLPCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPDelete := serviceloggingnewrelicotlp.NewDeleteCommand(serviceloggingNewRelicOTLPCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPDescribe := serviceloggingnewrelicotlp.NewDescribeCommand(serviceloggingNewRelicOTLPCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPList := serviceloggingnewrelicotlp.NewListCommand(serviceloggingNewRelicOTLPCmdRoot.CmdClause, data) serviceloggingNewRelicOTLPUpdate := serviceloggingnewrelicotlp.NewUpdateCommand(serviceloggingNewRelicOTLPCmdRoot.CmdClause, data) serviceloggingOpenstackCmdRoot := serviceloggingopenstack.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingOpenstackCreate := serviceloggingopenstack.NewCreateCommand(serviceloggingOpenstackCmdRoot.CmdClause, data) serviceloggingOpenstackDelete := serviceloggingopenstack.NewDeleteCommand(serviceloggingOpenstackCmdRoot.CmdClause, data) serviceloggingOpenstackDescribe := serviceloggingopenstack.NewDescribeCommand(serviceloggingOpenstackCmdRoot.CmdClause, data) serviceloggingOpenstackList := serviceloggingopenstack.NewListCommand(serviceloggingOpenstackCmdRoot.CmdClause, data) serviceloggingOpenstackUpdate := serviceloggingopenstack.NewUpdateCommand(serviceloggingOpenstackCmdRoot.CmdClause, data) serviceloggingPapertrailCmdRoot := serviceloggingpapertrail.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingPapertrailCreate := serviceloggingpapertrail.NewCreateCommand(serviceloggingPapertrailCmdRoot.CmdClause, data) serviceloggingPapertrailDelete := serviceloggingpapertrail.NewDeleteCommand(serviceloggingPapertrailCmdRoot.CmdClause, data) serviceloggingPapertrailDescribe := serviceloggingpapertrail.NewDescribeCommand(serviceloggingPapertrailCmdRoot.CmdClause, data) serviceloggingPapertrailList := serviceloggingpapertrail.NewListCommand(serviceloggingPapertrailCmdRoot.CmdClause, data) serviceloggingPapertrailUpdate := serviceloggingpapertrail.NewUpdateCommand(serviceloggingPapertrailCmdRoot.CmdClause, data) serviceloggingS3CmdRoot := serviceloggings3.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingS3Create := serviceloggings3.NewCreateCommand(serviceloggingS3CmdRoot.CmdClause, data) serviceloggingS3Delete := serviceloggings3.NewDeleteCommand(serviceloggingS3CmdRoot.CmdClause, data) serviceloggingS3Describe := serviceloggings3.NewDescribeCommand(serviceloggingS3CmdRoot.CmdClause, data) serviceloggingS3List := serviceloggings3.NewListCommand(serviceloggingS3CmdRoot.CmdClause, data) serviceloggingS3Update := serviceloggings3.NewUpdateCommand(serviceloggingS3CmdRoot.CmdClause, data) serviceloggingScalyrCmdRoot := serviceloggingscalyr.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingScalyrCreate := serviceloggingscalyr.NewCreateCommand(serviceloggingScalyrCmdRoot.CmdClause, data) serviceloggingScalyrDelete := serviceloggingscalyr.NewDeleteCommand(serviceloggingScalyrCmdRoot.CmdClause, data) serviceloggingScalyrDescribe := serviceloggingscalyr.NewDescribeCommand(serviceloggingScalyrCmdRoot.CmdClause, data) serviceloggingScalyrList := serviceloggingscalyr.NewListCommand(serviceloggingScalyrCmdRoot.CmdClause, data) serviceloggingScalyrUpdate := serviceloggingscalyr.NewUpdateCommand(serviceloggingScalyrCmdRoot.CmdClause, data) serviceloggingSftpCmdRoot := serviceloggingsftp.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingSftpCreate := serviceloggingsftp.NewCreateCommand(serviceloggingSftpCmdRoot.CmdClause, data) serviceloggingSftpDelete := serviceloggingsftp.NewDeleteCommand(serviceloggingSftpCmdRoot.CmdClause, data) serviceloggingSftpDescribe := serviceloggingsftp.NewDescribeCommand(serviceloggingSftpCmdRoot.CmdClause, data) serviceloggingSftpList := serviceloggingsftp.NewListCommand(serviceloggingSftpCmdRoot.CmdClause, data) serviceloggingSftpUpdate := serviceloggingsftp.NewUpdateCommand(serviceloggingSftpCmdRoot.CmdClause, data) serviceloggingSplunkCmdRoot := serviceloggingsplunk.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingSplunkCreate := serviceloggingsplunk.NewCreateCommand(serviceloggingSplunkCmdRoot.CmdClause, data) serviceloggingSplunkDelete := serviceloggingsplunk.NewDeleteCommand(serviceloggingSplunkCmdRoot.CmdClause, data) serviceloggingSplunkDescribe := serviceloggingsplunk.NewDescribeCommand(serviceloggingSplunkCmdRoot.CmdClause, data) serviceloggingSplunkList := serviceloggingsplunk.NewListCommand(serviceloggingSplunkCmdRoot.CmdClause, data) serviceloggingSplunkUpdate := serviceloggingsplunk.NewUpdateCommand(serviceloggingSplunkCmdRoot.CmdClause, data) serviceloggingSumologicCmdRoot := serviceloggingsumologic.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingSumologicCreate := serviceloggingsumologic.NewCreateCommand(serviceloggingSumologicCmdRoot.CmdClause, data) serviceloggingSumologicDelete := serviceloggingsumologic.NewDeleteCommand(serviceloggingSumologicCmdRoot.CmdClause, data) serviceloggingSumologicDescribe := serviceloggingsumologic.NewDescribeCommand(serviceloggingSumologicCmdRoot.CmdClause, data) serviceloggingSumologicList := serviceloggingsumologic.NewListCommand(serviceloggingSumologicCmdRoot.CmdClause, data) serviceloggingSumologicUpdate := serviceloggingsumologic.NewUpdateCommand(serviceloggingSumologicCmdRoot.CmdClause, data) serviceloggingSyslogCmdRoot := serviceloggingsyslog.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingSyslogCreate := serviceloggingsyslog.NewCreateCommand(serviceloggingSyslogCmdRoot.CmdClause, data) serviceloggingSyslogDelete := serviceloggingsyslog.NewDeleteCommand(serviceloggingSyslogCmdRoot.CmdClause, data) serviceloggingSyslogDescribe := serviceloggingsyslog.NewDescribeCommand(serviceloggingSyslogCmdRoot.CmdClause, data) serviceloggingSyslogList := serviceloggingsyslog.NewListCommand(serviceloggingSyslogCmdRoot.CmdClause, data) serviceloggingSyslogUpdate := serviceloggingsyslog.NewUpdateCommand(serviceloggingSyslogCmdRoot.CmdClause, data) serviceVersionCmdRoot := serviceversion.NewRootCommand(serviceCmdRoot.CmdClause, 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) serviceVersionValidate := serviceversion.NewValidateCommand(serviceVersionCmdRoot.CmdClause, data) servicedomainCmdRoot := servicedomain.NewRootCommand(serviceCmdRoot.CmdClause, data) servicedomainCreate := servicedomain.NewCreateCommand(servicedomainCmdRoot.CmdClause, data) servicedomainDelete := servicedomain.NewDeleteCommand(servicedomainCmdRoot.CmdClause, data) servicedomainDescribe := servicedomain.NewDescribeCommand(servicedomainCmdRoot.CmdClause, data) servicedomainList := servicedomain.NewListCommand(servicedomainCmdRoot.CmdClause, data) servicedomainUpdate := servicedomain.NewUpdateCommand(servicedomainCmdRoot.CmdClause, data) servicedomainValidate := servicedomain.NewValidateCommand(servicedomainCmdRoot.CmdClause, data) servicedictionaryentryCmdRoot := servicedictionaryentry.NewRootCommand(serviceCmdRoot.CmdClause, data) servicedictionaryentryCreate := servicedictionaryentry.NewCreateCommand(servicedictionaryentryCmdRoot.CmdClause, data) servicedictionaryentryDelete := servicedictionaryentry.NewDeleteCommand(servicedictionaryentryCmdRoot.CmdClause, data) servicedictionaryentryDescribe := servicedictionaryentry.NewDescribeCommand(servicedictionaryentryCmdRoot.CmdClause, data) servicedictionaryentryList := servicedictionaryentry.NewListCommand(servicedictionaryentryCmdRoot.CmdClause, data) servicedictionaryentryUpdate := servicedictionaryentry.NewUpdateCommand(servicedictionaryentryCmdRoot.CmdClause, data) servicebackendCmdRoot := servicebackend.NewRootCommand(serviceCmdRoot.CmdClause, data) servicebackendCreate := servicebackend.NewCreateCommand(servicebackendCmdRoot.CmdClause, data) servicebackendDelete := servicebackend.NewDeleteCommand(servicebackendCmdRoot.CmdClause, data) servicebackendDescribe := servicebackend.NewDescribeCommand(servicebackendCmdRoot.CmdClause, data) servicebackendList := servicebackend.NewListCommand(servicebackendCmdRoot.CmdClause, data) servicebackendUpdate := servicebackend.NewUpdateCommand(servicebackendCmdRoot.CmdClause, data) servicehealthcheckCmdRoot := servicehealthcheck.NewRootCommand(serviceCmdRoot.CmdClause, data) servicehealthcheckCreate := servicehealthcheck.NewCreateCommand(servicehealthcheckCmdRoot.CmdClause, data) servicehealthcheckDelete := servicehealthcheck.NewDeleteCommand(servicehealthcheckCmdRoot.CmdClause, data) servicehealthcheckDescribe := servicehealthcheck.NewDescribeCommand(servicehealthcheckCmdRoot.CmdClause, data) servicehealthcheckList := servicehealthcheck.NewListCommand(servicehealthcheckCmdRoot.CmdClause, data) servicehealthcheckUpdate := servicehealthcheck.NewUpdateCommand(servicehealthcheckCmdRoot.CmdClause, data) serviceimageoptimizerdefaultsCmdRoot := serviceimageoptimizerdefaults.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceimageoptimizerdefaultsGet := serviceimageoptimizerdefaults.NewGetCommand(serviceimageoptimizerdefaultsCmdRoot.CmdClause, data) serviceimageoptimizerdefaultsUpdate := serviceimageoptimizerdefaults.NewUpdateCommand(serviceimageoptimizerdefaultsCmdRoot.CmdClause, data) serviceratelimitCmdRoot := serviceratelimit.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceratelimitCreate := serviceratelimit.NewCreateCommand(serviceratelimitCmdRoot.CmdClause, data) serviceratelimitDelete := serviceratelimit.NewDeleteCommand(serviceratelimitCmdRoot.CmdClause, data) serviceratelimitDescribe := serviceratelimit.NewDescribeCommand(serviceratelimitCmdRoot.CmdClause, data) serviceratelimitList := serviceratelimit.NewListCommand(serviceratelimitCmdRoot.CmdClause, data) serviceratelimitUpdate := serviceratelimit.NewUpdateCommand(serviceratelimitCmdRoot.CmdClause, data) serviceresourcelinkCmdRoot := serviceresourcelink.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceresourcelinkCreate := serviceresourcelink.NewCreateCommand(serviceresourcelinkCmdRoot.CmdClause, data) serviceresourcelinkDelete := serviceresourcelink.NewDeleteCommand(serviceresourcelinkCmdRoot.CmdClause, data) serviceresourcelinkDescribe := serviceresourcelink.NewDescribeCommand(serviceresourcelinkCmdRoot.CmdClause, data) serviceresourcelinkList := serviceresourcelink.NewListCommand(serviceresourcelinkCmdRoot.CmdClause, data) serviceresourcelinkUpdate := serviceresourcelink.NewUpdateCommand(serviceresourcelinkCmdRoot.CmdClause, data) statsCmdRoot := stats.NewRootCommand(app, data) statsAggregate := stats.NewAggregateCommand(statsCmdRoot.CmdClause, data) statsDomainInspector := stats.NewDomainInspectorCommand(statsCmdRoot.CmdClause, data) statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) statsOriginInspector := stats.NewOriginInspectorCommand(statsCmdRoot.CmdClause, data) statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) statsUsage := stats.NewUsageCommand(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) toolsCmdRoot := tools.NewRootCommand(app, data) toolsDomainCmdRoot := domainTools.NewRootCommand(toolsCmdRoot.CmdClause, data) toolsDomainStatus := domainTools.NewDomainStatusCommand(toolsDomainCmdRoot.CmdClause, data) toolsDomainSuggestions := domainTools.NewDomainSuggestionsCommand(toolsDomainCmdRoot.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) versionCmdRoot := version.NewRootCommand(app, data) if !disableAuthCmd { whoamiCommands = []argparser.Command{whoami.NewRootCommand(app, data)} } // Aliases for deprecated commands aliasBackendRoot := aliasbackend.NewRootCommand(app, data) aliasBackendCreate := aliasbackend.NewCreateCommand(aliasBackendRoot.CmdClause, data) aliasBackendDelete := aliasbackend.NewDeleteCommand(aliasBackendRoot.CmdClause, data) aliasBackendDescribe := aliasbackend.NewDescribeCommand(aliasBackendRoot.CmdClause, data) aliasBackendList := aliasbackend.NewListCommand(aliasBackendRoot.CmdClause, data) aliasBackendUpdate := aliasbackend.NewUpdateCommand(aliasBackendRoot.CmdClause, data) aliasDictionaryEntryRoot := aliasdictionaryentry.NewRootCommand(app, data) aliasDictionaryEntryCreate := aliasdictionaryentry.NewCreateCommand(aliasDictionaryEntryRoot.CmdClause, data) aliasDictionaryEntryDelete := aliasdictionaryentry.NewDeleteCommand(aliasDictionaryEntryRoot.CmdClause, data) aliasDictionaryEntryDescribe := aliasdictionaryentry.NewDescribeCommand(aliasDictionaryEntryRoot.CmdClause, data) aliasDictionaryEntryList := aliasdictionaryentry.NewListCommand(aliasDictionaryEntryRoot.CmdClause, data) aliasDictionaryEntryUpdate := aliasdictionaryentry.NewUpdateCommand(aliasDictionaryEntryRoot.CmdClause, data) aliasDictionaryRoot := aliasdictionary.NewRootCommand(app, data) aliasDictionaryCreate := aliasdictionary.NewCreateCommand(aliasDictionaryRoot.CmdClause, data) aliasDictionaryDelete := aliasdictionary.NewDeleteCommand(aliasDictionaryRoot.CmdClause, data) aliasDictionaryDescribe := aliasdictionary.NewDescribeCommand(aliasDictionaryRoot.CmdClause, data) aliasDictionaryList := aliasdictionary.NewListCommand(aliasDictionaryRoot.CmdClause, data) aliasDictionaryUpdate := aliasdictionary.NewUpdateCommand(aliasDictionaryRoot.CmdClause, data) aliasHealthcheckRoot := aliashealthcheck.NewRootCommand(app, data) aliasHealthcheckCreate := aliashealthcheck.NewCreateCommand(aliasHealthcheckRoot.CmdClause, data) aliasHealthcheckDelete := aliashealthcheck.NewDeleteCommand(aliasHealthcheckRoot.CmdClause, data) aliasHealthcheckDescribe := aliashealthcheck.NewDescribeCommand(aliasHealthcheckRoot.CmdClause, data) aliasHealthcheckList := aliashealthcheck.NewListCommand(aliasHealthcheckRoot.CmdClause, data) aliasHealthcheckUpdate := aliashealthcheck.NewUpdateCommand(aliasHealthcheckRoot.CmdClause, data) aliasimageoptimizerdefaultsRoot := aliasimageoptimizerdefaults.NewRootCommand(app, data) aliasimageoptimizerdefaultsGet := aliasimageoptimizerdefaults.NewGetCommand(aliasimageoptimizerdefaultsRoot.CmdClause, data) aliasimageoptimizerdefaultsUpdate := aliasimageoptimizerdefaults.NewUpdateCommand(aliasimageoptimizerdefaultsRoot.CmdClause, data) aliasPurge := aliaspurge.NewCommand(app, data) aliasAlertRoot := aliasalerts.NewRootCommand(app, data) aliasAlertCreate := aliasalerts.NewCreateCommand(aliasAlertRoot.CmdClause, data) aliasAlertDelete := aliasalerts.NewDeleteCommand(aliasAlertRoot.CmdClause, data) aliasAlertDescribe := aliasalerts.NewDescribeCommand(aliasAlertRoot.CmdClause, data) aliasAlertList := aliasalerts.NewListCommand(aliasAlertRoot.CmdClause, data) aliasAlertListHistory := aliasalerts.NewListHistoryCommand(aliasAlertRoot.CmdClause, data) aliasAlertUpdate := aliasalerts.NewUpdateCommand(aliasAlertRoot.CmdClause, data) aliasACLRoot := aliasacl.NewRootCommand(app, data) aliasACLCreate := aliasacl.NewCreateCommand(aliasACLRoot.CmdClause, data) aliasACLDelete := aliasacl.NewDeleteCommand(aliasACLRoot.CmdClause, data) aliasACLDescribe := aliasacl.NewDescribeCommand(aliasACLRoot.CmdClause, data) aliasACLList := aliasacl.NewListCommand(aliasACLRoot.CmdClause, data) aliasACLUpdate := aliasacl.NewUpdateCommand(aliasACLRoot.CmdClause, data) aliasACLEntryRoot := aliasaclentry.NewRootCommand(app, data) aliasACLEntryCreate := aliasaclentry.NewCreateCommand(aliasACLEntryRoot.CmdClause, data) aliasACLEntryDelete := aliasaclentry.NewDeleteCommand(aliasACLEntryRoot.CmdClause, data) aliasACLEntryDescribe := aliasaclentry.NewDescribeCommand(aliasACLEntryRoot.CmdClause, data) aliasACLEntryList := aliasaclentry.NewListCommand(aliasACLEntryRoot.CmdClause, data) aliasACLEntryUpdate := aliasaclentry.NewUpdateCommand(aliasACLEntryRoot.CmdClause, data) aliasRateLimitRoot := aliasratelimit.NewRootCommand(app, data) aliasRateLimitCreate := aliasratelimit.NewCreateCommand(aliasRateLimitRoot.CmdClause, data) aliasRateLimitDelete := aliasratelimit.NewDeleteCommand(aliasRateLimitRoot.CmdClause, data) aliasRateLimitDescribe := aliasratelimit.NewDescribeCommand(aliasRateLimitRoot.CmdClause, data) aliasRateLimitList := aliasratelimit.NewListCommand(aliasRateLimitRoot.CmdClause, data) aliasRateLimitUpdate := aliasratelimit.NewUpdateCommand(aliasRateLimitRoot.CmdClause, data) aliasResourceLinkRoot := aliasresourcelink.NewRootCommand(app, data) aliasResourceLinkCreate := aliasresourcelink.NewCreateCommand(aliasResourceLinkRoot.CmdClause, data) aliasResourceLinkDelete := aliasresourcelink.NewDeleteCommand(aliasResourceLinkRoot.CmdClause, data) aliasResourceLinkDescribe := aliasresourcelink.NewDescribeCommand(aliasResourceLinkRoot.CmdClause, data) aliasResourceLinkList := aliasresourcelink.NewListCommand(aliasResourceLinkRoot.CmdClause, data) aliasResourceLinkUpdate := aliasresourcelink.NewUpdateCommand(aliasResourceLinkRoot.CmdClause, data) aliasServiceAuthRoot := aliasserviceauth.NewRootCommand(app, data) aliasServiceAuthCreate := aliasserviceauth.NewCreateCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthDelete := aliasserviceauth.NewDeleteCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthDescribe := aliasserviceauth.NewDescribeCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthList := aliasserviceauth.NewListCommand(aliasServiceAuthRoot.CmdClause, data) aliasServiceAuthUpdate := aliasserviceauth.NewUpdateCommand(aliasServiceAuthRoot.CmdClause, data) aliasVclRoot := aliasvcl.NewRootCommand(app, data) aliasVclDescribe := aliasvcl.NewDescribeCommand(aliasVclRoot.CmdClause, data) aliasVclConditionRoot := aliasvclcondition.NewRootCommand(aliasVclRoot.CmdClause, data) aliasVclConditionCreate := aliasvclcondition.NewCreateCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionDelete := aliasvclcondition.NewDeleteCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionDescribe := aliasvclcondition.NewDescribeCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionList := aliasvclcondition.NewListCommand(aliasVclConditionRoot.CmdClause, data) aliasVclConditionUpdate := aliasvclcondition.NewUpdateCommand(aliasVclConditionRoot.CmdClause, data) aliasVclCustomRoot := aliasvclcustom.NewRootCommand(aliasVclRoot.CmdClause, data) aliasVclCustomCreate := aliasvclcustom.NewCreateCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomDelete := aliasvclcustom.NewDeleteCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomDescribe := aliasvclcustom.NewDescribeCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomList := aliasvclcustom.NewListCommand(aliasVclCustomRoot.CmdClause, data) aliasVclCustomUpdate := aliasvclcustom.NewUpdateCommand(aliasVclCustomRoot.CmdClause, data) aliasVclSnippetRoot := aliasvclsnippet.NewRootCommand(aliasVclRoot.CmdClause, data) aliasVclSnippetCreate := aliasvclsnippet.NewCreateCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetDelete := aliasvclsnippet.NewDeleteCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetDescribe := aliasvclsnippet.NewDescribeCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetList := aliasvclsnippet.NewListCommand(aliasVclSnippetRoot.CmdClause, data) aliasVclSnippetUpdate := aliasvclsnippet.NewUpdateCommand(aliasVclSnippetRoot.CmdClause, data) aliasServiceVersionRoot := aliasserviceversion.NewRootCommand(app, data) aliasServiceVersionActivate := aliasserviceversion.NewActivateCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionClone := aliasserviceversion.NewCloneCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionDeactivate := aliasserviceversion.NewDeactivateCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionList := aliasserviceversion.NewListCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionLock := aliasserviceversion.NewLockCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionStage := aliasserviceversion.NewStageCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionUnstage := aliasserviceversion.NewUnstageCommand(aliasServiceVersionRoot.CmdClause, data) aliasServiceVersionUpdate := aliasserviceversion.NewUpdateCommand(aliasServiceVersionRoot.CmdClause, data) aliasLoggingRoot := aliaslogging.NewRootCommand(app, data) aliasAzureblobRoot := aliasazureblob.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasAzureblobCreate := aliasazureblob.NewCreateCommand(aliasAzureblobRoot.CmdClause, data) aliasAzureblobDelete := aliasazureblob.NewDeleteCommand(aliasAzureblobRoot.CmdClause, data) aliasAzureblobDescribe := aliasazureblob.NewDescribeCommand(aliasAzureblobRoot.CmdClause, data) aliasAzureblobList := aliasazureblob.NewListCommand(aliasAzureblobRoot.CmdClause, data) aliasAzureblobUpdate := aliasazureblob.NewUpdateCommand(aliasAzureblobRoot.CmdClause, data) aliasBigqueryRoot := aliasbigquery.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasBigqueryCreate := aliasbigquery.NewCreateCommand(aliasBigqueryRoot.CmdClause, data) aliasBigqueryDelete := aliasbigquery.NewDeleteCommand(aliasBigqueryRoot.CmdClause, data) aliasBigqueryDescribe := aliasbigquery.NewDescribeCommand(aliasBigqueryRoot.CmdClause, data) aliasBigqueryList := aliasbigquery.NewListCommand(aliasBigqueryRoot.CmdClause, data) aliasBigqueryUpdate := aliasbigquery.NewUpdateCommand(aliasBigqueryRoot.CmdClause, data) aliasCloudfilesRoot := aliascloudfiles.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasCloudfilesCreate := aliascloudfiles.NewCreateCommand(aliasCloudfilesRoot.CmdClause, data) aliasCloudfilesDelete := aliascloudfiles.NewDeleteCommand(aliasCloudfilesRoot.CmdClause, data) aliasCloudfilesDescribe := aliascloudfiles.NewDescribeCommand(aliasCloudfilesRoot.CmdClause, data) aliasCloudfilesList := aliascloudfiles.NewListCommand(aliasCloudfilesRoot.CmdClause, data) aliasCloudfilesUpdate := aliascloudfiles.NewUpdateCommand(aliasCloudfilesRoot.CmdClause, data) aliasDatadogRoot := aliasdatadog.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasDatadogCreate := aliasdatadog.NewCreateCommand(aliasDatadogRoot.CmdClause, data) aliasDatadogDelete := aliasdatadog.NewDeleteCommand(aliasDatadogRoot.CmdClause, data) aliasDatadogDescribe := aliasdatadog.NewDescribeCommand(aliasDatadogRoot.CmdClause, data) aliasDatadogList := aliasdatadog.NewListCommand(aliasDatadogRoot.CmdClause, data) aliasDatadogUpdate := aliasdatadog.NewUpdateCommand(aliasDatadogRoot.CmdClause, data) aliasDigitaloceanRoot := aliasdigitalocean.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasDigitaloceanCreate := aliasdigitalocean.NewCreateCommand(aliasDigitaloceanRoot.CmdClause, data) aliasDigitaloceanDelete := aliasdigitalocean.NewDeleteCommand(aliasDigitaloceanRoot.CmdClause, data) aliasDigitaloceanDescribe := aliasdigitalocean.NewDescribeCommand(aliasDigitaloceanRoot.CmdClause, data) aliasDigitaloceanList := aliasdigitalocean.NewListCommand(aliasDigitaloceanRoot.CmdClause, data) aliasDigitaloceanUpdate := aliasdigitalocean.NewUpdateCommand(aliasDigitaloceanRoot.CmdClause, data) aliasElasticsearchRoot := aliaselasticsearch.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasElasticsearchCreate := aliaselasticsearch.NewCreateCommand(aliasElasticsearchRoot.CmdClause, data) aliasElasticsearchDelete := aliaselasticsearch.NewDeleteCommand(aliasElasticsearchRoot.CmdClause, data) aliasElasticsearchDescribe := aliaselasticsearch.NewDescribeCommand(aliasElasticsearchRoot.CmdClause, data) aliasElasticsearchList := aliaselasticsearch.NewListCommand(aliasElasticsearchRoot.CmdClause, data) aliasElasticsearchUpdate := aliaselasticsearch.NewUpdateCommand(aliasElasticsearchRoot.CmdClause, data) aliasFtpRoot := aliasftp.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasFtpCreate := aliasftp.NewCreateCommand(aliasFtpRoot.CmdClause, data) aliasFtpDelete := aliasftp.NewDeleteCommand(aliasFtpRoot.CmdClause, data) aliasFtpDescribe := aliasftp.NewDescribeCommand(aliasFtpRoot.CmdClause, data) aliasFtpList := aliasftp.NewListCommand(aliasFtpRoot.CmdClause, data) aliasFtpUpdate := aliasftp.NewUpdateCommand(aliasFtpRoot.CmdClause, data) aliasGcsRoot := aliasgcs.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasGcsCreate := aliasgcs.NewCreateCommand(aliasGcsRoot.CmdClause, data) aliasGcsDelete := aliasgcs.NewDeleteCommand(aliasGcsRoot.CmdClause, data) aliasGcsDescribe := aliasgcs.NewDescribeCommand(aliasGcsRoot.CmdClause, data) aliasGcsList := aliasgcs.NewListCommand(aliasGcsRoot.CmdClause, data) aliasGcsUpdate := aliasgcs.NewUpdateCommand(aliasGcsRoot.CmdClause, data) aliasGooglepubsubRoot := aliasgooglepubsub.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasGooglepubsubCreate := aliasgooglepubsub.NewCreateCommand(aliasGooglepubsubRoot.CmdClause, data) aliasGooglepubsubDelete := aliasgooglepubsub.NewDeleteCommand(aliasGooglepubsubRoot.CmdClause, data) aliasGooglepubsubDescribe := aliasgooglepubsub.NewDescribeCommand(aliasGooglepubsubRoot.CmdClause, data) aliasGooglepubsubList := aliasgooglepubsub.NewListCommand(aliasGooglepubsubRoot.CmdClause, data) aliasGooglepubsubUpdate := aliasgooglepubsub.NewUpdateCommand(aliasGooglepubsubRoot.CmdClause, data) aliasGrafanacloudlogsRoot := aliasgrafanacloudlogs.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasGrafanacloudlogsCreate := aliasgrafanacloudlogs.NewCreateCommand(aliasGrafanacloudlogsRoot.CmdClause, data) aliasGrafanacloudlogsDelete := aliasgrafanacloudlogs.NewDeleteCommand(aliasGrafanacloudlogsRoot.CmdClause, data) aliasGrafanacloudlogsDescribe := aliasgrafanacloudlogs.NewDescribeCommand(aliasGrafanacloudlogsRoot.CmdClause, data) aliasGrafanacloudlogsList := aliasgrafanacloudlogs.NewListCommand(aliasGrafanacloudlogsRoot.CmdClause, data) aliasGrafanacloudlogsUpdate := aliasgrafanacloudlogs.NewUpdateCommand(aliasGrafanacloudlogsRoot.CmdClause, data) aliasHerokuRoot := aliasheroku.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasHerokuCreate := aliasheroku.NewCreateCommand(aliasHerokuRoot.CmdClause, data) aliasHerokuDelete := aliasheroku.NewDeleteCommand(aliasHerokuRoot.CmdClause, data) aliasHerokuDescribe := aliasheroku.NewDescribeCommand(aliasHerokuRoot.CmdClause, data) aliasHerokuList := aliasheroku.NewListCommand(aliasHerokuRoot.CmdClause, data) aliasHerokuUpdate := aliasheroku.NewUpdateCommand(aliasHerokuRoot.CmdClause, data) aliasHoneycombRoot := aliashoneycomb.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasHoneycombCreate := aliashoneycomb.NewCreateCommand(aliasHoneycombRoot.CmdClause, data) aliasHoneycombDelete := aliashoneycomb.NewDeleteCommand(aliasHoneycombRoot.CmdClause, data) aliasHoneycombDescribe := aliashoneycomb.NewDescribeCommand(aliasHoneycombRoot.CmdClause, data) aliasHoneycombList := aliashoneycomb.NewListCommand(aliasHoneycombRoot.CmdClause, data) aliasHoneycombUpdate := aliashoneycomb.NewUpdateCommand(aliasHoneycombRoot.CmdClause, data) aliasHTTPSRoot := aliashttps.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasHTTPSCreate := aliashttps.NewCreateCommand(aliasHTTPSRoot.CmdClause, data) aliasHTTPSDelete := aliashttps.NewDeleteCommand(aliasHTTPSRoot.CmdClause, data) aliasHTTPSDescribe := aliashttps.NewDescribeCommand(aliasHTTPSRoot.CmdClause, data) aliasHTTPSList := aliashttps.NewListCommand(aliasHTTPSRoot.CmdClause, data) aliasHTTPSUpdate := aliashttps.NewUpdateCommand(aliasHTTPSRoot.CmdClause, data) aliasKafkaRoot := aliaskafka.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasKafkaCreate := aliaskafka.NewCreateCommand(aliasKafkaRoot.CmdClause, data) aliasKafkaDelete := aliaskafka.NewDeleteCommand(aliasKafkaRoot.CmdClause, data) aliasKafkaDescribe := aliaskafka.NewDescribeCommand(aliasKafkaRoot.CmdClause, data) aliasKafkaList := aliaskafka.NewListCommand(aliasKafkaRoot.CmdClause, data) aliasKafkaUpdate := aliaskafka.NewUpdateCommand(aliasKafkaRoot.CmdClause, data) aliasKinesisRoot := aliaskinesis.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasKinesisCreate := aliaskinesis.NewCreateCommand(aliasKinesisRoot.CmdClause, data) aliasKinesisDelete := aliaskinesis.NewDeleteCommand(aliasKinesisRoot.CmdClause, data) aliasKinesisDescribe := aliaskinesis.NewDescribeCommand(aliasKinesisRoot.CmdClause, data) aliasKinesisList := aliaskinesis.NewListCommand(aliasKinesisRoot.CmdClause, data) aliasKinesisUpdate := aliaskinesis.NewUpdateCommand(aliasKinesisRoot.CmdClause, data) aliasLogglyRoot := aliasloggly.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasLogglyCreate := aliasloggly.NewCreateCommand(aliasLogglyRoot.CmdClause, data) aliasLogglyDelete := aliasloggly.NewDeleteCommand(aliasLogglyRoot.CmdClause, data) aliasLogglyDescribe := aliasloggly.NewDescribeCommand(aliasLogglyRoot.CmdClause, data) aliasLogglyList := aliasloggly.NewListCommand(aliasLogglyRoot.CmdClause, data) aliasLogglyUpdate := aliasloggly.NewUpdateCommand(aliasLogglyRoot.CmdClause, data) aliasLogshuttleRoot := aliaslogshuttle.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasLogshuttleCreate := aliaslogshuttle.NewCreateCommand(aliasLogshuttleRoot.CmdClause, data) aliasLogshuttleDelete := aliaslogshuttle.NewDeleteCommand(aliasLogshuttleRoot.CmdClause, data) aliasLogshuttleDescribe := aliaslogshuttle.NewDescribeCommand(aliasLogshuttleRoot.CmdClause, data) aliasLogshuttleList := aliaslogshuttle.NewListCommand(aliasLogshuttleRoot.CmdClause, data) aliasLogshuttleUpdate := aliaslogshuttle.NewUpdateCommand(aliasLogshuttleRoot.CmdClause, data) aliasNewrelicRoot := aliasnewrelic.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasNewrelicCreate := aliasnewrelic.NewCreateCommand(aliasNewrelicRoot.CmdClause, data) aliasNewrelicDelete := aliasnewrelic.NewDeleteCommand(aliasNewrelicRoot.CmdClause, data) aliasNewrelicDescribe := aliasnewrelic.NewDescribeCommand(aliasNewrelicRoot.CmdClause, data) aliasNewrelicList := aliasnewrelic.NewListCommand(aliasNewrelicRoot.CmdClause, data) aliasNewrelicUpdate := aliasnewrelic.NewUpdateCommand(aliasNewrelicRoot.CmdClause, data) aliasNewrelicotlpRoot := aliasnewrelicotlp.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasNewrelicotlpCreate := aliasnewrelicotlp.NewCreateCommand(aliasNewrelicotlpRoot.CmdClause, data) aliasNewrelicotlpDelete := aliasnewrelicotlp.NewDeleteCommand(aliasNewrelicotlpRoot.CmdClause, data) aliasNewrelicotlpDescribe := aliasnewrelicotlp.NewDescribeCommand(aliasNewrelicotlpRoot.CmdClause, data) aliasNewrelicotlpList := aliasnewrelicotlp.NewListCommand(aliasNewrelicotlpRoot.CmdClause, data) aliasNewrelicotlpUpdate := aliasnewrelicotlp.NewUpdateCommand(aliasNewrelicotlpRoot.CmdClause, data) aliasOpenstackRoot := aliasopenstack.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasOpenstackCreate := aliasopenstack.NewCreateCommand(aliasOpenstackRoot.CmdClause, data) aliasOpenstackDelete := aliasopenstack.NewDeleteCommand(aliasOpenstackRoot.CmdClause, data) aliasOpenstackDescribe := aliasopenstack.NewDescribeCommand(aliasOpenstackRoot.CmdClause, data) aliasOpenstackList := aliasopenstack.NewListCommand(aliasOpenstackRoot.CmdClause, data) aliasOpenstackUpdate := aliasopenstack.NewUpdateCommand(aliasOpenstackRoot.CmdClause, data) aliasPapertrailRoot := aliaspapertrail.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasPapertrailCreate := aliaspapertrail.NewCreateCommand(aliasPapertrailRoot.CmdClause, data) aliasPapertrailDelete := aliaspapertrail.NewDeleteCommand(aliasPapertrailRoot.CmdClause, data) aliasPapertrailDescribe := aliaspapertrail.NewDescribeCommand(aliasPapertrailRoot.CmdClause, data) aliasPapertrailList := aliaspapertrail.NewListCommand(aliasPapertrailRoot.CmdClause, data) aliasPapertrailUpdate := aliaspapertrail.NewUpdateCommand(aliasPapertrailRoot.CmdClause, data) aliasS3Root := aliass3.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasS3Create := aliass3.NewCreateCommand(aliasS3Root.CmdClause, data) aliasS3Delete := aliass3.NewDeleteCommand(aliasS3Root.CmdClause, data) aliasS3Describe := aliass3.NewDescribeCommand(aliasS3Root.CmdClause, data) aliasS3List := aliass3.NewListCommand(aliasS3Root.CmdClause, data) aliasS3Update := aliass3.NewUpdateCommand(aliasS3Root.CmdClause, data) aliasScalyrRoot := aliasscalyr.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasScalyrCreate := aliasscalyr.NewCreateCommand(aliasScalyrRoot.CmdClause, data) aliasScalyrDelete := aliasscalyr.NewDeleteCommand(aliasScalyrRoot.CmdClause, data) aliasScalyrDescribe := aliasscalyr.NewDescribeCommand(aliasScalyrRoot.CmdClause, data) aliasScalyrList := aliasscalyr.NewListCommand(aliasScalyrRoot.CmdClause, data) aliasScalyrUpdate := aliasscalyr.NewUpdateCommand(aliasScalyrRoot.CmdClause, data) aliasSftpRoot := aliassftp.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasSftpCreate := aliassftp.NewCreateCommand(aliasSftpRoot.CmdClause, data) aliasSftpDelete := aliassftp.NewDeleteCommand(aliasSftpRoot.CmdClause, data) aliasSftpDescribe := aliassftp.NewDescribeCommand(aliasSftpRoot.CmdClause, data) aliasSftpList := aliassftp.NewListCommand(aliasSftpRoot.CmdClause, data) aliasSftpUpdate := aliassftp.NewUpdateCommand(aliasSftpRoot.CmdClause, data) aliasSplunkRoot := aliassplunk.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasSplunkCreate := aliassplunk.NewCreateCommand(aliasSplunkRoot.CmdClause, data) aliasSplunkDelete := aliassplunk.NewDeleteCommand(aliasSplunkRoot.CmdClause, data) aliasSplunkDescribe := aliassplunk.NewDescribeCommand(aliasSplunkRoot.CmdClause, data) aliasSplunkList := aliassplunk.NewListCommand(aliasSplunkRoot.CmdClause, data) aliasSplunkUpdate := aliassplunk.NewUpdateCommand(aliasSplunkRoot.CmdClause, data) aliasSumologicRoot := aliassumologic.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasSumologicCreate := aliassumologic.NewCreateCommand(aliasSumologicRoot.CmdClause, data) aliasSumologicDelete := aliassumologic.NewDeleteCommand(aliasSumologicRoot.CmdClause, data) aliasSumologicDescribe := aliassumologic.NewDescribeCommand(aliasSumologicRoot.CmdClause, data) aliasSumologicList := aliassumologic.NewListCommand(aliasSumologicRoot.CmdClause, data) aliasSumologicUpdate := aliassumologic.NewUpdateCommand(aliasSumologicRoot.CmdClause, data) aliasSyslogRoot := aliassyslog.NewRootCommand(aliasLoggingRoot.CmdClause, data) aliasSyslogCreate := aliassyslog.NewCreateCommand(aliasSyslogRoot.CmdClause, data) aliasSyslogDelete := aliassyslog.NewDeleteCommand(aliasSyslogRoot.CmdClause, data) aliasSyslogDescribe := aliassyslog.NewDescribeCommand(aliasSyslogRoot.CmdClause, data) aliasSyslogList := aliassyslog.NewListCommand(aliasSyslogRoot.CmdClause, data) aliasSyslogUpdate := aliassyslog.NewUpdateCommand(aliasSyslogRoot.CmdClause, data) if data.SSORunner == nil { data.SSORunner = func(in io.Reader, out io.Writer, forceReAuth bool, skipPrompt bool) error { return authcmd.RunSSO(in, out, data, forceReAuth, skipPrompt) } } cmds := []argparser.Command{ shellcompleteCmdRoot, } cmds = append(cmds, authCommands...) cmds = append(cmds, authtokenCommands...) cmds = append(cmds, []argparser.Command{ apisecurityRoot, discoveredoperationsRoot, discoveredoperationsList, discoveredoperationsUpdate, operationsRoot, operationsList, operationsCreate, operationsDescribe, operationsUpdate, operationsDelete, operationsAddTags, tagsRoot, tagsCreate, tagsDelete, tagsGet, tagsList, tagsUpdate, computeCmdRoot, computeACLCmdRoot, computeACLCreate, computeACLList, computeACLDescribe, computeACLDelete, computeACLUpdate, computeACLLookup, computeACLEntriesList, computeBuild, computeDeploy, computeHashFiles, 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, domainCmdRoot, domainCreate, domainDelete, domainDescribe, domainList, domainUpdate, installRoot, ipCmdRoot, kvstoreCreate, kvstoreDelete, kvstoreDescribe, kvstoreList, kvstoreentryCreate, kvstoreentryDelete, kvstoreentryGet, kvstoreentryDescribe, kvstoreentryList, logtailCmdRoot, serviceloggingDebugCmd, serviceloggingAzureblobCmdRoot, serviceloggingAzureblobCreate, serviceloggingAzureblobDelete, serviceloggingAzureblobDescribe, serviceloggingAzureblobList, serviceloggingAzureblobUpdate, serviceloggingBigQueryCmdRoot, serviceloggingBigQueryCreate, serviceloggingBigQueryDelete, serviceloggingBigQueryDescribe, serviceloggingBigQueryList, serviceloggingBigQueryUpdate, serviceloggingCloudfilesCmdRoot, serviceloggingCloudfilesCreate, serviceloggingCloudfilesDelete, serviceloggingCloudfilesDescribe, serviceloggingCloudfilesList, serviceloggingCloudfilesUpdate, serviceloggingCmdRoot, serviceloggingDatadogCmdRoot, serviceloggingDatadogCreate, serviceloggingDatadogDelete, serviceloggingDatadogDescribe, serviceloggingDatadogList, serviceloggingDatadogUpdate, serviceloggingDigitaloceanCmdRoot, serviceloggingDigitaloceanCreate, serviceloggingDigitaloceanDelete, serviceloggingDigitaloceanDescribe, serviceloggingDigitaloceanList, serviceloggingDigitaloceanUpdate, serviceloggingElasticsearchCmdRoot, serviceloggingElasticsearchCreate, serviceloggingElasticsearchDelete, serviceloggingElasticsearchDescribe, serviceloggingElasticsearchList, serviceloggingElasticsearchUpdate, serviceloggingFtpCmdRoot, serviceloggingFtpCreate, serviceloggingFtpDelete, serviceloggingFtpDescribe, serviceloggingFtpList, serviceloggingFtpUpdate, serviceloggingGcsCmdRoot, serviceloggingGcsCreate, serviceloggingGcsDelete, serviceloggingGcsDescribe, serviceloggingGcsList, serviceloggingGcsUpdate, serviceloggingGooglepubsubCmdRoot, serviceloggingGooglepubsubCreate, serviceloggingGooglepubsubDelete, serviceloggingGooglepubsubDescribe, serviceloggingGooglepubsubList, serviceloggingGooglepubsubUpdate, serviceloggingGrafanacloudlogsCmdRoot, serviceloggingGrafanacloudlogsCreate, serviceloggingGrafanacloudlogsDelete, serviceloggingGrafanacloudlogsDescribe, serviceloggingGrafanacloudlogsList, serviceloggingGrafanacloudlogsUpdate, serviceloggingHerokuCmdRoot, serviceloggingHerokuCreate, serviceloggingHerokuDelete, serviceloggingHerokuDescribe, serviceloggingHerokuList, serviceloggingHerokuUpdate, serviceloggingHoneycombCmdRoot, serviceloggingHoneycombCreate, serviceloggingHoneycombDelete, serviceloggingHoneycombDescribe, serviceloggingHoneycombList, serviceloggingHoneycombUpdate, serviceloggingHTTPSCmdRoot, serviceloggingHTTPSCreate, serviceloggingHTTPSDelete, serviceloggingHTTPSDescribe, serviceloggingHTTPSList, serviceloggingHTTPSUpdate, serviceloggingKafkaCmdRoot, serviceloggingKafkaCreate, serviceloggingKafkaDelete, serviceloggingKafkaDescribe, serviceloggingKafkaList, serviceloggingKafkaUpdate, serviceloggingKinesisCmdRoot, serviceloggingKinesisCreate, serviceloggingKinesisDelete, serviceloggingKinesisDescribe, serviceloggingKinesisList, serviceloggingKinesisUpdate, serviceloggingLogglyCmdRoot, serviceloggingLogglyCreate, serviceloggingLogglyDelete, serviceloggingLogglyDescribe, serviceloggingLogglyList, serviceloggingLogglyUpdate, serviceloggingLogshuttleCmdRoot, serviceloggingLogshuttleCreate, serviceloggingLogshuttleDelete, serviceloggingLogshuttleDescribe, serviceloggingLogshuttleList, serviceloggingLogshuttleUpdate, serviceloggingNewRelicCmdRoot, serviceloggingNewRelicCreate, serviceloggingNewRelicDelete, serviceloggingNewRelicDescribe, serviceloggingNewRelicList, serviceloggingNewRelicUpdate, serviceloggingNewRelicOTLPCmdRoot, serviceloggingNewRelicOTLPCreate, serviceloggingNewRelicOTLPDelete, serviceloggingNewRelicOTLPDescribe, serviceloggingNewRelicOTLPList, serviceloggingNewRelicOTLPUpdate, serviceloggingOpenstackCmdRoot, serviceloggingOpenstackCreate, serviceloggingOpenstackDelete, serviceloggingOpenstackDescribe, serviceloggingOpenstackList, serviceloggingOpenstackUpdate, serviceloggingPapertrailCmdRoot, serviceloggingPapertrailCreate, serviceloggingPapertrailDelete, serviceloggingPapertrailDescribe, serviceloggingPapertrailList, serviceloggingPapertrailUpdate, serviceloggingS3CmdRoot, serviceloggingS3Create, serviceloggingS3Delete, serviceloggingS3Describe, serviceloggingS3List, serviceloggingS3Update, serviceloggingScalyrCmdRoot, serviceloggingScalyrCreate, serviceloggingScalyrDelete, serviceloggingScalyrDescribe, serviceloggingScalyrList, serviceloggingScalyrUpdate, serviceloggingSftpCmdRoot, serviceloggingSftpCreate, serviceloggingSftpDelete, serviceloggingSftpDescribe, serviceloggingSftpList, serviceloggingSftpUpdate, serviceloggingSplunkCmdRoot, serviceloggingSplunkCreate, serviceloggingSplunkDelete, serviceloggingSplunkDescribe, serviceloggingSplunkList, serviceloggingSplunkUpdate, serviceloggingSumologicCmdRoot, serviceloggingSumologicCreate, serviceloggingSumologicDelete, serviceloggingSumologicDescribe, serviceloggingSumologicList, serviceloggingSumologicUpdate, serviceloggingSyslogCmdRoot, serviceloggingSyslogCreate, serviceloggingSyslogDelete, serviceloggingSyslogDescribe, serviceloggingSyslogList, serviceloggingSyslogUpdate, ngwafRoot, ngwafRedactionCreate, ngwafRedactionDelete, ngwafRedactionList, ngwafRedactionRetrieve, ngwafRedactionUpdate, ngwafRedactionRoot, ngwafCountryListRoot, ngwafCountryListCreate, ngwafCountryListDelete, ngwafCountryListGet, ngwafCountryListList, ngwafCountryListUpdate, ngwafCustomSignalRoot, ngwafCustomSignalCreate, ngwafCustomSignalDelete, ngwafCustomSignalGet, ngwafCustomSignalList, ngwafCustomSignalUpdate, ngwafIPListRoot, ngwafIPListCreate, ngwafIPListDelete, ngwafIPListGet, ngwafIPListList, ngwafIPListUpdate, ngwafRuleRoot, ngwafRuleCreate, ngwafRuleDelete, ngwafRuleGet, ngwafRuleList, ngwafRuleUpdate, ngwafSignalListRoot, ngwafSignalListCreate, ngwafSignalListDelete, ngwafSignalListGet, ngwafSignalListList, ngwafSignalListUpdate, ngwafStringListRoot, ngwafStringListCreate, ngwafStringListDelete, ngwafStringListGet, ngwafStringListList, ngwafStringListUpdate, ngwafWildcardListCreate, ngwafWildcardListDelete, ngwafWildcardListGet, ngwafWildcardListList, ngwafWildcardListUpdate, ngwafWorkspaceCountryListRoot, ngwafWorkspaceCountryListCreate, ngwafWorkspaceCountryListDelete, ngwafWorkspaceCountryListGet, ngwafWorkspaceCountryListList, ngwafWorkspaceCountryListUpdate, ngwafWorkspaceCustomSignalRoot, ngwafWorkspaceCustomSignalCreate, ngwafWorkspaceCustomSignalDelete, ngwafWorkspaceCustomSignalGet, ngwafWorkspaceCustomSignalList, ngwafWorkspaceCustomSignalUpdate, ngwafWorkspaceIPListRoot, ngwafWorkspaceIPListCreate, ngwafWorkspaceIPListDelete, ngwafWorkspaceIPListGet, ngwafWorkspaceIPListList, ngwafWorkspaceIPListUpdate, ngwafWorkspaceRuleRoot, ngwafWorkspaceRuleCreate, ngwafWorkspaceRuleDelete, ngwafWorkspaceRuleGet, ngwafWorkspaceRuleList, ngwafWorkspaceRuleUpdate, ngwafWorkspaceSignalListRoot, ngwafWorkspaceSignalListCreate, ngwafWorkspaceSignalListDelete, ngwafWorkspaceSignalListGet, ngwafWorkspaceSignalListList, ngwafWorkspaceSignalListUpdate, ngwafWorkspaceStringListRoot, ngwafWorkspaceStringListCreate, ngwafWorkspaceStringListDelete, ngwafWorkspaceStringListGet, ngwafWorkspaceStringListList, ngwafWorkspaceStringListUpdate, ngwafWorkspaceThresholdRoot, ngwafWorkspaceThresholdCreate, ngwafWorkspaceThresholdDelete, ngwafWorkspaceThresholdGet, ngwafWorkspaceThresholdList, ngwafWorkspaceThresholdUpdate, ngwafWorkspaceWildcardListCreate, ngwafWorkspaceWildcardListDelete, ngwafWorkspaceWildcardListGet, ngwafWorkspaceWildcardListList, ngwafWorkspaceWildcardListUpdate, ngwafVirtualpatchList, ngwafVirtualpatchRetrieve, ngwafVirtualpatchRoot, ngwafVirtualpatchUpdate, ngwafWorkspaceAlertRoot, ngwafWorkspaceAlertDatadogRoot, ngwafWorkspaceAlertDatadogCreate, ngwafWorkspaceAlertDatadogDelete, ngwafWorkspaceAlertDatadogGet, ngwafWorkspaceAlertDatadogList, ngwafWorkspaceAlertDatadogUpdate, ngwafWorkspaceAlertJiraRoot, ngwafWorkspaceAlertJiraCreate, ngwafWorkspaceAlertJiraDelete, ngwafWorkspaceAlertJiraGet, ngwafWorkspaceAlertJiraList, ngwafWorkspaceAlertJiraUpdate, ngwafWorkspaceAlertMailinglistRoot, ngwafWorkspaceAlertMailinglistCreate, ngwafWorkspaceAlertMailinglistDelete, ngwafWorkspaceAlertMailinglistGet, ngwafWorkspaceAlertMailinglistList, ngwafWorkspaceAlertMailinglistUpdate, ngwafWorkspaceAlertMicrosoftteamsRoot, ngwafWorkspaceAlertMicrosoftteamsCreate, ngwafWorkspaceAlertMicrosoftteamsDelete, ngwafWorkspaceAlertMicrosoftteamsGet, ngwafWorkspaceAlertMicrosoftteamsList, ngwafWorkspaceAlertMicrosoftteamsUpdate, ngwafWorkspaceAlertOpsgenieRoot, ngwafWorkspaceAlertOpsgenieCreate, ngwafWorkspaceAlertOpsgenieDelete, ngwafWorkspaceAlertOpsgenieGet, ngwafWorkspaceAlertOpsgenieList, ngwafWorkspaceAlertOpsgenieUpdate, ngwafWorkspaceAlertPagerdutyRoot, ngwafWorkspaceAlertPagerdutyCreate, ngwafWorkspaceAlertPagerdutyDelete, ngwafWorkspaceAlertPagerdutyGet, ngwafWorkspaceAlertPagerdutyList, ngwafWorkspaceAlertPagerdutyUpdate, ngwafWorkspaceAlertSlackRoot, ngwafWorkspaceAlertSlackCreate, ngwafWorkspaceAlertSlackDelete, ngwafWorkspaceAlertSlackGet, ngwafWorkspaceAlertSlackList, ngwafWorkspaceAlertSlackUpdate, ngwafWorkspaceAlertWebhookRoot, ngwafWorkspaceAlertWebhookCreate, ngwafWorkspaceAlertWebhookDelete, ngwafWorkspaceAlertWebhookGet, ngwafWorkspaceAlertWebhookGetSigningKey, ngwafWorkspaceAlertWebhookList, ngwafWorkspaceAlertWebhookRotateSigningKey, ngwafWorkspaceAlertWebhookUpdate, ngwafWorkspaceRoot, ngwafWorkspaceCreate, ngwafWorkspaceDelete, ngwafWorkspaceGet, ngwafWorkspaceList, ngwafWorkspaceUpdate, objectStorageRoot, objectStorageAccesskeysRoot, objectStorageAccesskeysCreate, objectStorageAccesskeysDelete, objectStorageAccesskeysGet, objectStorageAccesskeysList, popCmdRoot, productsCmdRoot, }...) cmds = append(cmds, profileCommands...) cmds = append(cmds, []argparser.Command{ secretstoreCreate, secretstoreDescribe, secretstoreDelete, secretstoreList, secretstoreentryCreate, secretstoreentryDescribe, secretstoreentryDelete, secretstoreentryList, serviceCmdRoot, serviceCreate, serviceDelete, serviceDescribe, serviceList, serviceSearch, serviceUpdate, servicePurge, servicealertCreate, servicealertDelete, servicealertDescribe, servicealertList, servicealertListHistory, servicealertUpdate, serviceaclCmdRoot, serviceaclCreate, serviceaclDelete, serviceaclDescribe, serviceaclList, serviceaclUpdate, serviceaclentryCmdRoot, serviceaclentryCreate, serviceaclentryDelete, serviceaclentryDescribe, serviceaclentryList, serviceaclentryUpdate, serviceauthCmdRoot, serviceauthCreate, serviceauthDelete, serviceauthDescribe, serviceauthList, serviceauthUpdate, servicedictionaryCmdRoot, servicedictionaryCreate, servicedictionaryDelete, servicedictionaryDescribe, servicedictionaryList, servicedictionaryUpdate, servicevclCmdRoot, servicevclDescribe, servicevclConditionCmdRoot, servicevclConditionCreate, servicevclConditionDelete, servicevclConditionDescribe, servicevclConditionList, servicevclConditionUpdate, servicevclCustomCmdRoot, servicevclCustomCreate, servicevclCustomDelete, servicevclCustomDescribe, servicevclCustomList, servicevclCustomUpdate, servicevclSnippetCmdRoot, servicevclSnippetCreate, servicevclSnippetDelete, servicevclSnippetDescribe, servicevclSnippetList, servicevclSnippetUpdate, servicedomainCmdRoot, servicedomainCreate, servicedomainDelete, servicedomainDescribe, servicedomainList, servicedomainUpdate, servicedomainValidate, servicedictionaryentryCmdRoot, servicedictionaryentryCreate, servicedictionaryentryDelete, servicedictionaryentryDescribe, servicedictionaryentryList, servicedictionaryentryUpdate, servicebackendCmdRoot, servicebackendCreate, servicebackendDelete, servicebackendDescribe, servicebackendList, servicebackendUpdate, servicehealthcheckCmdRoot, servicehealthcheckCreate, servicehealthcheckDelete, servicehealthcheckDescribe, servicehealthcheckList, servicehealthcheckUpdate, serviceimageoptimizerdefaultsCmdRoot, serviceimageoptimizerdefaultsGet, serviceimageoptimizerdefaultsUpdate, serviceratelimitCmdRoot, serviceratelimitCreate, serviceratelimitDelete, serviceratelimitDescribe, serviceratelimitList, serviceratelimitUpdate, serviceresourcelinkCmdRoot, serviceresourcelinkCreate, serviceresourcelinkDelete, serviceresourcelinkDescribe, serviceresourcelinkList, serviceresourcelinkUpdate, serviceVersionActivate, serviceVersionClone, serviceVersionCmdRoot, serviceVersionDeactivate, serviceVersionList, serviceVersionLock, serviceVersionStage, serviceVersionUnstage, serviceVersionUpdate, serviceVersionValidate, }...) cmds = append(cmds, ssoCommands...) cmds = append(cmds, []argparser.Command{ statsCmdRoot, statsAggregate, statsDomainInspector, statsHistorical, statsOriginInspector, statsRealtime, statsRegions, statsUsage, 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, toolsCmdRoot, toolsDomainCmdRoot, toolsDomainStatus, toolsDomainSuggestions, updateRoot, userCmdRoot, userCreate, userDelete, userDescribe, userList, userUpdate, versionCmdRoot, }...) cmds = append(cmds, whoamiCommands...) cmds = append(cmds, []argparser.Command{ aliasBackendCreate, aliasBackendDelete, aliasBackendDescribe, aliasBackendList, aliasBackendUpdate, aliasDictionaryEntryCreate, aliasDictionaryEntryDelete, aliasDictionaryEntryDescribe, aliasDictionaryEntryList, aliasDictionaryEntryUpdate, aliasDictionaryCreate, aliasDictionaryDelete, aliasDictionaryDescribe, aliasDictionaryList, aliasDictionaryUpdate, aliasHealthcheckCreate, aliasHealthcheckDelete, aliasHealthcheckDescribe, aliasHealthcheckList, aliasHealthcheckUpdate, aliasimageoptimizerdefaultsGet, aliasimageoptimizerdefaultsUpdate, aliasPurge, aliasAlertRoot, aliasAlertCreate, aliasAlertDelete, aliasAlertDescribe, aliasAlertList, aliasAlertListHistory, aliasAlertUpdate, aliasACLCreate, aliasACLDelete, aliasACLDescribe, aliasACLList, aliasACLUpdate, aliasACLEntryCreate, aliasACLEntryDelete, aliasACLEntryDescribe, aliasACLEntryList, aliasACLEntryUpdate, aliasRateLimitCreate, aliasRateLimitDelete, aliasRateLimitDescribe, aliasRateLimitList, aliasRateLimitUpdate, aliasResourceLinkCreate, aliasResourceLinkDelete, aliasResourceLinkDescribe, aliasResourceLinkList, aliasResourceLinkUpdate, aliasServiceAuthCreate, aliasServiceAuthDelete, aliasServiceAuthDescribe, aliasServiceAuthList, aliasServiceAuthUpdate, aliasVclDescribe, aliasVclConditionCreate, aliasVclConditionDelete, aliasVclConditionDescribe, aliasVclConditionList, aliasVclConditionUpdate, aliasVclCustomCreate, aliasVclCustomDelete, aliasVclCustomDescribe, aliasVclCustomList, aliasVclCustomUpdate, aliasVclSnippetCreate, aliasVclSnippetDelete, aliasVclSnippetDescribe, aliasVclSnippetList, aliasVclSnippetUpdate, aliasServiceVersionActivate, aliasServiceVersionClone, aliasServiceVersionDeactivate, aliasServiceVersionList, aliasServiceVersionLock, aliasServiceVersionStage, aliasServiceVersionUnstage, aliasServiceVersionUpdate, aliasLoggingRoot, aliasAzureblobRoot, aliasAzureblobCreate, aliasAzureblobDelete, aliasAzureblobDescribe, aliasAzureblobList, aliasAzureblobUpdate, aliasBigqueryRoot, aliasBigqueryCreate, aliasBigqueryDelete, aliasBigqueryDescribe, aliasBigqueryList, aliasBigqueryUpdate, aliasCloudfilesRoot, aliasCloudfilesCreate, aliasCloudfilesDelete, aliasCloudfilesDescribe, aliasCloudfilesList, aliasCloudfilesUpdate, aliasDatadogRoot, aliasDatadogCreate, aliasDatadogDelete, aliasDatadogDescribe, aliasDatadogList, aliasDatadogUpdate, aliasDigitaloceanRoot, aliasDigitaloceanCreate, aliasDigitaloceanDelete, aliasDigitaloceanDescribe, aliasDigitaloceanList, aliasDigitaloceanUpdate, aliasElasticsearchRoot, aliasElasticsearchCreate, aliasElasticsearchDelete, aliasElasticsearchDescribe, aliasElasticsearchList, aliasElasticsearchUpdate, aliasFtpRoot, aliasFtpCreate, aliasFtpDelete, aliasFtpDescribe, aliasFtpList, aliasFtpUpdate, aliasGcsRoot, aliasGcsCreate, aliasGcsDelete, aliasGcsDescribe, aliasGcsList, aliasGcsUpdate, aliasGooglepubsubRoot, aliasGooglepubsubCreate, aliasGooglepubsubDelete, aliasGooglepubsubDescribe, aliasGooglepubsubList, aliasGooglepubsubUpdate, aliasGrafanacloudlogsRoot, aliasGrafanacloudlogsCreate, aliasGrafanacloudlogsDelete, aliasGrafanacloudlogsDescribe, aliasGrafanacloudlogsList, aliasGrafanacloudlogsUpdate, aliasHerokuRoot, aliasHerokuCreate, aliasHerokuDelete, aliasHerokuDescribe, aliasHerokuList, aliasHerokuUpdate, aliasHoneycombRoot, aliasHoneycombCreate, aliasHoneycombDelete, aliasHoneycombDescribe, aliasHoneycombList, aliasHoneycombUpdate, aliasHTTPSRoot, aliasHTTPSCreate, aliasHTTPSDelete, aliasHTTPSDescribe, aliasHTTPSList, aliasHTTPSUpdate, aliasKafkaRoot, aliasKafkaCreate, aliasKafkaDelete, aliasKafkaDescribe, aliasKafkaList, aliasKafkaUpdate, aliasKinesisRoot, aliasKinesisCreate, aliasKinesisDelete, aliasKinesisDescribe, aliasKinesisList, aliasKinesisUpdate, aliasLogglyRoot, aliasLogglyCreate, aliasLogglyDelete, aliasLogglyDescribe, aliasLogglyList, aliasLogglyUpdate, aliasLogshuttleRoot, aliasLogshuttleCreate, aliasLogshuttleDelete, aliasLogshuttleDescribe, aliasLogshuttleList, aliasLogshuttleUpdate, aliasNewrelicRoot, aliasNewrelicCreate, aliasNewrelicDelete, aliasNewrelicDescribe, aliasNewrelicList, aliasNewrelicUpdate, aliasNewrelicotlpRoot, aliasNewrelicotlpCreate, aliasNewrelicotlpDelete, aliasNewrelicotlpDescribe, aliasNewrelicotlpList, aliasNewrelicotlpUpdate, aliasOpenstackRoot, aliasOpenstackCreate, aliasOpenstackDelete, aliasOpenstackDescribe, aliasOpenstackList, aliasOpenstackUpdate, aliasPapertrailRoot, aliasPapertrailCreate, aliasPapertrailDelete, aliasPapertrailDescribe, aliasPapertrailList, aliasPapertrailUpdate, aliasS3Root, aliasS3Create, aliasS3Delete, aliasS3Describe, aliasS3List, aliasS3Update, aliasScalyrRoot, aliasScalyrCreate, aliasScalyrDelete, aliasScalyrDescribe, aliasScalyrList, aliasScalyrUpdate, aliasSftpRoot, aliasSftpCreate, aliasSftpDelete, aliasSftpDescribe, aliasSftpList, aliasSftpUpdate, aliasSplunkRoot, aliasSplunkCreate, aliasSplunkDelete, aliasSplunkDescribe, aliasSplunkList, aliasSplunkUpdate, aliasSumologicRoot, aliasSumologicCreate, aliasSumologicDelete, aliasSumologicDescribe, aliasSumologicList, aliasSumologicUpdate, aliasSyslogRoot, aliasSyslogCreate, aliasSyslogDelete, aliasSyslogDescribe, aliasSyslogList, aliasSyslogUpdate, }...) return cmds } ================================================ FILE: pkg/commands/commands_test.go ================================================ package commands_test import ( "bytes" "io" "strings" "testing" "github.com/fastly/kingpin" "github.com/fastly/cli/pkg/commands" "github.com/fastly/cli/pkg/testutil" ) // authCommandPrefixes lists the command names (and prefixes for subcommands) // that should be excluded when FASTLY_DISABLE_AUTH_COMMAND is set. var authCommandPrefixes = []string{"auth", "auth-token", "sso", "profile", "whoami"} // isAuthRelated reports whether a command name belongs to an auth-related // command group. func isAuthRelated(name string) bool { for _, prefix := range authCommandPrefixes { if name == prefix || strings.HasPrefix(name, prefix+" ") { return true } } return false } func TestDefineDisableAuthCommand(t *testing.T) { newApp := func(stdout *bytes.Buffer) *kingpin.Application { app := kingpin.New("fastly", "test") app.Writers(stdout, io.Discard) app.Terminate(nil) return app } t.Run("auth-related commands present by default", func(t *testing.T) { var stdout bytes.Buffer data := testutil.MockGlobalData([]string{"fastly"}, &stdout) cmds := commands.Define(newApp(&stdout), data) for _, want := range authCommandPrefixes { found := false for _, cmd := range cmds { if cmd.Name() == want || strings.HasPrefix(cmd.Name(), want+" ") { found = true break } } if !found { t.Errorf("expected %q command to be present when FASTLY_DISABLE_AUTH_COMMAND is not set", want) } } }) t.Run("auth-related commands excluded when FASTLY_DISABLE_AUTH_COMMAND is set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") var stdout bytes.Buffer data := testutil.MockGlobalData([]string{"fastly"}, &stdout) cmds := commands.Define(newApp(&stdout), data) for _, cmd := range cmds { if isAuthRelated(cmd.Name()) { t.Errorf("expected no auth-related commands, but found %q", cmd.Name()) } } // Non-auth commands still exist. found := false for _, cmd := range cmds { if cmd.Name() == "compute" { found = true break } } if !found { t.Error("expected compute command to still be present") } }) } ================================================ FILE: pkg/commands/compute/build.go ================================================ package compute import ( "bufio" "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/kennygrant/sanitize" "github.com/mholt/archives" "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, 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 prioritizes 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 "cpp": language = NewLanguage(&LanguageOptions{ Name: "cpp", SourceDirectory: CPPSourceDirectory, Toolchain: NewCPP(c, in, manifestFilename, out, spinner), }) 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) } } return createTarGz(dir, destination) } // createTarGz creates a .tar.gz archive from a directory. func createTarGz(sourceDir, destFile string) error { ctx := context.Background() // Map files from disk files, err := archives.FilesFromDisk(ctx, nil, map[string]string{ sourceDir: "", }) if err != nil { return fmt.Errorf("failed to map files from disk: %w", err) } // Ensure parent directory exists destDir := filepath.Dir(destFile) if err := os.MkdirAll(destDir, 0o755); err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } // Create output file out, err := os.Create(destFile) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } defer out.Close() // Create compressed archive format format := archives.CompressedArchive{ Compression: archives.Gz{}, Archival: archives.Tar{}, } // Create the archive err = format.Archive(ctx, out, files) if err != nil { return fmt.Errorf("failed to create archive: %w", 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 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 hash. // 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"` } ================================================ FILE: pkg/commands/compute/build_test.go ================================================ 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 TestBuildUnknownLanguage(t *testing.T) { if 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 }{ { 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", }, } for testcaseIdx := range scenarios { testcase := &scenarios[testcaseIdx] t.Run(testcase.name, func(t *testing.T) { pwd, err := os.Getwd() if err != nil { t.Fatal(err) } wasmtoolsBinName := "wasm-tools" latestDownloaded := wasmtoolsBinName + "-latest-downloaded" rootdir := testutil.NewEnv(testutil.EnvOpts{ T: t, 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) 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) opts.Versioners = global.Versioners{ WasmTools: mock.AssetVersioner{ AssetVersion: "1.2.3", BinaryFilename: wasmtoolsBinName, DownloadOK: true, DownloadedFile: latestDownloaded, InstallFilePath: wasmtoolsBinPath, }, } return opts, nil } err = app.Run(testcase.args, nil) t.Log(stdout.String()) testutil.AssertErrorContains(t, err, testcase.wantError) }) } } 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: file not found", wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", }, // 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{ 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{ 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{ 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{ 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: file not found", wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", }, // 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{ 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{ 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: file not found", wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", }, // 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. // NOTE: npmInstall is required because toolchain verification checks for node_modules. { 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", }, npmInstall: true, }, { 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.JsDefaultBuildCommand), 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", "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) } }) } } func TestBuildCPP(t *testing.T) { if os.Getenv("TEST_COMPUTE_BUILD_CPP") == "" && 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: file not found", wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", }, // 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"), applicationConfig: &config.File{ Language: config.Language{ CPP: config.CPP{ ToolchainConstraint: ">= 14.0.0", WasmWasiTarget: "wasm32-wasip1", }, }, }, fastlyManifest: ` manifest_version = 2 name = "test" language = "cpp"`, wantOutput: []string{ "No [scripts.build] found in fastly.toml.", // requires --verbose "The following default build command for C++ will be used", "clang++ -O3 --target=wasm32-wasip1 -o ./bin/main.wasm main.cpp", }, }, { name: "wasmwasi target error", args: args("compute build --verbose"), applicationConfig: &config.File{ Language: config.Language{ CPP: config.CPP{ ToolchainConstraint: ">= 14.0.0", WasmWasiTarget: "wasm32-wasi", }, }, }, fastlyManifest: ` manifest_version = 2 name = "test" language = "cpp"`, wantError: "the default build in .fastly/config.toml should produce a wasm32-wasip1 binary, but was instead set to produce a wasm32-wasi binary", }, { name: "build error", args: args("compute build"), applicationConfig: &config.File{ Language: config.Language{ CPP: config.CPP{ ToolchainConstraint: ">= 14.0.0", WasmWasiTarget: "wasm32-wasip1", }, }, }, fastlyManifest: ` manifest_version = 2 name = "test" language = "cpp" [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"), applicationConfig: &config.File{ Language: config.Language{ CPP: config.CPP{ ToolchainConstraint: ">= 14.0.0", WasmWasiTarget: "wasm32-wasip1", }, }, }, fastlyManifest: ` manifest_version = 2 name = "test" language = "cpp" [scripts] build = "clang++ -O3 --target=wasm32-wasip1 -o bin/main.wasm main.cpp"`, 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", "cpp", "main.cpp"), Dst: "main.cpp"}, }, 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) } }) } } // 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) } }) } } ================================================ FILE: pkg/commands/compute/compute_mocks_test.go ================================================ 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 ( "context" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/testutil" ) func getServiceOK(_ context.Context, _ *fastly.GetServiceInput) (*fastly.Service, error) { return &fastly.Service{ ServiceID: fastly.ToPointer("12345"), Name: fastly.ToPointer("test"), }, nil } func createDomainOK(_ context.Context, 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(_ context.Context, 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(_ context.Context, i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { return &fastly.ConfigStore{ Name: i.Name, }, nil } func updateConfigStoreItemOK(_ context.Context, i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { return &fastly.ConfigStoreItem{ Key: i.Key, Value: i.Value, }, nil } func createDictionaryOK(_ context.Context, 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(_ context.Context, 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(_ context.Context, i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { return &fastly.KVStore{ StoreID: "example-store", Name: i.Name, }, nil } func createKVStoreItemOK(_ context.Context, _ *fastly.InsertKVStoreKeyInput) error { return nil } func createResourceOK(_ context.Context, _ *fastly.CreateResourceInput) (*fastly.Resource, error) { return nil, nil } func getPackageOk(_ context.Context, i *fastly.GetPackageInput) (*fastly.Package, error) { return &fastly.Package{ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion)}, nil } func updatePackageOk(_ context.Context, i *fastly.UpdatePackageInput) (*fastly.Package, error) { return &fastly.Package{ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion)}, nil } func updatePackageError(_ context.Context, _ *fastly.UpdatePackageInput) (*fastly.Package, error) { return nil, testutil.Err } func activateVersionOk(_ context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { return &fastly.Version{ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(i.ServiceVersion)}, nil } func updateVersionOk(_ context.Context, 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(_ context.Context, _ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { return []*fastly.Domain{ {Name: fastly.ToPointer("https://directly-careful-coyote.edgecompute.app")}, }, nil } func listKVStoresOk(_ context.Context, _ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return &fastly.ListKVStoresResponse{ Data: []fastly.KVStore{ { StoreID: "123", Name: "store_one", }, { StoreID: "456", Name: "store_two", }, }, }, nil } func listKVStoresEmpty(_ context.Context, _ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return &fastly.ListKVStoresResponse{}, nil } func getKVStoreOk(_ context.Context, _ *fastly.GetKVStoreInput) (*fastly.KVStore, error) { return &fastly.KVStore{ StoreID: "123", Name: "store_one", }, nil } func listSecretStoresOk(_ context.Context, _ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return &fastly.SecretStores{ Data: []fastly.SecretStore{ { StoreID: "123", Name: "store_one", }, { StoreID: "456", Name: "store_two", }, }, }, nil } func listSecretStoresEmpty(_ context.Context, _ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return &fastly.SecretStores{}, nil } func getSecretStoreOk(_ context.Context, _ *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { return &fastly.SecretStore{ StoreID: "123", Name: "store_one", }, nil } func createSecretStoreOk(_ context.Context, _ *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { return &fastly.SecretStore{ StoreID: "123", Name: "store_one", }, nil } func createSecretOk(_ context.Context, _ *fastly.CreateSecretInput) (*fastly.Secret, error) { return &fastly.Secret{ Digest: []byte("123"), Name: "foo", }, nil } func listConfigStoresOk(_ context.Context, _ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return []*fastly.ConfigStore{ { StoreID: "123", Name: "example", }, { StoreID: "456", Name: "example_two", }, }, nil } func listConfigStoresEmpty(_ context.Context, _ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return []*fastly.ConfigStore{}, nil } func getConfigStoreOk(_ context.Context, _ *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { return &fastly.ConfigStore{ StoreID: "123", Name: "example", }, nil } func getServiceDetailsWasm(_ context.Context, i *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { detail := &fastly.ServiceDetail{ Type: fastly.ToPointer("wasm"), Version: &fastly.Version{ Number: fastly.ToPointer(1), }, } // If filtering for active version, also set ActiveVersion field for _, filter := range i.Filters { if filter.Key == "versions.active" && filter.Value { detail.ActiveVersion = &fastly.Version{ Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), } } } return detail, nil } func getServiceDetailsWasmNoActive(_ context.Context, _ *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { // Returns service details with no active version, forcing fallback to latest return &fastly.ServiceDetail{ Type: fastly.ToPointer("wasm"), Version: &fastly.Version{ Number: fastly.ToPointer(1), }, }, nil } ================================================ FILE: pkg/commands/compute/compute_test.go ================================================ package compute_test import ( "context" "os" "path/filepath" "reflect" "testing" "github.com/mholt/archives" "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", "experimental-enable-pushpin", "file", "profile-guest", "pushpin-path", "pushpin-proxy-port", "pushpin-publish-port", "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) } } // TestFlagDivergenceHashFiles validates that the manually curated list of flags // within the `compute hash-files` command doesn't fall out of sync with the // `compute build` command as `compute hash-files` 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 hash-files` are unique to it. // We only want to be sure hash-files 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 // Walk the archive using archives API input, err := os.Open(destination) if err != nil { t.Fatal(err) } defer input.Close() format, stream, err := archives.Identify(context.Background(), destination, input) if err != nil { t.Fatal(err) } if ex, ok := format.(archives.Extractor); ok { err = ex.Extract(context.Background(), stream, func(_ context.Context, f archives.FileInfo) error { name := filepath.Base(f.NameInArchive) if f.IsDir() { directories = append(directories, name) } else { files = append(files, name) } return nil }) if err != nil { t.Fatal(err) } } else { t.Fatal("format does not support extraction") } 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) }) } } ================================================ FILE: pkg/commands/compute/computeacl/computeacl_test.go ================================================ 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/v15/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" ================================================ FILE: pkg/commands/compute/computeacl/create.go ================================================ package computeacl import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/delete.go ================================================ package computeacl import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/describe.go ================================================ package computeacl import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/doc.go ================================================ // Package computeacl contains commands to inspect and manipulate Fastly compute ACLs. package computeacl ================================================ FILE: pkg/commands/compute/computeacl/listacls.go ================================================ package computeacl import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/listentries.go ================================================ package computeacl import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/lookup.go ================================================ package computeacl import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/computeacl/root.go ================================================ 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") } ================================================ FILE: pkg/commands/compute/computeacl/testdata/entries.json ================================================ { "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" } ] } ================================================ FILE: pkg/commands/compute/computeacl/update.go ================================================ package computeacl import ( "context" "encoding/json" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/deploy.go ================================================ package compute import ( "context" "errors" "fmt" "io" "io/fs" "net/http" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/kennygrant/sanitize" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/api" "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/file" "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/" ) // 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 NoDefaultDomain argparser.OptionalBool 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("no-default-domain", "Skip default domain creation").Action(c.NoDefaultDomain.Set).BoolVar(&c.NoDefaultDomain.Value) 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) 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, 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, NoDefaultDomain: c.NoDefaultDomain.WasSet, 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 && serviceURL != "" { 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)) if serviceURL != "" { 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. func (c *DeployCommand) Setup(out io.Writer) (serviceID string, err error) { _, s := c.Globals.Token() if s == lookup.SourceUndefined { return "", 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 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 serviceID, err } return 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) // Extract archive using shared utility if err = file.ExtractArchive(packageFlag, dst, nil); 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 } // NewService handles creating a new service when no Service ID is found. func (c *DeployCommand) NewService(manifestFilename string, 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, 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. func createService( g *global.Data, serviceName string, 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(context.TODO(), &fastly.CreateServiceInput{ Name: &serviceName, Type: fastly.ToPointer("wasm"), }) if err != nil { 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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &fastly.ListDomainsInput{ ServiceID: serviceID, ServiceVersion: serviceVersion, }) if err != nil { return "", err } if len(latestDomains) == 0 { return "", nil } 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(context.TODO(), &fastly.GetServiceDetailsInput{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.", } } 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(context.TODO(), &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) } ================================================ FILE: pkg/commands/compute/deploy_test.go ================================================ package compute_test import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "strings" "testing" "time" "github.com/fastly/go-fastly/v15/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 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: file not found", 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, GetServiceDetailsFn: getServiceDetailsWasmNoActive, 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, GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOk, }, 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, GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOk, }, 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()), }, { 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, GetVersionFn: testutil.GetVersion, 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{ "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, GetVersionFn: testutil.GetVersion, GetPackageFn: getPackageOk, GetServiceDetailsFn: getServiceDetailsWasm, GetServiceFn: getServiceOK, 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, }, 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, GetVersionFn: testutil.GetVersion, 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{ "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, GetVersionFn: testutil.GetVersion, 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{ "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, 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, GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOk, 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)", }, }, { name: "success with --no-default-domain flag for new service", args: args("compute deploy --no-default-domain --token 123"), api: mock.API{ ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, CreateServiceFn: createServiceOK, DeleteServiceFn: deleteServiceOK, GetPackageFn: getPackageOk, ListDomainsFn: listDomainsNone, UpdatePackageFn: updatePackageOk, }, stdin: []string{ "Y", // when prompted to create a new service }, wantOutput: []string{ "Deployed package (service 12345, version 1)", }, dontWantOutput: []string{ "Creating domain", "Domain:", }, }, { name: "success with --no-default-domain but explicit --domain provided", args: args("compute deploy --token 123 --no-default-domain --domain example.com"), api: mock.API{ ActivateVersionFn: activateVersionOk, CreateBackendFn: createBackendOK, CreateDomainFn: createDomainOK, CreateServiceFn: createServiceOK, GetPackageFn: getPackageOk, ListDomainsFn: listDomainsNone, 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 domain 'example.com'", "Deployed package (service 12345, version 1)", }, }, { name: "success with --no-default-domain and existing service", args: args("compute deploy --service-id 123 --token 123 --no-default-domain"), api: mock.API{ ActivateVersionFn: activateVersionOk, CloneVersionFn: testutil.CloneVersionResult(4), GetPackageFn: getPackageOk, GetServiceDetailsFn: getServiceDetailsWasm, GetServiceFn: getServiceOK, ListDomainsFn: listDomainsOk, ListVersionsFn: testutil.ListVersions, UpdatePackageFn: updatePackageOk, }, wantOutput: []string{ "Uploading package", "Activating service", "Deployed package (service 123, version 4)", }, dontWantOutput: []string{ "Creating domain", }, }, // 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, }, 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, 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, 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, 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, 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) { // Clear FASTLY_SERVICE_ID for tests that create a new service if testcase.api.CreateServiceFn != nil { t.Setenv("FASTLY_SERVICE_ID", "") } // 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(_ context.Context, _ *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, GetVersionFn: testutil.GetVersion, 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(_ context.Context, i *fastly.CreateServiceInput) (*fastly.Service, error) { return &fastly.Service{ ServiceID: fastly.ToPointer("12345"), Name: i.Name, Type: i.Type, }, nil } func createServiceError(_ context.Context, _ *fastly.CreateServiceInput) (*fastly.Service, error) { return nil, testutil.Err } func deleteServiceOK(_ context.Context, _ *fastly.DeleteServiceInput) error { return nil } func createDomainError(_ context.Context, _ *fastly.CreateDomainInput) (*fastly.Domain, error) { return nil, testutil.Err } func deleteDomainOK(_ context.Context, _ *fastly.DeleteDomainInput) error { return nil } func createBackendError(_ context.Context, _ *fastly.CreateBackendInput) (*fastly.Backend, error) { return nil, testutil.Err } func deleteBackendOK(_ context.Context, _ *fastly.DeleteBackendInput) error { return nil } func getPackageIdentical(_ context.Context, 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(_ context.Context, _ *fastly.ActivateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func listDomainsError(_ context.Context, _ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { return nil, testutil.Err } func listDomainsNone(_ context.Context, _ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { return []*fastly.Domain{}, nil } ================================================ FILE: pkg/commands/compute/dir.go ================================================ 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 } ================================================ FILE: pkg/commands/compute/doc.go ================================================ // Package compute contains commands to manage Compute packages. package compute ================================================ FILE: pkg/commands/compute/hashfiles.go ================================================ package compute import ( "bytes" "crypto/sha512" "errors" "fmt" "io" "os" "path/filepath" "sort" "github.com/kennygrant/sanitize" "github.com/mholt/archives" "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 archives.FileInfo) error { entry := f.NameInArchive contents[entry] = &bytes.Buffer{} rc, err := f.Open() if err != nil { return fmt.Errorf("error opening %s: %w", entry, err) } defer rc.Close() if _, err := io.Copy(contents[entry], rc); 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 } ================================================ FILE: pkg/commands/compute/init.go ================================================ package compute import ( "context" "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/v15/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/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", "cpp", "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 auth token email if available. email := "" if _, at := c.Globals.Config.GetDefaultAuthToken(); at != nil && at.Email != "" { email = at.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(context.TODO(), &fastly.GetServiceDetailsInput{ 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(context.TODO(), &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(context.TODO(), &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)) } ================================================ FILE: pkg/commands/compute/init_test.go ================================================ package compute_test import ( "context" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/fastly/go-fastly/v15/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", }, } skCPP := []config.StarterKit{ { Name: "Default", Path: "https://github.com/fastly/compute-starter-kit-cpp-default", Branch: "main", }, { Name: "Empty", Path: "https://github.com/fastly/compute-starter-kit-cpp-empty", 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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Email: "test@example.com", }, "non_default": &config.AuthToken{ Type: config.AuthTokenTypeStatic, 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`, }, { name: "with C++ language", args: args("compute init --language cpp"), configFile: config.File{ StarterKits: config.StarterKitLanguages{ CPP: skCPP, }, }, manifestIncludes: `name = "fastly-temp`, }, { name: "with --from set to C++ empty starter kit", args: args("compute init --from https://github.com/fastly/compute-starter-kit-cpp-empty"), configFile: config.File{ StarterKits: config.StarterKitLanguages{ CPP: skCPP, }, }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", "SUCCESS: Initialized package", }, }, // 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 threadsafe.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(context.Context, *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) getPackage func(context.Context, *fastly.GetPackageInput) (*fastly.Package, error) expectInOutput []string expectInManifest []string expectNoManifest bool expectInError string suppressBeacon bool }{ { name: "when the service exists", args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), getServiceDetails: func(_ context.Context, gsi *fastly.GetServiceDetailsInput) (*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(_ context.Context, 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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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 suppressBeacon: true, }, { name: "service has a cloned_from value", args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), getServiceDetails: func(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *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.suppressBeacon { 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) } } }) } } ================================================ FILE: pkg/commands/compute/language.go ================================================ 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: "cpp", DisplayName: "C++", StarterKits: kits.CPP, }), 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 } ================================================ FILE: pkg/commands/compute/language_assemblyscript.go ================================================ 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("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 } ================================================ FILE: pkg/commands/compute/language_cpp.go ================================================ package compute import ( "fmt" "io" "os/exec" "regexp" "strings" "github.com/Masterminds/semver/v3" "github.com/fastly/cli/pkg/config" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/text" ) // CPPDefaultBuildCommand is a build command compiled into the CLI binary so it // can be used as a fallback for customers 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 CPPDefaultBuildCommand = "clang++ -O3 --target=%s -o %s main.cpp" // CPPDefaultWasmWasiTarget is the expected C++ WasmWasi build target. const CPPDefaultWasmWasiTarget = "wasm32-wasip1" // CPPSourceDirectory represents the source code directory. const CPPSourceDirectory = "." // NewCPP constructs a new C++ toolchain. func NewCPP( c *BuildCommand, in io.Reader, manifestFilename string, out io.Writer, spinner text.Spinner, ) *CPP { return &CPP{ Shell: Shell{}, autoYes: c.Globals.Flags.AutoYes, build: c.Globals.Manifest.File.Scripts.Build, config: c.Globals.Config.Language.CPP, 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(), } } // CPP implements a Toolchain for the C++ language. type CPP 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 C++ specific application configuration. config config.CPP // 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 user's 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 (cpp *CPP) DefaultBuildScript() bool { return cpp.defaultBuild } // Dependencies returns all dependencies used by the project. func (cpp *CPP) Dependencies() map[string]string { // For C++, dependencies are typically managed through various systems // (CMake, Conan, vcpkg, etc.). For now, return an empty map. // This could be extended in the future to parse CMakeLists.txt or other files. return make(map[string]string) } // Build compiles the user's source code into a Wasm binary. func (cpp *CPP) Build() error { if cpp.build == "" { cpp.build = fmt.Sprintf(CPPDefaultBuildCommand, CPPDefaultWasmWasiTarget, binWasmPath) cpp.defaultBuild = true if !cpp.verbose { text.Break(cpp.output) } text.Info(cpp.output, "No [scripts.build] found in %s. Visit https://www.fastly.com/documentation/guides/compute/ to learn more about building C++ projects.\n\n", cpp.manifestFilename) text.Description(cpp.output, "The following default build command for C++ will be used", cpp.build) } cpp.toolchainConstraint( "clang++", `clang version (?P\d+\.\d+\.\d+)`, cpp.config.ToolchainConstraint, ) wasmWasiTarget := cpp.config.WasmWasiTarget if wasmWasiTarget != "" && wasmWasiTarget != CPPDefaultWasmWasiTarget { return fmt.Errorf("the default build in .fastly/config.toml should produce a %s binary, but was instead set to produce a %s binary", CPPDefaultWasmWasiTarget, wasmWasiTarget) } bt := BuildToolchain{ autoYes: cpp.autoYes, buildFn: cpp.Shell.Build, buildScript: cpp.build, env: cpp.env, errlog: cpp.errlog, in: cpp.input, manifestFilename: cpp.manifestFilename, metadataFilterEnvVars: cpp.metadataFilterEnvVars, nonInteractive: cpp.nonInteractive, out: cpp.output, postBuild: cpp.postBuild, spinner: cpp.spinner, timeout: cpp.timeout, verbose: cpp.verbose, } return bt.Build() } // 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 (cpp *CPP) toolchainConstraint(toolchain, pattern, constraint string) { if constraint == "" { return } if cpp.verbose { text.Info(cpp.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(cpp.output, "The %s version requirement was not satisfied: %v", toolchain, errs) } } ================================================ FILE: pkg/commands/compute/language_go.go ================================================ 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...)) } } ================================================ FILE: pkg/commands/compute/language_javascript.go ================================================ package compute import ( "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" 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) // BunDefaultBuildCommand is the default build command when Bun is the detected runtime. var BunDefaultBuildCommand = fmt.Sprintf("bunx js-compute-runtime ./src/index.js %s", binWasmPath) // JsSourceDirectory represents the source code directory. const JsSourceDirectory = "src" // ErrNpmMissing is returned when Node.js is found but npm is not installed. var ErrNpmMissing = errors.New("node found but npm missing") // ErrNoJSRuntime is returned when neither node nor bun can be found on PATH. var ErrNoJSRuntime = errors.New("no JavaScript runtime found (node or bun)") // ErrPackageJSONMissing is returned when the project has no package.json. var ErrPackageJSONMissing = errors.New("package.json not found") // ErrNodeModulesMissing is returned when node_modules has not been installed. var ErrNodeModulesMissing = errors.New("node_modules directory not found - dependencies not installed") // ErrJsComputeMissing is returned when @fastly/js-compute is not installed. var ErrJsComputeMissing = errors.New("@fastly/js-compute package not found") // JSRuntime represents a detected JavaScript runtime. type JSRuntime struct { // Name is the runtime name (node or bun). Name string // Version is the runtime version string. Version string // PkgMgr is the package manager to use (npm or bun). PkgMgr string } // 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 // nodeModulesDirs is the set of node_modules directories found walking up the tree. // Supports monorepo/hoisted setups where dependencies may be split across levels. nodeModulesDirs []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 // runtime is the detected JavaScript runtime (node or bun). runtime *JSRuntime // 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 } // isDefaultBuildScript reports whether the configured build script is the // well-known default used by Fastly starter kits (e.g. "npm run build" or // "bun run build"). Leading "KEY=value" environment-variable assignments are // tolerated so values like "NODE_ENV=production npm run build" still match. // These scripts delegate to the same toolchain that the CLI would invoke // directly, so the same verification logic applies. func (j *JavaScript) isDefaultBuildScript() bool { tokens := strings.Fields(j.build) for len(tokens) > 0 && isEnvAssignment(tokens[0]) { tokens = tokens[1:] } if len(tokens) != 3 || tokens[1] != "run" || tokens[2] != "build" { return false } return tokens[0] == "npm" || tokens[0] == "bun" } // isEnvAssignment reports whether a token looks like a shell environment-variable // assignment (NAME=value). Only the name portion is validated; the value may // contain any characters, including path separators (e.g. PATH=./node_modules/.bin:$PATH). func isEnvAssignment(token string) bool { name, _, ok := strings.Cut(token, "=") if !ok || name == "" { return false } for i, r := range name { switch { case r == '_': case r >= 'A' && r <= 'Z': case r >= 'a' && r <= 'z': case i > 0 && r >= '0' && r <= '9': default: return false } } return true } // Build compiles the user's source code into a Wasm binary. func (j *JavaScript) Build() error { if j.build == "" { if err := j.verifyToolchain(); err != nil { return err } j.build = j.getDefaultBuildCommand() j.defaultBuild = true } else if j.isDefaultBuildScript() { if err := j.verifyToolchain(); err != nil { return err } } 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() } // 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) path = filepath.Join(wd, filename) _, statErr := os.Stat(path) switch { case statErr == nil: return true, path, nil case !errors.Is(statErr, os.ErrNotExist): return false, "", statErr } 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"` } // checkBun checks if Bun is installed and returns runtime info. func (j *JavaScript) checkBun() (*JSRuntime, error) { if _, err := exec.LookPath("bun"); err != nil { return nil, err } cmd := exec.Command("bun", "--version") output, err := cmd.CombinedOutput() if err != nil { return nil, err } return &JSRuntime{ Name: "bun", Version: strings.TrimSpace(string(output)), PkgMgr: "bun", }, nil } // checkNode checks if Node.js and npm are installed and returns runtime info. func (j *JavaScript) checkNode() (*JSRuntime, error) { if _, err := exec.LookPath("node"); err != nil { return nil, err } if _, err := exec.LookPath("npm"); err != nil { return nil, ErrNpmMissing } nodeCmd := exec.Command("node", "--version") nodeOutput, err := nodeCmd.CombinedOutput() if err != nil { return nil, err } return &JSRuntime{ Name: "node", Version: strings.TrimSpace(string(nodeOutput)), PkgMgr: "npm", }, nil } // detectProjectRuntime checks lockfiles to determine which runtime the project // uses. It searches from package.json upward so workspace setups (lockfile at // the workspace root, package.json in a subpackage) are detected. A bun.lockb // counts only when it sits beside a package.json, which avoids picking up // unrelated lockfiles in parent directories. Returns "bun" if a Bun project // is detected, "node" otherwise. func (j *JavaScript) detectProjectRuntime() (string, error) { wd, err := os.Getwd() if err != nil { return "", err } home, err := os.UserHomeDir() if err != nil { return "", err } found, pkgPath, err := search("package.json", wd, home) if err != nil { return "", err } if !found { return "node", nil } dir := filepath.Dir(pkgPath) for { hasBunLock := false for _, lockfile := range []string{"bun.lockb", "bun.lock"} { if _, err := os.Stat(filepath.Join(dir, lockfile)); err == nil { hasBunLock = true break } } if hasBunLock { if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil { return "bun", nil } } parent := filepath.Dir(dir) if parent == dir || dir == home { break } dir = parent } return "node", nil } // detectRuntime checks for available JavaScript runtimes. // Respects the project's lockfile to determine preferred runtime. func (j *JavaScript) detectRuntime() (*JSRuntime, error) { projectRuntime, err := j.detectProjectRuntime() if err != nil { return nil, err } var nodeErr, bunErr error var nodeRuntime, bunRuntime *JSRuntime bunRuntime, bunErr = j.checkBun() nodeRuntime, nodeErr = j.checkNode() if j.verbose && bunRuntime == nil && bunErr != nil && !errors.Is(bunErr, exec.ErrNotFound) { text.Warning(j.output, "Bun was found on PATH but could not be queried: %v\n", bunErr) } // Use project's preferred runtime if available if projectRuntime == "bun" && bunRuntime != nil { if j.verbose { text.Info(j.output, "Found Bun %s (bun.lockb detected)\n", bunRuntime.Version) } return bunRuntime, nil } if projectRuntime == "node" && nodeRuntime != nil { if j.verbose { text.Info(j.output, "Found Node.js %s with npm\n", nodeRuntime.Version) } return nodeRuntime, nil } // Fall back to any available runtime if nodeRuntime != nil { if j.verbose { text.Info(j.output, "Found Node.js %s with npm\n", nodeRuntime.Version) } return nodeRuntime, nil } if bunRuntime != nil { if j.verbose { text.Info(j.output, "Found Bun %s\n", bunRuntime.Version) } return bunRuntime, nil } if errors.Is(nodeErr, ErrNpmMissing) { return nil, fsterr.RemediationError{ Inner: nodeErr, Remediation: `Node.js is installed but npm is missing. Install npm (usually bundled with Node.js): - Reinstall Node.js from https://nodejs.org/ - Or install npm separately: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm Verify: npm --version Then retry the build.`, } } return nil, fsterr.RemediationError{ Inner: ErrNoJSRuntime, Remediation: `A JavaScript runtime is required to build Compute applications. Install one of the following: Option 1 - Node.js: Install from https://nodejs.org/ (LTS version recommended) Or use nvm: https://github.com/nvm-sh/nvm Verify: node --version && npm --version Option 2 - Bun: curl -fsSL https://bun.sh/install | bash Verify: bun --version Then retry the build.`, } } // findAllNodeModules collects every node_modules directory from startDir up to // (but not including) the user's home directory. The result is ordered nearest // first, which matches the Node.js module resolution order. func (j *JavaScript) findAllNodeModules(startDir, home string) []string { var dirs []string dir := startDir for { candidate := filepath.Join(dir, "node_modules") if info, err := os.Stat(candidate); err == nil && info.IsDir() { dirs = append(dirs, candidate) } parent := filepath.Dir(dir) if parent == dir || dir == home { break } dir = parent } return dirs } // verifyDependencies checks that package.json and node_modules exist. func (j *JavaScript) verifyDependencies() error { wd, err := os.Getwd() if err != nil { return err } home, err := os.UserHomeDir() if err != nil { return err } found, pkgPath, err := search("package.json", wd, home) if err != nil { return err } if !found { initCmd := "npm init" installCmd := "npm install @fastly/js-compute" if j.runtime != nil && j.runtime.PkgMgr == "bun" { initCmd = "bun init" installCmd = "bun add @fastly/js-compute" } return fsterr.RemediationError{ Inner: ErrPackageJSONMissing, Remediation: fmt.Sprintf(`A package.json file is required for JavaScript Compute projects. Ensure you're in the correct project directory, or use --dir to specify the project root. To initialize a new project: %s %s Then retry the build.`, initCmd, installCmd), } } pkgDir := filepath.Dir(pkgPath) j.nodeModulesDirs = j.findAllNodeModules(pkgDir, home) if len(j.nodeModulesDirs) == 0 { installCmd := "npm install" if j.runtime != nil && j.runtime.PkgMgr == "bun" { installCmd = "bun install" } return fsterr.RemediationError{ Inner: ErrNodeModulesMissing, Remediation: fmt.Sprintf(`Dependencies have not been installed. Run: %s This will install all dependencies from package.json. Then retry the build.`, installCmd), } } if j.verbose { text.Info(j.output, "Found package.json at %s\n", pkgPath) for _, d := range j.nodeModulesDirs { text.Info(j.output, "Found node_modules at %s\n", d) } } return nil } // verifyJsComputeRuntime checks that @fastly/js-compute is installed. func (j *JavaScript) verifyJsComputeRuntime() error { for _, nmDir := range j.nodeModulesDirs { runtimePath := filepath.Join(nmDir, "@fastly", "js-compute") if _, err := os.Stat(runtimePath); err == nil { if j.verbose { text.Info(j.output, "Found @fastly/js-compute runtime in %s\n", nmDir) } return nil } } installCmd := "npm install @fastly/js-compute" if j.runtime != nil && j.runtime.PkgMgr == "bun" { installCmd = "bun add @fastly/js-compute" } return fsterr.RemediationError{ Inner: ErrJsComputeMissing, Remediation: fmt.Sprintf(`The Fastly JavaScript Compute runtime is not installed. Run: %s This package is required to compile JavaScript for Fastly Compute. Then retry the build.`, installCmd), } } // verifyToolchain checks that a JavaScript runtime is installed and accessible. // Called when using the default build script or a well-known starter kit script // (e.g. "npm run build"). func (j *JavaScript) verifyToolchain() error { runtime, err := j.detectRuntime() if err != nil { return err } j.runtime = runtime if err := j.verifyDependencies(); err != nil { return err } if err := j.verifyJsComputeRuntime(); err != nil { return err } return nil } // getDefaultBuildCommand returns the appropriate build command for the detected runtime. func (j *JavaScript) getDefaultBuildCommand() string { if j.runtime != nil && j.runtime.PkgMgr == "bun" { return BunDefaultBuildCommand } return JsDefaultBuildCommand } ================================================ FILE: pkg/commands/compute/language_javascript_test.go ================================================ package compute import ( "bytes" "errors" "os" "path/filepath" "runtime" "testing" fsterr "github.com/fastly/cli/pkg/errors" ) // createFakeRuntime creates a fake executable that outputs the given string. func createFakeRuntime(t *testing.T, dir, name, output string) { t.Helper() var script string if runtime.GOOS == "windows" { script = "@echo off\r\necho " + output name += ".bat" } else { script = "#!/bin/sh\necho '" + output + "'" } path := filepath.Join(dir, name) // G306 (CWE-276): Expect WriteFile permissions to be 0600 or less // Disabling as executables must be executable. // #nosec G306 err := os.WriteFile(path, []byte(script), 0o755) if err != nil { t.Fatal(err) } } func TestJavaScript_detectRuntime_NoRuntime(t *testing.T) { // Create a temp directory with no executables tmpDir := t.TempDir() t.Setenv("PATH", tmpDir) j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } _, err := j.detectRuntime() if err == nil { t.Fatal("expected error when no runtime is found") } // Check it's a RemediationError with helpful message var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T", err) } if re.Remediation == "" { t.Error("expected remediation message") } } func TestJavaScript_detectRuntime_NodeFound(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } if rt.Name != "node" { t.Errorf("expected runtime name 'node', got %q", rt.Name) } if rt.PkgMgr != "npm" { t.Errorf("expected package manager 'npm', got %q", rt.PkgMgr) } } func TestJavaScript_detectRuntime_BunFound(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") t.Setenv("PATH", tmpDir) j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } if rt.Name != "bun" { t.Errorf("expected runtime name 'bun', got %q", rt.Name) } if rt.PkgMgr != "bun" { t.Errorf("expected package manager 'bun', got %q", rt.PkgMgr) } } func TestJavaScript_detectRuntime_NodePreferredByDefault(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) // Create project dir without bun.lockb (npm project) projectDir := t.TempDir() originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(projectDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } // Node should be preferred by default (no bun.lockb) if rt.Name != "node" { t.Errorf("expected runtime name 'node' (default), got %q", rt.Name) } } func TestJavaScript_detectRuntime_BunPreferredWithLockfile(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) // Create project dir with package.json and bun.lockb (bun project) projectDir := t.TempDir() // #nosec G306 if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { t.Fatal(err) } // #nosec G306 if err := os.WriteFile(filepath.Join(projectDir, "bun.lockb"), []byte{}, 0o644); err != nil { t.Fatal(err) } originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(projectDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } // Bun should be used when bun.lockb exists alongside package.json if rt.Name != "bun" { t.Errorf("expected runtime name 'bun' (bun.lockb detected), got %q", rt.Name) } } func TestJavaScript_detectRuntime_BunLockfileInParentDir(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) // Create project structure: projectDir/subdir with package.json and bun.lockb in projectDir projectDir := t.TempDir() subDir := filepath.Join(projectDir, "subdir") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatal(err) } // #nosec G306 if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { t.Fatal(err) } // #nosec G306 if err := os.WriteFile(filepath.Join(projectDir, "bun.lockb"), []byte{}, 0o644); err != nil { t.Fatal(err) } // Run from subdir - should detect bun.lockb alongside package.json in parent originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(subDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } // Bun should be detected from project root (where package.json is) if rt.Name != "bun" { t.Errorf("expected runtime name 'bun' (bun.lockb with package.json), got %q", rt.Name) } } func TestJavaScript_detectRuntime_BunWorkspace(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) // Create Bun workspace structure: // workspace/package.json (workspace root) // workspace/bun.lockb // workspace/packages/myapp/package.json (subpackage - we run from here) workspaceDir := t.TempDir() subpkgDir := filepath.Join(workspaceDir, "packages", "myapp") if err := os.MkdirAll(subpkgDir, 0o755); err != nil { t.Fatal(err) } // Workspace root package.json // #nosec G306 if err := os.WriteFile(filepath.Join(workspaceDir, "package.json"), []byte(`{"workspaces":["packages/*"]}`), 0o644); err != nil { t.Fatal(err) } // #nosec G306 if err := os.WriteFile(filepath.Join(workspaceDir, "bun.lockb"), []byte{}, 0o644); err != nil { t.Fatal(err) } // Subpackage package.json // #nosec G306 if err := os.WriteFile(filepath.Join(subpkgDir, "package.json"), []byte(`{"name":"myapp"}`), 0o644); err != nil { t.Fatal(err) } // Run from subpackage originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(subpkgDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } // Bun should be detected from workspace root (bun.lockb + package.json) if rt.Name != "bun" { t.Errorf("expected runtime name 'bun' (workspace detected), got %q", rt.Name) } } func TestJavaScript_detectRuntime_IgnoresUnrelatedBunLockfile(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "bun", "1.3.7") createFakeRuntime(t, tmpDir, "node", "v24.13.0") createFakeRuntime(t, tmpDir, "npm", "11.7.0") t.Setenv("PATH", tmpDir) // Create structure: parentDir/bun.lockb (unrelated) and parentDir/project/package.json (npm project) parentDir := t.TempDir() projectDir := filepath.Join(parentDir, "project") if err := os.MkdirAll(projectDir, 0o755); err != nil { t.Fatal(err) } // Unrelated bun.lockb in parent (not alongside package.json) // #nosec G306 if err := os.WriteFile(filepath.Join(parentDir, "bun.lockb"), []byte{}, 0o644); err != nil { t.Fatal(err) } // Project's package.json (no bun.lockb here) // #nosec G306 if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{}`), 0o644); err != nil { t.Fatal(err) } originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(projectDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } rt, err := j.detectRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } // Should use Node because project root has no bun.lockb (parent's is unrelated) if rt.Name != "node" { t.Errorf("expected runtime name 'node' (unrelated bun.lockb ignored), got %q", rt.Name) } } func TestJavaScript_detectRuntime_NodeMissingNpm(t *testing.T) { tmpDir := t.TempDir() createFakeRuntime(t, tmpDir, "node", "v24.13.0") // npm is NOT created t.Setenv("PATH", tmpDir) j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, } _, err := j.detectRuntime() if err == nil { t.Fatal("expected error when npm is missing") } // Check for specific error message var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T", err) } if !errors.Is(re.Inner, ErrNpmMissing) { t.Errorf("expected ErrNpmMissing, got %v", re.Inner) } } func TestJavaScript_findAllNodeModules(t *testing.T) { // Create directory structure: // tmpDir/project/node_modules (parent) // tmpDir/project/subdir/node_modules (child) tmpDir := t.TempDir() projectDir := filepath.Join(tmpDir, "project") subDir := filepath.Join(projectDir, "subdir") parentNM := filepath.Join(projectDir, "node_modules") childNM := filepath.Join(subDir, "node_modules") if err := os.MkdirAll(childNM, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(parentNM, 0o755); err != nil { t.Fatal(err) } j := &JavaScript{} // From subDir should find both, nearest first dirs := j.findAllNodeModules(subDir, tmpDir) if len(dirs) != 2 { t.Fatalf("expected 2 node_modules dirs, got %d: %v", len(dirs), dirs) } if dirs[0] != childNM { t.Errorf("expected first dir %q, got %q", childNM, dirs[0]) } if dirs[1] != parentNM { t.Errorf("expected second dir %q, got %q", parentNM, dirs[1]) } // From projectDir should find only one dirs = j.findAllNodeModules(projectDir, tmpDir) if len(dirs) != 1 { t.Fatalf("expected 1 node_modules dir, got %d: %v", len(dirs), dirs) } if dirs[0] != parentNM { t.Errorf("expected path %q, got %q", parentNM, dirs[0]) } // Should not find node_modules above home dirs = j.findAllNodeModules(tmpDir, tmpDir) if len(dirs) != 0 { t.Errorf("expected no node_modules dirs, got %v", dirs) } } func TestJavaScript_verifyDependencies_NoPackageJson(t *testing.T) { tmpDir := t.TempDir() binDir := t.TempDir() createFakeRuntime(t, binDir, "node", "v24.13.0") createFakeRuntime(t, binDir, "npm", "11.7.0") t.Setenv("PATH", binDir) // Change to temp dir with no package.json originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } err := j.verifyDependencies() if err == nil { t.Fatal("expected error when package.json not found") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T", err) } } func TestJavaScript_verifyDependencies_NoNodeModules(t *testing.T) { tmpDir := t.TempDir() binDir := t.TempDir() createFakeRuntime(t, binDir, "node", "v24.13.0") createFakeRuntime(t, binDir, "npm", "11.7.0") t.Setenv("PATH", binDir) // Create package.json but no node_modules // #nosec G306 if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0o644); err != nil { t.Fatal(err) } originalWd, _ := os.Getwd() defer func() { _ = os.Chdir(originalWd) }() if err := os.Chdir(tmpDir); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } err := j.verifyDependencies() if err == nil { t.Fatal("expected error when node_modules not found") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T", err) } } func TestJavaScript_verifyJsComputeRuntime_NotInstalled(t *testing.T) { tmpDir := t.TempDir() nodeModulesDir := filepath.Join(tmpDir, "node_modules") if err := os.MkdirAll(nodeModulesDir, 0o755); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, nodeModulesDirs: []string{nodeModulesDir}, runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } err := j.verifyJsComputeRuntime() if err == nil { t.Fatal("expected error when @fastly/js-compute not found") } var re fsterr.RemediationError if !errors.As(err, &re) { t.Fatalf("expected RemediationError, got %T", err) } } func TestJavaScript_verifyJsComputeRuntime_Installed(t *testing.T) { tmpDir := t.TempDir() nodeModulesDir := filepath.Join(tmpDir, "node_modules") runtimeDir := filepath.Join(nodeModulesDir, "@fastly", "js-compute") if err := os.MkdirAll(runtimeDir, 0o755); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, nodeModulesDirs: []string{nodeModulesDir}, runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } err := j.verifyJsComputeRuntime() if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestJavaScript_verifyJsComputeRuntime_InParentNodeModules(t *testing.T) { // Monorepo: @fastly/js-compute is hoisted to root node_modules tmpDir := t.TempDir() rootNM := filepath.Join(tmpDir, "node_modules") childNM := filepath.Join(tmpDir, "app", "node_modules") runtimeDir := filepath.Join(rootNM, "@fastly", "js-compute") if err := os.MkdirAll(runtimeDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(childNM, 0o755); err != nil { t.Fatal(err) } j := &JavaScript{ output: &bytes.Buffer{}, verbose: false, nodeModulesDirs: []string{childNM, rootNM}, runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } err := j.verifyJsComputeRuntime() if err != nil { t.Fatalf("expected to find @fastly/js-compute in parent node_modules: %v", err) } } func TestJavaScript_isDefaultBuildScript(t *testing.T) { tests := []struct { build string want bool }{ {"npm run build", true}, {"bun run build", true}, {" npm run build ", true}, {"NODE_ENV=production npm run build", true}, {"PATH=./node_modules/.bin:$PATH npm run build", true}, {"NODE_ENV=production FOO=bar bun run build", true}, {"", false}, {"custom-build-cmd", false}, {"npm run build && echo done", false}, {"=value npm run build", false}, {"./scripts/build npm run build", false}, } for _, tt := range tests { j := &JavaScript{build: tt.build} if got := j.isDefaultBuildScript(); got != tt.want { t.Errorf("isDefaultBuildScript() with build=%q: got %v, want %v", tt.build, got, tt.want) } } } func TestJavaScript_getDefaultBuildCommand_Node(t *testing.T) { j := &JavaScript{ runtime: &JSRuntime{Name: "node", PkgMgr: "npm"}, } cmd := j.getDefaultBuildCommand() if cmd != JsDefaultBuildCommand { t.Errorf("expected default command, got %q", cmd) } } func TestJavaScript_getDefaultBuildCommand_Bun(t *testing.T) { j := &JavaScript{ runtime: &JSRuntime{Name: "bun", PkgMgr: "bun"}, } cmd := j.getDefaultBuildCommand() if cmd == JsDefaultBuildCommand { t.Errorf("expected bun command, got npm command %q", cmd) } if !bytes.Contains([]byte(cmd), []byte("bunx")) { t.Errorf("expected command to contain 'bunx', got %q", cmd) } } ================================================ FILE: pkg/commands/compute/language_other.go ================================================ 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() } ================================================ FILE: pkg/commands/compute/language_rust.go ================================================ 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) } // Even though most users shouldn't be using Rust prereleases, it is // useful for Fastly to be able to test with Rust prereleases, so we // shouldn't outright prohibit them. c.IncludePrerelease = true 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 an 'exact version' constraint was // violated, generate an error message // specific to that situation if strings.Contains(e.Error(), "is equal to") { err = fmt.Errorf("version '%s' of Rust is not compatible 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"` } ================================================ FILE: pkg/commands/compute/language_toolchain.go ================================================ 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 } ================================================ FILE: pkg/commands/compute/metadata.go ================================================ 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 } ================================================ FILE: pkg/commands/compute/metadata_test.go ================================================ 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) } ================================================ FILE: pkg/commands/compute/pack.go ================================================ package compute import ( "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/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) } { dir := fmt.Sprintf("pkg/%s", filename) dst := fmt.Sprintf("%s.tar.gz", dir) if err = createTarGz(dir, dst); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Path (absolute)": dir, "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 { dir := "pkg/package" dst := fmt.Sprintf("%s.tar.gz", dir) if err = createTarGz(dir, dst); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Tar source": dir, "Tar destination": dst, }) return err } return nil }) } ================================================ FILE: pkg/commands/compute/pack_test.go ================================================ 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) } } }) } } ================================================ FILE: pkg/commands/compute/publish.go ================================================ 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 noDefaultDomain argparser.OptionalBool 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("no-default-domain", "Skip default domain creation").Action(c.noDefaultDomain.Set).BoolVar(&c.noDefaultDomain.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 an argparser.OptionalServiceNameID } if c.serviceVersion.WasSet { c.deploy.ServiceVersion = c.serviceVersion // deploy's field is an argparser.OptionalServiceVersion } if c.domain.WasSet { c.deploy.Domain = c.domain.Value } if c.env.WasSet { c.deploy.Env = c.env.Value } if c.noDefaultDomain.WasSet { c.deploy.NoDefaultDomain = c.noDefaultDomain } 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) } ================================================ FILE: pkg/commands/compute/pushpin.conf.template ================================================ [global] include={libdir}/internal.conf # directory to save runtime files rundir=%[1]s # prefix for zmq ipc specs ipc_prefix= # port offset for zmq tcp specs and http control server port_offset=0 # TTL (seconds) for connection stats stats_connection_ttl=120 # whether to send individual connection stats stats_connection_send=true [runner] # services to start services=connmgr,proxy,handler # plain HTTP port to listen on for client connections http_port=%[4]d # list of HTTPS ports to listen on for client connections (you must have certs set) #https_ports=443 # list of unix socket paths to listen on for client connections #local_ports={rundir}/{ipc_prefix}server # directory to save log files logdir=%[2]s # logging level. 2 = info, >2 = verbose log_level=2 # client full request header must fit in this buffer client_buffer_size=8192 # maximum number of client connections client_maxconn=50000 # whether connections can use compression allow_compression=false # paths # mongrel2_bin=mongrel2 # m2sh_bin=m2sh # zurl_bin=zurl [proxy] # routes config file (path relative to location of this file) routesfile=%[3]s # enable debug mode to get informative error responses debug=false # whether to use automatic CORS and JSON-P wrapping auto_cross_origin=false # whether to accept x-forwarded-proto accept_x_forwarded_protocol=false # whether to assert x-forwarded-proto set_x_forwarded_protocol=proto-only # how to treat x-forwarded-for. example: "truncate:0,append" x_forwarded_for= # how to treat x-forwarded-for if grip-signed x_forwarded_for_trusted= # the following headers must be marked in order to qualify as orig orig_headers_need_mark= # whether to accept Pushpin-Route header accept_pushpin_route=true # value to append to the CDN-Loop header cdn_loop= # include client IP address in logs log_from=false # include client user agent in logs log_user_agent=false # for signing proxied requests sig_iss=viceroy # for signing proxied requests. use "base64:" prefix for binary key sig_key=viceroy_dev_signing_key_dont_use_in_production # use this to allow grip to be forwarded upstream (e.g. to fanout.io) upstream_key= # for the sockjs iframe transport sockjs_url=http://cdn.jsdelivr.net/sockjs/0.3.4/sockjs.min.js # updates check has three modes: # report: check for new pushpin version and report anonymous usage info to # the pushpin developers # check: check for new pushpin version only, don't report anything # off: don't do any reporting or checking # pushpin will output a log message when a new version is available. report # mode helps the pushpin project build credibility, so please enable it if you # enjoy this software :) updates_check=report # use this field to identify your organization in updates requests. if left # blank, updates requests will be anonymous organization_name= [handler] # ipc permissions (octal) #ipc_file_mode=777 # bind PULL for receiving publish commands push_in_spec=tcp://127.0.0.1:%[6]d # list of bind SUB for receiving published messages push_in_sub_specs=tcp://127.0.0.1:%[7]d # whether the above SUB socket should connect instead of bind push_in_sub_connect=false # addr/port to listen on for receiving publish commands via HTTP push_in_http_addr=0.0.0.0 push_in_http_port=%[5]d # maximum headers and body size in bytes when receiving publish commands via HTTP push_in_http_max_headers_size=8192 push_in_http_max_body_size=65536 # bind PUB for sending stats (metrics, subscription info, etc) stats_spec=ipc://{rundir}/{ipc_prefix}stats # bind REP for responding to commands command_spec=tcp://127.0.0.1:%[8]d # max messages per second message_rate=2500 # max rate-limited messages message_hwm=25000 # set to report blocks counts in stats (content size / block size) #message_block_size= # max time (milliseconds) for out-of-order messages to wait message_wait=5000 # time (seconds) to cache message ids id_cache_ttl=60 # retry/recover sessions soon after the first subscription to a channel update_on_first_subscription=true # max subscriptions per connection connection_subscription_max=20 # time (seconds) to linger response mode subscriptions subscription_linger=60 # TTL (seconds) for subscription stats stats_subscription_ttl=60 # interval (seconds) to send report stats stats_report_interval=10 # stats output format stats_format=tnetstring ================================================ FILE: pkg/commands/compute/root.go ================================================ 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") } ================================================ FILE: pkg/commands/compute/secrets.go ================================================ 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 } ================================================ FILE: pkg/commands/compute/serve.go ================================================ package compute import ( "bufio" "bytes" "context" "crypto/rand" _ "embed" "encoding/binary" "errors" "fmt" "io" "io/fs" "log" "net" "net/url" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "strings" "sync" "syscall" "time" "github.com/bep/debounce" "github.com/blang/semver" "github.com/fatih/color" "github.com/fsnotify/fsnotify" "github.com/mitchellh/go-ps" 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 enablePushpin bool pushpinRunnerBinPath string pushpinProxyPort string pushpinPublishPort string 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("experimental-enable-pushpin", "Enable experimental Pushpin support for local testing of Fanout").BoolVar(&c.enablePushpin) c.CmdClause.Flag("pushpin-path", "The path to a user installed version of the Pushpin runner binary").StringVar(&c.pushpinRunnerBinPath) c.CmdClause.Flag("pushpin-proxy-port", "The port to run the Pushpin runner on. Overrides 'local_server.pushpin.proxy_port' from 'fastly.toml', and if not specified there, defaults to 7677.").StringVar(&c.pushpinProxyPort) c.CmdClause.Flag("pushpin-publish-port", "The port to run the Pushpin publish handler on. Overrides 'local_server.pushpin.publish_port' from 'fastly.toml', and if not specified there, defaults to 5561.").StringVar(&c.pushpinPublishPort) 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 } enablePushpin := c.enablePushpin || (c.Globals.Manifest.File.LocalServer.Pushpin != nil && c.Globals.Manifest.File.LocalServer.Pushpin.EnablePushpin != nil && *c.Globals.Manifest.File.LocalServer.Pushpin.EnablePushpin) var pushpinCtx pushpinContext if enablePushpin { // The function checks for nil, so the semgrep warning is falsely triggered // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable pushpinCtx, err = c.startPushpin(spinner, out) if err != nil { pushpinCtx.Close() return err } defer pushpinCtx.Close() } 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, pushpinProxyPort: pushpinCtx.proxyPort, 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() } // GetPushpinProxyPort returns the port to run the Pushpin proxy. // // The default value is 7677. // It can be overridden by providing the --pushpin-proxy-port command-line parameter. // If it is not found then `local_server.pushpin.proxy_port` in fastly.toml is also checked. func (c *ServeCommand) GetPushpinProxyPort(out io.Writer) (uint16, error) { pushpinProxyPortStr := c.pushpinProxyPort var pushpinProxyPort uint16 if pushpinProxyPortStr != "" { pushpinProxyPortInt, err := strconv.ParseUint(pushpinProxyPortStr, 10, 16) if err != nil { return 0, fmt.Errorf("can't parse --pushpin-proxy-port value as a number: %s", pushpinProxyPortStr) } if pushpinProxyPortInt < 1 || pushpinProxyPortInt > 65535 { return 0, fmt.Errorf("--pushpin-proxy-port must be a number between 1 and 65535 (got: %d)", pushpinProxyPortInt) } pushpinProxyPort = uint16(pushpinProxyPortInt) if c.Globals.Verbose() { text.Info(out, "Using Pushpin proxy port from --pushpin-proxy-port flag: %d", pushpinProxyPort) } return pushpinProxyPort, nil } if c.Globals.Manifest.File.LocalServer.Pushpin != nil && c.Globals.Manifest.File.LocalServer.Pushpin.PushpinProxyPort != nil { pushpinProxyPort = *c.Globals.Manifest.File.LocalServer.Pushpin.PushpinProxyPort if pushpinProxyPort != 0 { if c.Globals.Verbose() { text.Info(out, "Using Pushpin proxy port via `local_server.pushpin.proxy_port` setting: %d", pushpinProxyPort) } return pushpinProxyPort, nil } } pushpinProxyPort = 7677 if c.Globals.Verbose() { text.Info(out, "Using default Pushpin proxy port %d", pushpinProxyPort) } return pushpinProxyPort, nil } // GetPushpinPublishPort returns the port to run the Pushpin publishing handler. // The design of Pushpin opens four ports starting with this port, though the publishing // handler itself runs on the specified port. // // The default value is 5561. // It can be overridden by providing the --pushpin-publish-port command-line parameter. // If it is not found then `local_server.pushpin.publish_port` in fastly.toml is also checked. func (c *ServeCommand) GetPushpinPublishPort(out io.Writer) (uint16, error) { pushpinPublishPortStr := c.pushpinPublishPort var pushpinPublishPort uint16 if pushpinPublishPortStr != "" { pushpinPublishPortInt, err := strconv.ParseUint(pushpinPublishPortStr, 10, 16) if err != nil { return 0, fmt.Errorf("can't parse --pushpin-publish-port value as a number: %s", pushpinPublishPortStr) } if pushpinPublishPortInt < 1 || pushpinPublishPortInt > 65535 { return 0, fmt.Errorf("--pushpin-publish-port must be a number between 1 and 65535 (got: %d)", pushpinPublishPortInt) } pushpinPublishPort = uint16(pushpinPublishPortInt) if c.Globals.Verbose() { text.Info(out, "Using Pushpin publish handler port from --pushpin-publish-port flag: %d", pushpinPublishPort) } return pushpinPublishPort, nil } if c.Globals.Manifest.File.LocalServer.Pushpin != nil && c.Globals.Manifest.File.LocalServer.Pushpin.PushpinPublishPort != nil { pushpinPublishPort = *c.Globals.Manifest.File.LocalServer.Pushpin.PushpinPublishPort if pushpinPublishPort != 0 { if c.Globals.Verbose() { text.Info(out, "Using Pushpin publish handler port via `local_server.pushpin.publish_port` setting: %d", pushpinPublishPort) } return pushpinPublishPort, nil } } pushpinPublishPort = 5561 if c.Globals.Verbose() { text.Info(out, "Using default Pushpin publish handler port %d", pushpinPublishPort) } return pushpinPublishPort, nil } // GetPushpinRunner returns the path to the installed Pushpin binary. // // This value comes from searching the system path for `pushpin` // It can be overridden by providing the --pushpin-path command-line parameter. // If it is not found then `local_server.pushpin.pushpin_path` in fastly.toml is also checked. func (c *ServeCommand) GetPushpinRunner(out io.Writer) (bin string, err error) { pushpinRunnerBinPath := c.pushpinRunnerBinPath if pushpinRunnerBinPath != "" { if c.Globals.Verbose() { text.Info(out, "Using user provided install of Pushpin runner via --pushpin-path flag: %s", pushpinRunnerBinPath) } return filepath.Abs(pushpinRunnerBinPath) } if c.Globals.Manifest.File.LocalServer.Pushpin != nil && c.Globals.Manifest.File.LocalServer.Pushpin.PushpinPath != nil { pushpinRunnerBinPath = *c.Globals.Manifest.File.LocalServer.Pushpin.PushpinPath if pushpinRunnerBinPath != "" { if c.Globals.Verbose() { text.Info(out, "Using user provided install of Pushpin runner via `local_server.pushpin.pushpin_path` setting: %s", pushpinRunnerBinPath) } return filepath.Abs(pushpinRunnerBinPath) } } if c.Globals.Verbose() { text.Info(out, "No --pushpin-path provided, attempting to find 'pushpin' in your PATH...") } pushpinRunnerBinPath, err = exec.LookPath("pushpin") if err != nil { return "", fsterr.RemediationError{ Inner: fmt.Errorf("failed to find 'pushpin' in your $PATH"), Remediation: "Pushpin support was enabled via --enable-experimental-pushpin, but the 'pushpin' binary could not be found in your $PATH. Please install Pushpin (see: https://pushpin.org/docs/install/) or provide a path to the binary using the --pushpin-path flag.", } } if c.Globals.Verbose() { text.Info(out, "Found Pushpin runner via $PATH lookup: %s", pushpinRunnerBinPath) } return filepath.Abs(pushpinRunnerBinPath) } // BuildPushpinRoutes builds a slice of strings based on the backends // defined in the manifest's backend section. func (c *ServeCommand) BuildPushpinRoutes() []string { var routes []string for name, backend := range c.Globals.Manifest.File.LocalServer.Backends { // The target should be a URL u, err := url.Parse(backend.URL) if err != nil { // This is unlikely as we parse it elsewhere, but good to be safe. // We'll just skip this backend if the URL is invalid. continue } // Route Rule: // 1. `id=`: Match requests whose Pushpin-Route header equals the backend name. rules := fmt.Sprintf("id=%s", name) // 2. A backend may have a path component. If it does, then it will be prepended during forwarding. forwardPrefix := strings.TrimSuffix(u.Path, "/") if forwardPrefix != "" { rules += fmt.Sprintf(",replace_beg=%s", forwardPrefix) } // Target: target := normalizeHost(u) // 1. `over_http`: Enable WebSocket-over-HTTP target += ",over_http" // 2. `ssl`: If backend is https if u.Scheme == "https" { target += ",ssl" } // 3. `host`: If the backend has an override_host. if backend.OverrideHost != "" { target += fmt.Sprintf(",host=%s", backend.OverrideHost) } // The final route format routeArg := fmt.Sprintf("%s %s", rules, target) routes = append(routes, routeArg) } return routes } func normalizeHost(u *url.URL) string { host := u.Host // If Host already has a port, SplitHostPort succeeds // This an attempt at future-proofing as it handles IPv6 if _, _, err := net.SplitHostPort(host); err == nil { return host } switch u.Scheme { case "https": return net.JoinHostPort(host, "443") case "http": return net.JoinHostPort(host, "80") default: // Unknown scheme, leave untouched return host } } func formatPushpinLog(line string) (string, string) { level := "INFO" msg := line if strings.HasPrefix(line, "[ERR]") || strings.HasPrefix(line, "[WARN]") || strings.HasPrefix(line, "[INFO]") || strings.HasPrefix(line, "[DEBUG]") { parts := strings.SplitN(line, " ", 4) if len(parts) == 4 { level = strings.Trim(parts[0], "[]") if level == "ERR" { level = "ERROR" } msg = parts[3] } } // Return as-is if it doesn't match pattern return level, "[Pushpin] " + msg } // pushpinContext contains information about the instance of Pushpin that is // executed when enabled. type pushpinContext struct { instanceID uint32 confFilePath string pushpinRunnerBin string pushpinRunDir string pushpinLogDir string routesFilePath string proxyPort uint16 publishPort uint16 cleanup func() } // Close ends Pushpin if it's running by calling the registered cleanup function. func (c *pushpinContext) Close() { if c.cleanup != nil { c.cleanup() } } // pushpinConfTemplate is a template used by buildPushpinConf. // //go:embed pushpin.conf.template var pushpinConfTemplate string // buildPushpinConf builds a temporary pushpin.conf file that contains everything that covers our needs. func (c *pushpinContext) buildPushpinConf() string { pullPort := c.publishPort + 1 subPort := c.publishPort + 2 repPort := c.publishPort + 3 return fmt.Sprintf( pushpinConfTemplate, c.pushpinRunDir, c.pushpinLogDir, c.routesFilePath, c.proxyPort, c.publishPort, pullPort, subPort, repPort, ) } // startPushpin starts Pushpin based on the configuration provided by the // command line and/or fastly.toml. The cleanup function on the returned pushpinContext // needs to eventually be called by the caller to shut down Pushpin. func (c *ServeCommand) startPushpin(spinner text.Spinner, out io.Writer) (pushpinContext, error) { text.Info(out, "Enabling experimental Pushpin support for local testing of Fanout.") pushpinCtx := pushpinContext{} // Generate a non-zero instance ID to represent this Pushpin instance and build temporary // files for { p := make([]byte, 4) _, _ = rand.Read(p) pushpinCtx.instanceID = binary.BigEndian.Uint32(p) if pushpinCtx.instanceID != 0 { break } } var err error pushpinCtx.proxyPort, err = c.GetPushpinProxyPort(out) if err != nil { return pushpinCtx, err } pushpinCtx.publishPort, err = c.GetPushpinPublishPort(out) if err != nil { return pushpinCtx, err } pushpinCtx.pushpinRunnerBin, err = c.GetPushpinRunner(out) if err != nil { return pushpinCtx, err } pwd, _ := os.Getwd() pushpinCtx.pushpinLogDir = filepath.Join(pwd, "pushpin-logs") pushpinCtx.pushpinRunDir = filepath.Join( os.TempDir(), fmt.Sprintf("pushpin-%08x", pushpinCtx.instanceID), ) pushpinCtx.confFilePath = filepath.Join( os.TempDir(), fmt.Sprintf("pushpin-%08x.conf", pushpinCtx.instanceID), ) pushpinCtx.routesFilePath = filepath.Join( os.TempDir(), fmt.Sprintf("pushpin-routes-%08x", pushpinCtx.instanceID), ) text.Break(out) err = spinner.Start() if err != nil { return pushpinCtx, err } msg := "Starting Pushpin" spinner.Message(msg + "...") spinner.StopMessage(msg) err = spinner.Stop() if err != nil { return pushpinCtx, err } pushpinConfContents := pushpinCtx.buildPushpinConf() err = os.WriteFile(pushpinCtx.confFilePath, []byte(pushpinConfContents), 0o600) if err != nil { return pushpinCtx, fmt.Errorf("error writing config file %s: %w", pushpinCtx.confFilePath, err) } pushpinRoutesContents := strings.Join(c.BuildPushpinRoutes(), "\n") + "\n" err = os.WriteFile(pushpinCtx.routesFilePath, []byte(pushpinRoutesContents), 0o600) if err != nil { return pushpinCtx, fmt.Errorf("error writing routes file %s: %w", pushpinCtx.routesFilePath, err) } // Pushpin is configured with the following. // - A conf file that sets up the parameters of the instance. In our case, we: // - set the runtime temporary files directory // - set the log output directory // - enable "pushpin-route" header for routing // - set the message size (64k) to match Fanout // - set the publishing addr and port // - path to the routes file to use // - A routes file that sets up the routes. In our case, we: // - wires up a backend name (id) to the server host // - if the backend sets an override host, then we set that // - if the backend enables HTTPS, then we enable that // - if the backend has a path prefix, then we set that up // - enables WebSocket-over-HTTP // The runtime temporary directory, as well as the conf file and routes file // are set up and torn down along with fastly compute serve. args := []string{ fmt.Sprintf("--config=%s", pushpinCtx.confFilePath), "--verbose", } // Set up a context that can be canceled (prevent zombie Pushpin process) var pushpinCmd *exec.Cmd ctx, cancel := context.WithCancel(context.Background()) var once sync.Once pushpinCtx.cleanup = func() { once.Do(func() { if pushpinCmd != nil && pushpinCmd.Process != nil { if c.Globals.Verbose() { text.Output(out, "shutting down Pushpin") } killProcessTree(pushpinCmd.Process.Pid) } if c.Globals.Verbose() { text.Output(out, "removing %s", pushpinCtx.pushpinRunDir) } _ = os.RemoveAll(pushpinCtx.pushpinRunDir) if c.Globals.Verbose() { text.Output(out, "deleting %s", pushpinCtx.confFilePath) } _ = os.Remove(pushpinCtx.confFilePath) if c.Globals.Verbose() { text.Output(out, "deleting %s", pushpinCtx.routesFilePath) } _ = os.Remove(pushpinCtx.routesFilePath) cancel() }) } // Also allow other forms of termination to perform cleanups sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) go func() { <-sigCh pushpinCtx.Close() }() // gosec flagged this: // G204: Subprocess launched with a potential tainted input or cmd arguments // Disabling as we control this command. // #nosec // nosemgrep pushpinCmd = exec.CommandContext(ctx, pushpinCtx.pushpinRunnerBin, args...) pushpinCmd.Stderr = out stdout, err := pushpinCmd.StdoutPipe() if err != nil { return pushpinCtx, fmt.Errorf("failed to capture Pushpin stdout: %w", err) } // Start Pushpin if c.Globals.Verbose() { text.Output(out, "%s: %s", text.BoldYellow("Pushpin command"), strings.Join(pushpinCmd.Args, " ")) text.Output(out, "%s: %d", text.BoldYellow("Pushpin proxy port"), pushpinCtx.proxyPort) text.Output(out, "%s: %d", text.BoldYellow("Pushpin publisher port"), pushpinCtx.publishPort) text.Output(out, "%s: %d - %d", text.BoldYellow("Pushpin other reserved ports"), pushpinCtx.publishPort+1, pushpinCtx.publishPort+3) text.Output(out, "%s: %s", text.BoldYellow("Pushpin temporary runtime directory"), pushpinCtx.pushpinRunDir) text.Output(out, "%s: %s", text.BoldYellow("Pushpin conf file"), pushpinCtx.confFilePath) text.Output(out, "%s: %s", text.BoldYellow("Pushpin routes file"), pushpinCtx.routesFilePath) } if err := pushpinCmd.Start(); err != nil { return pushpinCtx, fmt.Errorf("failed to start Pushpin runner: %w", err) } // Monitor output from Pushpin // 1. convert output and log it // 2. wait for a timeout after a startup message startupError := make(chan error, 1) go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() // Successful if timeout passes after seeing 'started' if strings.HasSuffix(line, "started") { go func() { time.Sleep(1000 * time.Millisecond) startupError <- nil }() } level, msg := formatPushpinLog(line) if level != "DEBUG" || c.Globals.Verbose() { text.Output(out, "%s %s %s", time.Now().UTC().Format("2006-01-02T15:04:05.000000Z"), level, msg) } } if err := scanner.Err(); err != nil { startupError <- fmt.Errorf("error reading Pushpin output: %w", err) } else { startupError <- fmt.Errorf("process Pushpin terminated") } }() // Startup error err = <-startupError if err != nil { return pushpinCtx, fsterr.RemediationError{ Inner: err, Remediation: fmt.Sprintf("Check that your disk isn't full and that a process isn't already running on ports %d or %d - %d.", pushpinCtx.proxyPort, pushpinCtx.publishPort, pushpinCtx.publishPort+3), } } text.Success(out, "Pushpin started.") text.Break(out) return pushpinCtx, nil } // 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 pushpinProxyPort uint16 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.pushpinProxyPort != 0 { args = append(args, fmt.Sprintf("--local-pushpin-proxy-port=%d", opts.pushpinProxyPort)) } 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 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) } } func killProcessTree(pid int) { processes, err := ps.Processes() if err != nil { log.Printf("failed to list processes: %v", err) return } var children []int for _, p := range processes { if p.PPid() == pid { children = append(children, p.Pid()) } } for _, child := range children { killProcessTree(child) } _ = killProcess(pid) } ================================================ FILE: pkg/commands/compute/serve_test.go ================================================ 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) } } ================================================ FILE: pkg/commands/compute/serve_unix.go ================================================ //go:build !windows package compute import ( "syscall" ) func killProcess(pid int) error { return syscall.Kill(pid, syscall.SIGKILL) } ================================================ FILE: pkg/commands/compute/serve_windows.go ================================================ //go:build windows package compute import ( "os/exec" "strconv" ) func killProcess(pid int) error { // This is safe as the pid is obtained internally // nolint:gosec return exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid)).Run() } ================================================ FILE: pkg/commands/compute/setup/backend.go ================================================ package setup import ( "context" "fmt" "io" "net" "strconv" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/commands/service/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/compute/setup/config_store.go ================================================ package setup import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/compute/setup/doc.go ================================================ // Package setup contains logic for managing the creation of resources that are // defined within the fastly.toml manifest file. package setup ================================================ FILE: pkg/commands/compute/setup/domain.go ================================================ package setup import ( "context" "fmt" "io" "net/http" "regexp" "strings" petname "github.com/dustinkirkland/golang-petname" "github.com/fastly/go-fastly/v15/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 NoDefaultDomain 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 { // Don't generate a domain if --no-default-domain is set and no domain is provided if d.NoDefaultDomain && d.PackageDomain == "" { d.missing = false return nil } // 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(context.TODO(), &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 { // Only mark as missing if we're not intentionally skipping domain creation if !d.NoDefaultDomain || d.PackageDomain != "" { 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(context.TODO(), &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 { return errors.RemediationError{ Inner: fmt.Errorf("invalid config: both 'file' and 'items' were set"), Remediation: fmt.Sprintf("Edit the [setup.kv_stores.%s] configuration to use either 'file' or 'items', not both", name), } } fileItems, err := loadKVStoreFile(settings.File) if err != nil { return fmt.Errorf("failed to load KV Store file '%s': %w", settings.File, err) } items = fileItems } for key, item := range settings.Items { if item.Value != "" && item.File != "" { return errors.RemediationError{ Inner: fmt.Errorf("invalid config: both 'value' and 'file' were set"), Remediation: fmt.Sprintf("Edit the [setup.kv_stores.%s.items.%s] configuration to use either 'value' or 'file', not both", name, key), } } promptMessage := "Value" dv := "example" if item.Value != "" { dv = item.Value } if item.File != "" { promptMessage = "File" dv = item.File } prompt := text.Prompt(fmt.Sprintf("%s: [%s] ", promptMessage, dv)) var ( value string err error ) if !o.AcceptDefaults && !o.NonInteractive { text.Output(o.Stdout, "\nCreate a KV 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 } var f *os.File if item.File != "" { abs, err := filepath.Abs(item.File) if err != nil { return fmt.Errorf("failed to construct absolute path for '%s': %w", item.File, err) } // G304 (CWE-22): Potential file inclusion via variable // Disabling as we trust the source of the variable. // #nosec f, err = os.Open(abs) if err != nil { return fmt.Errorf("failed to open file '%s': %w", abs, err) } } kvsi := KVStoreItem{ Key: key, } if item.File != "" && f != nil { lr, err := fastly.FileLengthReader(f) if err != nil { return fmt.Errorf("failed to convert file to a LengthReader: %w", err) } kvsi.Body = lr } else { kvsi.Value = value } items = append(items, kvsi) } o.required = append(o.required, KVStore{ Name: name, Items: items, LinkExistingStore: linkExistingStore, ExistingStoreID: existingStoreID, }) } return nil } // Create calls the relevant API to create the service resource(s). func (o *KVStores) Create() error { if o.Spinner == nil { return errors.RemediationError{ Inner: fmt.Errorf("internal logic error: no spinner configured for setup.KVStores"), Remediation: errors.BugRemediation, } } for _, kvStore := range o.required { var ( err error store *fastly.KVStore ) if kvStore.LinkExistingStore { err = o.Spinner.Process(fmt.Sprintf("Retrieving existing KV Store '%s'", kvStore.Name), func(_ *text.SpinnerWrapper) error { store, err = o.APIClient.GetKVStore(context.TODO(), &fastly.GetKVStoreInput{ StoreID: kvStore.ExistingStoreID, }) if err != nil { return fmt.Errorf("failed to get existing store '%s': %w", kvStore.Name, err) } return nil }) if err != nil { return err } } else { err = o.Spinner.Process(fmt.Sprintf("Creating KV Store '%s'", kvStore.Name), func(_ *text.SpinnerWrapper) error { store, err = o.APIClient.CreateKVStore(context.TODO(), &fastly.CreateKVStoreInput{ Name: kvStore.Name, }) if err != nil { return fmt.Errorf("error creating KV Store: %w", err) } return nil }) if err != nil { return err } } if len(kvStore.Items) > 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(context.TODO(), 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(context.TODO(), &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 } // loadKVStoreFile loads KV Store items from a JSON file. func loadKVStoreFile(filePath string) ([]KVStoreItem, error) { abs, err := filepath.Abs(filePath) if err != nil { return nil, fmt.Errorf("failed to construct absolute path for '%s': %w", filePath, err) } // G304 (CWE-22): Potential file inclusion via variable // Disabling as we trust the source of the variable. // #nosec data, err := os.ReadFile(abs) if err != nil { return nil, fmt.Errorf("failed to read file '%s': %w", abs, err) } var jsonData map[string]any if err := json.Unmarshal(data, &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON file: %w", err) } var items []KVStoreItem for key, val := range jsonData { // Handle string values directly if strVal, ok := val.(string); ok { items = append(items, KVStoreItem{ Key: key, Value: strVal, }) } else { // For non-string values, marshal back to JSON string jsonVal, err := json.Marshal(val) if err != nil { return nil, fmt.Errorf("failed to marshal value for key '%s': %w", key, err) } items = append(items, KVStoreItem{ Key: key, Value: string(jsonVal), }) } } return items, nil } ================================================ FILE: pkg/commands/compute/setup/kv_store_test.go ================================================ package setup import ( "os" "path/filepath" "testing" ) func TestLoadKVStoreFile(t *testing.T) { // Create a temporary JSON file for testing tmpDir := t.TempDir() jsonFile := filepath.Join(tmpDir, "test.json") jsonContent := `{ "key1": "value1", "key2": "value2", "key3": {"nested": "object"} }` if err := os.WriteFile(jsonFile, []byte(jsonContent), 0o600); err != nil { t.Fatalf("failed to create test file: %v", err) } tests := []struct { name string file string wantErr bool wantCount int }{ { name: "valid json file", file: jsonFile, wantErr: false, wantCount: 3, }, { name: "non-existent file", file: "/nonexistent/path/file.json", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { items, err := loadKVStoreFile(tt.file) if (err != nil) != tt.wantErr { t.Errorf("loadKVStoreFile() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && len(items) != tt.wantCount { t.Errorf("loadKVStoreFile() got %d items, want %d", len(items), tt.wantCount) } // Verify that nested objects are marshaled to JSON strings if !tt.wantErr { foundNested := false for _, item := range items { if item.Key == "key3" { foundNested = true if item.Value != `{"nested":"object"}` { t.Errorf("nested object not properly marshaled: got %q", item.Value) } } } if !foundNested { t.Error("expected to find key3 with nested object") } } }) } } ================================================ FILE: pkg/commands/compute/setup/loggers.go ================================================ 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 } ================================================ FILE: pkg/commands/compute/setup/secret_store.go ================================================ package setup import ( "context" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/compute/testdata/build/cpp/main.cpp ================================================ #include int main() { printf("Hello from C++ test!\n"); return 0; } ================================================ FILE: pkg/commands/compute/testdata/build/go/go.mod ================================================ module cli-go-sdk go 1.18 require github.com/fastly/compute-sdk-go v0.1.1 ================================================ FILE: pkg/commands/compute/testdata/build/go/main.go ================================================ package main import "fmt" func main() { fmt.Println("hello") } ================================================ FILE: pkg/commands/compute/testdata/build/javascript/package.json ================================================ { "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" } } ================================================ FILE: pkg/commands/compute/testdata/build/javascript/src/index.js ================================================ // 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); }); ================================================ FILE: pkg/commands/compute/testdata/build/rust/Cargo.lock ================================================ # This file is automatically @generated by Cargo. # It is not intended for manual editing. [[package]] name = "anyhow" version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afddf7f520a80dbf76e6f50a35bca42a2331ef227a28b3b6dc5c2e2338d114b1" [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" [[package]] name = "bytes" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" [[package]] name = "bytes" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" dependencies = [ "num-integer", "num-traits", "serde", ] [[package]] name = "fastly-compute-project" version = "0.1.0" dependencies = [ "fastly", ] [[package]] name = "fastly" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b32333410ceb0499e2c98af856a47464231e44e78cbfc4a5c66592c1dd8bd07a" dependencies = [ "anyhow", "bytes 0.5.6", "chrono", "fastly-macros", "fastly-shared", "fastly-sys", "http", "lazy_static", "log", "mime", "serde", "serde_json", "serde_urlencoded", "thiserror", "url", ] [[package]] name = "fastly-macros" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9805cc40e0ce43131c09107bf833af402ab102ed2e0bdd1a7f4d9b8789138229" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "fastly-shared" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31e6b7bc3eae372a3538281a7c38af84bdc15298cfa71badb7eb9f5cb487ae8" dependencies = [ "bitflags", "http", "thiserror", ] [[package]] name = "fastly-sys" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b95e92b98ef5ea2cef58bde3f3ca41c4fa478172ac66e2d491923765f2b8690" dependencies = [ "fastly-shared", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece68d15c92e84fa4f19d3780f1294e5ca82a78a6d515f1efaabcc144688be00" dependencies = [ "matches", "percent-encoding", ] [[package]] name = "http" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7245cd7449cc792608c3c8a9eaf69bd4eabbabf802713748fd739c98b82f0747" dependencies = [ "bytes 1.0.1", "fnv", "itoa", ] [[package]] name = "idna" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" dependencies = [ "matches", "unicode-bidi", "unicode-normalization", ] [[package]] name = "itoa" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "log" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" dependencies = [ "cfg-if", ] [[package]] name = "matches" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" [[package]] name = "mime" version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] name = "num-integer" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" dependencies = [ "autocfg", "num-traits", ] [[package]] name = "num-traits" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" dependencies = [ "autocfg", ] [[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "proc-macro2" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" dependencies = [ "unicode-xid", ] [[package]] name = "quote" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" dependencies = [ "proc-macro2", ] [[package]] name = "ryu" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" [[package]] name = "serde" version = "1.0.123" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d5161132722baa40d802cc70b15262b98258453e85e5d1d365c757c73869ae" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.123" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9391c295d64fc0abb2c556bad848f33cb8296276b1ad2677d1ae1ace4f258f31" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fceb2595057b6891a4ee808f70054bd2d12f0e97f1cbb78689b59f676df325a" dependencies = [ "itoa", "ryu", "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "syn" version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" dependencies = [ "proc-macro2", "quote", "unicode-xid", ] [[package]] name = "thiserror" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tinyvec" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317cca572a0e89c3ce0ca1f1bdc9369547fe318a683418e42ac8f59d14701023" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "unicode-bidi" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" dependencies = [ "matches", ] [[package]] name = "unicode-normalization" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13e63ab62dbe32aeee58d1c5408d35c36c392bba5d9d3142287219721afe606" dependencies = [ "tinyvec", ] [[package]] name = "unicode-xid" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" [[package]] name = "url" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5909f2b0817350449ed73e8bcd81c8c3c8d9a7a5d8acba4b27db277f1868976e" dependencies = [ "form_urlencoded", "idna", "matches", "percent-encoding", ] ================================================ FILE: pkg/commands/compute/testdata/build/rust/Cargo.toml ================================================ [package] name = "fastly-compute-project" version = "0.1.0" authors = ["phamann "] edition = "2018" [profile.release] debug = true [dependencies] fastly = "^0.6.0" ================================================ FILE: pkg/commands/compute/testdata/build/rust/fastly.toml ================================================ manifest_version = "0.2.0" name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/build/rust/src/main.rs ================================================ fn main() { println!("Hello world!") } ================================================ FILE: pkg/commands/compute/testdata/init/fastly-invalid-missing-version.toml ================================================ name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-invalid-section-version.toml ================================================ name = "Default Rust template" description = "Default package template for Rust based edge compute projects." [manifest_version] authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-invalid-unrecognised.toml ================================================ manifest_version = "abc" # not a number name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-invalid-version-exceeded.toml ================================================ 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" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-missing-spec-url.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-valid-integer.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-valid-semver.toml ================================================ 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" ================================================ FILE: pkg/commands/compute/testdata/init/fastly-viceroy-update.toml ================================================ # 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" ================================================ FILE: pkg/commands/compute/testdata/kv_store_example.json ================================================ { "key1": "value1", "key2": "value2", "key3": "value3" } ================================================ FILE: pkg/commands/compute/testdata/metadata/config.toml ================================================ [wasm-metadata] build_info = "disable" machine_info = "disable" package_info = "disable" ================================================ FILE: pkg/commands/compute/testdata/pack/main.wasm ================================================ ================================================ FILE: pkg/commands/compute/update.go ================================================ package compute import ( "context" "fmt" "io" "path/filepath" "github.com/kennygrant/sanitize" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/compute/update_test.go ================================================ 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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) } ================================================ FILE: pkg/commands/compute/validate.go ================================================ package compute import ( "context" "fmt" "io" "os" "path/filepath" "github.com/kennygrant/sanitize" "github.com/mholt/archives" "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 archives.FileInfo) error { for k := range files { if filepath.Base(f.NameInArchive) == k { 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(archives.FileInfo) 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 format, stream, err := archives.Identify(context.Background(), path, file) if err != nil { return fmt.Errorf("error identifying archive format: %w", err) } if ex, ok := format.(archives.Extractor); ok { return ex.Extract(context.Background(), stream, func(_ context.Context, f archives.FileInfo) error { // Skip directories if f.IsDir() { return nil } return fn(f) }) } return fmt.Errorf("format does not support extraction") } ================================================ FILE: pkg/commands/compute/validate_test.go ================================================ 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) } ================================================ FILE: pkg/commands/config/config_test.go ================================================ 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) } ================================================ FILE: pkg/commands/config/doc.go ================================================ // Package config contains commands to inspect the CLI configuration. package config ================================================ FILE: pkg/commands/config/root.go ================================================ 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 } ================================================ FILE: pkg/commands/config/testdata/config.toml ================================================ config_version = 2 [fastly] api_endpoint = "https://api.fastly.com" ================================================ FILE: pkg/commands/configstore/configstore_test.go ================================================ package configstore_test import ( "context" "errors" "fmt" "testing" "time" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { return &fastly.ConfigStore{ StoreID: i.StoreID, Name: storeName, CreatedAt: &now, }, nil }, GetConfigStoreMetadataFn: func(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, _ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return nil, nil }, }, WantOutput: fmtStores(nil), }, { API: &mock.API{ ListConfigStoresFn: func(_ context.Context, _ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return nil, errors.New("unknown error") }, }, WantError: "unknown error", }, { API: &mock.API{ ListConfigStoresFn: func(_ context.Context, _ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return stores, nil }, }, WantOutput: fmtStores(stores), }, { Args: "--json", API: &mock.API{ ListConfigStoresFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { return nil, nil }, }, WantOutput: fmtServices(nil), }, { Args: fmt.Sprintf("--store-id %s", storeID), API: &mock.API{ ListConfigStoreServicesFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { return services, nil }, }, WantOutput: fmtServices(services), }, { Args: fmt.Sprintf("--store-id %s --json", storeID), API: &mock.API{ ListConfigStoreServicesFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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) } ================================================ FILE: pkg/commands/configstore/create.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstore/delete.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstore/describe.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstore/doc.go ================================================ // 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 ================================================ FILE: pkg/commands/configstore/helper_test.go ================================================ package configstore_test import ( "bytes" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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() } ================================================ FILE: pkg/commands/configstore/list.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstore/list_services.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstore/root.go ================================================ 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") } ================================================ FILE: pkg/commands/configstore/update.go ================================================ package configstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstoreentry/configstoreentry_test.go ================================================ package configstoreentry_test import ( "bytes" "context" "errors" "fmt" "testing" "time" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { return testItems, nil }, DeleteConfigStoreItemFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { return testItems, nil }, DeleteConfigStoreItemFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, _ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { return testItems, nil }, }, WantOutput: printConfigStoreItemsTbl(testItems), }, { Args: fmt.Sprintf("--store-id %s --json", storeID), API: &mock.API{ ListConfigStoreItemsFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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() } ================================================ FILE: pkg/commands/configstoreentry/create.go ================================================ package configstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstoreentry/delete.go ================================================ package configstoreentry import ( "context" "fmt" "io" "strings" "sync" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstoreentry/describe.go ================================================ package configstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstoreentry/doc.go ================================================ // 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 ================================================ FILE: pkg/commands/configstoreentry/errors.go ================================================ package configstoreentry import ( "errors" "fmt" fsterr "github.com/fastly/cli/pkg/errors" ) const ( maxKeyLen = 256 // maxValueLen is the maximum length of Config Store entry's value. It's set to 64k, // even though customers may have a smaller limit. The API will reject requests if the // value is larger than the customer's limit. maxValueLen = 2 << 15 ) 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), } ================================================ FILE: pkg/commands/configstoreentry/list.go ================================================ package configstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/configstoreentry/root.go ================================================ 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") } ================================================ FILE: pkg/commands/configstoreentry/update.go ================================================ package configstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/dashboard/create.go ================================================ package dashboard import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/dashboard/dashboard_test.go ================================================ package dashboard_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.DeleteObservabilityCustomDashboardInput) error { return testutil.Err }, }, Args: "--id beepboop", WantError: testutil.Err.Error(), }, { Name: "validate DeleteObservabilityCustomDashboard API success", API: &mock.API{ DeleteObservabilityCustomDashboardFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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 (auth: 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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 } ================================================ FILE: pkg/commands/dashboard/delete.go ================================================ package dashboard import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/dashboard/describe.go ================================================ package dashboard import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/dashboard/printer" 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(context.TODO(), input) if err != nil { return err } if ok, err := c.WriteJSON(out, dashboard); ok { return err } printer.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 } ================================================ FILE: pkg/commands/dashboard/doc.go ================================================ // Package dashboard contains commands to manage custom Observability Dashboards. package dashboard ================================================ FILE: pkg/commands/dashboard/item/common.go ================================================ 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"} ) ================================================ FILE: pkg/commands/dashboard/item/create.go ================================================ package item import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/dashboard/printer" 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(context.TODO(), &fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) if err != nil { return err } input := c.constructInput(d) d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(context.TODO(), 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 printer.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 } ================================================ FILE: pkg/commands/dashboard/item/delete.go ================================================ package item import ( "context" "io" "slices" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), 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 } ================================================ FILE: pkg/commands/dashboard/item/describe.go ================================================ package item import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/dashboard/printer" 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(context.TODO(), input) if err != nil { return err } di, err := getItemFromDashboard(context.TODO(), d, c.itemID) if err != nil { return err } if c.JSONOutput.Enabled { _, err := c.WriteJSON(out, di) if err != nil { return err } } else { printer.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(_ context.Context, 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) } ================================================ FILE: pkg/commands/dashboard/item/doc.go ================================================ // Package item contains commands to inspect and manipulate the contents of // a Custom Observability Dashboard. package item ================================================ FILE: pkg/commands/dashboard/item/item_test.go ================================================ package item_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { d := defaultDashboard() return &d, nil } func updateDashboardOK(_ context.Context, i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { d := defaultDashboard() d.Items = *i.Items return &d, nil } func updateDashboardEmpty(_ context.Context, _ *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { d := defaultDashboard() d.Items = []fastly.DashboardItem{} return &d, nil } ================================================ FILE: pkg/commands/dashboard/item/root.go ================================================ 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") } ================================================ FILE: pkg/commands/dashboard/item/update.go ================================================ package item import ( "context" "fmt" "io" "slices" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), 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 } ================================================ FILE: pkg/commands/dashboard/list.go ================================================ package dashboard import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/dashboard/printer" 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(context.TODO(), 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() { printer.PrintVerbose(out, dashboards) } else { printer.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() { printer.PrintVerbose(out, dashboards) } else { printer.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 var err error if c.order.WasSet { sign, err = argparser.ConvertOrderFromStringFlag(c.order.Value, "order") if err != nil { c.Globals.ErrLog.Add(err) return nil, err } } if c.sort.WasSet { str := sign + c.sort.Value input.Sort = &str } return &input, nil } ================================================ FILE: pkg/commands/dashboard/printer/print.go ================================================ // Package printer contains functions used by both dashboard and dashboard/item packages package printer import ( "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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) } } } ================================================ FILE: pkg/commands/dashboard/root.go ================================================ 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") } ================================================ FILE: pkg/commands/dashboard/update.go ================================================ package dashboard import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/doc.go ================================================ // Package commands contains functions for managing exposed CLI commands. package commands ================================================ FILE: pkg/commands/domain/common.go ================================================ package domain import ( "fmt" "io" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "github.com/fastly/cli/pkg/text" ) // printSummary displays the information returned from the API in a summarised // format. func printSummary(out io.Writer, data []domains.Data) { t := text.NewTable(out) t.AddHeader("FQDN", "DOMAIN ID", "SERVICE ID", "CREATED AT", "UPDATED AT", "DESCRIPTION") 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, d.Description) } t.Print() } // printSummary displays the information returned from the API in a verbose // format. func printVerbose(out io.Writer, data []domains.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, "Description: %s\n", d.Description) fmt.Fprintf(out, "\n") } } ================================================ FILE: pkg/commands/domain/create.go ================================================ package domain import ( "context" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "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 // Optional. description 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 domain").Alias("add") // Optional. c.CmdClause.Flag("description", "The description for the domain").Action(c.description.Set).StringVar(&c.description.Value) 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 := &domains.CreateInput{ FQDN: &c.fqdn, } if c.serviceID != "" { input.ServiceID = &c.serviceID } if c.description.WasSet { input.Description = &c.description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } d, err := domains.Create(context.TODO(), 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 } ================================================ FILE: pkg/commands/domain/delete.go ================================================ package domain import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "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 := &domains.DeleteInput{ DomainID: &c.domainID, } err := domains.Delete(context.TODO(), 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 } ================================================ FILE: pkg/commands/domain/describe.go ================================================ package domain import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "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 := &domains.GetInput{ DomainID: &c.domainID, } d, err := domains.Get(context.TODO(), 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 := []domains.Data{*d} if c.Globals.Verbose() { printVerbose(out, cl) } else { printSummary(out, cl) } } return nil } ================================================ FILE: pkg/commands/domain/doc.go ================================================ // Package domainv1 contains commands to inspect and manipulate Fastly domains. package domain ================================================ FILE: pkg/commands/domain/domain_test.go ================================================ package domain_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" root "github.com/fastly/cli/pkg/commands/domain" "github.com/fastly/cli/pkg/testutil" ) func TestDomainCreate(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(domains.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(domains.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 TestDomainList(t *testing.T) { fqdn := "www.example.com" sid := "123" did := "domain-id" description := "domain description" resp := testutil.GenJSON(domains.Collection{ Data: []domains.Data{ { DomainID: did, FQDN: fqdn, ServiceID: &sid, Description: description, }, }, }) 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 TestDomainDescribe(t *testing.T) { fqdn := "www.example.com" sid := "123" did := "domain-id" description := "domain description" resp := testutil.GenJSON(domains.Data{ DomainID: did, FQDN: fqdn, ServiceID: &sid, Description: description, }) 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 TestDomainUpdate(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(domains.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(domains.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 TestDomainDelete(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) } ================================================ FILE: pkg/commands/domain/list.go ================================================ package domain import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "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 := &domains.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 := domains.List(context.TODO(), 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 } } ================================================ FILE: pkg/commands/domain/root.go ================================================ 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 domains") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/domain/update.go ================================================ package domain import ( "context" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/domains" "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 description 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") // Required. c.CmdClause.Flag("domain-id", "The Domain Identifier (UUID)").Required().StringVar(&c.domainID) // Optional c.CmdClause.Flag("description", "The description for the domain").Action(c.description.Set).StringVar(&c.description.Value) 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 := &domains.UpdateInput{ DomainID: &c.domainID, } if c.serviceID != "" { input.ServiceID = &c.serviceID } if c.description.WasSet { input.Description = &c.description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } d, err := domains.Update(context.TODO(), 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 } ================================================ FILE: pkg/commands/install/doc.go ================================================ // Package install contains functions for installing a specific CLI version. package install ================================================ FILE: pkg/commands/install/root.go ================================================ 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 } ================================================ FILE: pkg/commands/ip/doc.go ================================================ // Package ip contains commands to inspect and manipulate Fastly IPs. package ip ================================================ FILE: pkg/commands/ip/ip_test.go ================================================ package ip_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context) (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) } ================================================ FILE: pkg/commands/ip/root.go ================================================ package ip import ( "context" "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(context.TODO()) 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 } ================================================ FILE: pkg/commands/kvstore/create.go ================================================ package kvstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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 a 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstore/delete.go ================================================ package kvstore import ( "context" "fmt" "io" "strconv" "github.com/fastly/go-fastly/v15/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 a 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstore/describe.go ================================================ package kvstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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 a 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstore/doc.go ================================================ // Package kvstore contains commands to inspect and manipulate Fastly edge // kv stores. package kvstore ================================================ FILE: pkg/commands/kvstore/kvstore_test.go ================================================ package kvstore_test import ( "bytes" "context" "errors" "fmt" "testing" "time" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return nil, nil }, }, }, { API: &mock.API{ ListKVStoresFn: func(_ context.Context, _ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return nil, errors.New("unknown error") }, }, WantError: "unknown error", }, { API: &mock.API{ ListKVStoresFn: func(_ context.Context, _ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return stores, nil }, }, WantOutput: fmtStores(stores), }, { Args: "--json", API: &mock.API{ ListKVStoresFn: func(_ context.Context, _ *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() } ================================================ FILE: pkg/commands/kvstore/list.go ================================================ package kvstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } } ================================================ FILE: pkg/commands/kvstore/root.go ================================================ 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") } ================================================ FILE: pkg/commands/kvstoreentry/create.go ================================================ package kvstoreentry import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "github.com/fastly/go-fastly/v15/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("add", "Limit the operation to adding a new item. If an existing item with the specified key exists, the operation will fail (default: false)").BoolVar(&c.add) c.CmdClause.Flag("append", "If an item with the specified key exists, the value provided in the operation is appended to the existing value instead of replacing it (default: false)").BoolVar(&c.append) c.CmdClause.Flag("background-fetch", "If set to true, the new value for the item will not be immediately visible to other users of the KV store; they will receive the existing (stale) value while the platform updates cached copies. Setting this to true ensures that other users of the KV store will receive responses to 'get' operations for this item quickly, although they will be slightly out of date (default: false)").BoolVar(&c.backFetch) 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.CmdClause.Flag("if-generation-match", "Value which must match the current generation marker in an item for an update operation to proceed").StringVar(&c.ifGenMatch) c.CmdClause.Flag("metadata", "An arbitrary data field which can contain up to 2000 bytes of data").StringVar(&c.metadata) c.CmdClause.Flag("prepend", "If an item with the specified key exists, the value provided in the operation is prepended to the existing value instead of replacing it (Default: false)").BoolVar(&c.prepend) 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 a kv store. type CreateCommand struct { argparser.Base argparser.JSONOutput add bool append bool backFetch bool dirAllowHidden bool dirConcurrency int dirPath string filePath string ifGenMatch string metadata string prepend bool 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 } // Append optional params. c.Input.Add = c.add c.Input.Append = c.append c.Input.BackgroundFetch = c.backFetch // Parse generation match if provided. if c.ifGenMatch != "" { inputGeneration, err := strconv.ParseUint(c.ifGenMatch, 10, 64) if err != nil { return fmt.Errorf("invalid generation value: %s", c.ifGenMatch) } c.Input.IfGenerationMatch = inputGeneration } if c.metadata != "" { c.Input.Metadata = &c.metadata } c.Input.Prepend = c.prepend err := c.Globals.APIClient.InsertKVStoreKey(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstoreentry/delete.go ================================================ package kvstoreentry import ( "context" "fmt" "io" "strconv" "sync" "sync/atomic" "github.com/fastly/go-fastly/v15/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 a kv store. type DeleteCommand struct { argparser.Base argparser.JSONOutput key argparser.OptionalString // NOTE: Public fields can be set via `kv-store delete`. DeleteAll bool Force bool IfGenerationMatch string 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.CmdClause.Flag("force", "Return a successful result from a 'delete' operation even if the specified key was not found").BoolVar(&c.Force) c.CmdClause.Flag("if-generation-match", "Value which must match the current generation marker in an item for a delete operation to proceed").StringVar(&c.IfGenerationMatch) 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, Force: c.Force, Key: c.key.Value, } // Validate generation value if provided. if c.IfGenerationMatch != "" { _, err := strconv.ParseUint(c.IfGenerationMatch, 10, 64) if err != nil { return fmt.Errorf("invalid generation value: %s", c.IfGenerationMatch) } input.IfGenerationMatch, _ = strconv.ParseUint(c.IfGenerationMatch, 10, 64) } err := c.Globals.APIClient.DeleteKVStoreKey(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstoreentry/describe.go ================================================ package kvstoreentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 fetch the value of a key from a kv store. type DescribeCommand struct { argparser.Base argparser.JSONOutput Input fastly.GetKVStoreItemInput } // 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, // This argument suppresses the 'Fastly API' output from the global verbose command. SuppressVerbose: true, }, } c.CmdClause = parent.Command("describe", "Get the associated attributes of a key") // 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 } if c.Globals.Flags.Verbose { // We won't be supporting a --verbose flag here as there wouldn't be any additional output to provide. return fmt.Errorf("the 'describe' command does not support the --verbose flag") } item, err := c.Globals.APIClient.GetKVStoreItem(context.TODO(), &c.Input) if err != nil { c.Globals.ErrLog.Add(err) return err } if c.JSONOutput.Enabled { o := map[string]interface{}{ "key": c.Input.Key, "generation": fmt.Sprintf("%d", item.Generation), "metadata": item.Metadata, } if ok, err := c.WriteJSON(out, o); ok { return err } return nil } // IMPORTANT: Don't use `text` package as binary data can be messed up. // Print the key attributes. fmt.Fprintf(out, "Key: %s\n", c.Input.Key) fmt.Fprintf(out, "Generation: %d\n", item.Generation) fmt.Fprintf(out, "Metadata: %s\n", item.Metadata) return nil } ================================================ FILE: pkg/commands/kvstoreentry/doc.go ================================================ // Package kvstoreentry contains commands to inspect and manipulate Fastly edge // kv stores keys. package kvstoreentry ================================================ FILE: pkg/commands/kvstoreentry/get.go ================================================ package kvstoreentry import ( "context" "encoding/base64" "fmt" "io" "strconv" "github.com/fastly/go-fastly/v15/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" ) // GetCommand calls the Fastly API to fetch the value of a key from a kv store. type GetCommand struct { argparser.Base argparser.JSONOutput Input fastly.GetKVStoreItemInput Generation 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, // This argument suppresses the 'Fastly API' output from the global verbose command. SuppressVerbose: true, }, } c.CmdClause = parent.Command("get", "Get the value associated with a key") // 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.CmdClause.Flag("if-generation-match", "Compares if the provided generation marker matches that of the object").StringVar(&c.Generation) c.RegisterFlagBool(c.JSONFlag()) // --json return &c } // Exec invokes the application logic for the command. func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { // As the 'describe' command provides the object attributes, // we won't be supporting a --verbose flag here. if c.Globals.Flags.Verbose { return fmt.Errorf("the 'get' command does not support the --verbose flag") } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } // Validate generation value before making API call var inputGeneration uint64 if c.Generation != "" { var err error inputGeneration, err = strconv.ParseUint(c.Generation, 10, 64) if err != nil { return fmt.Errorf("invalid generation value: %s", c.Generation) } } result, err := c.Globals.APIClient.GetKVStoreItem(context.TODO(), &c.Input) if err != nil { c.Globals.ErrLog.Add(err) return err } // Check if the generation marker matches the API result if c.Generation != "" { if inputGeneration != result.Generation { return fmt.Errorf("generation value does not match: expected %d, got %d", result.Generation, inputGeneration) } } // Ensure we close the value reader. if result.Value != nil { defer result.Value.Close() } // Read the value from ReadCloser. var value string if result.Value != nil { valueBytes, err := io.ReadAll(result.Value) if err != nil { c.Globals.ErrLog.Add(err) return err } value = string(valueBytes) } if c.JSONOutput.Enabled { // We are encoding the value of the item here to ensure safe // output for binary content along with other outputs. encodedValue := base64.StdEncoding.EncodeToString([]byte(value)) text.Output(out, `{"%s": "%s"}`, c.Input.Key, encodedValue) return nil } // IMPORTANT: Don't use `text` package as binary data can be messed up. fmt.Fprint(out, value) return nil } ================================================ FILE: pkg/commands/kvstoreentry/hidden.go ================================================ package kvstoreentry func isHiddenFile(filename string) bool { return filename[0] == '.' } ================================================ FILE: pkg/commands/kvstoreentry/kvstoreentry_test.go ================================================ package kvstoreentry_test import ( "context" "encoding/base64" "errors" "fmt" "io" "path/filepath" "strings" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *fastly.InsertKVStoreKeyInput) error { return nil }, }, WantOutput: fstfmt.JSON(`{"id": %q, "key": %q}`, storeID, itemKey), }, { Name: "validate --add flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --add", storeID, itemKey, itemValue), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if !i.Add { return errors.New("expected Add flag to be true") } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Name: "validate --append flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --append", storeID, itemKey, itemValue), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if !i.Append { return errors.New("expected Append flag to be true") } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Name: "validate --prepend flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --prepend", storeID, itemKey, itemValue), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if !i.Prepend { return errors.New("expected Prepend flag to be true") } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Name: "validate --background-fetch flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --background-fetch", storeID, itemKey, itemValue), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if !i.BackgroundFetch { return errors.New("expected BackgroundFetch flag to be true") } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Name: "validate --if-generation-match flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --if-generation-match 42", storeID, itemKey, itemValue), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if i.IfGenerationMatch != 42 { return fmt.Errorf("expected IfGenerationMatch to be 42, got %d", i.IfGenerationMatch) } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Name: "validate --if-generation-match flag with invalid value", Args: fmt.Sprintf("--store-id %s --key %s --value %s --if-generation-match invalid", storeID, itemKey, itemValue), WantError: "invalid generation value: invalid", }, { Name: "validate --metadata flag", Args: fmt.Sprintf("--store-id %s --key %s --value %s --metadata %s", storeID, itemKey, itemValue, "test-metadata"), API: &mock.API{ InsertKVStoreKeyFn: func(_ context.Context, i *fastly.InsertKVStoreKeyInput) error { if i.Metadata == nil || *i.Metadata != "test-metadata" { return errors.New("expected Metadata to be 'test-metadata'") } return nil }, }, WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), }, { Args: fmt.Sprintf("--store-id %s --stdin", storeID), Stdin: []string{`{"key":"example","value":"VkFMVUU="}`}, API: &mock.API{ BatchModifyKVStoreKeyFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { return nil }, }, WantOutput: fstfmt.JSON(`{"key": "%s", "store_id": "%s", "deleted": true}`, itemKey, storeID), }, { Name: "validate --force flag with any key", Args: fmt.Sprintf("--store-id %s --key %s --force", storeID, "myFakeKey"), API: &mock.API{ DeleteKVStoreKeyFn: func(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { return nil }, }, WantOutput: fstfmt.Success("Deleted key '%s' from KV Store '%s'", "myFakeKey", storeID), }, { Name: "validate --if-generation-match with matching generation", Args: fmt.Sprintf("--store-id %s --key %s --if-generation-match 123", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{Generation: 123}, nil }, DeleteKVStoreKeyFn: func(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { return nil }, }, WantOutput: fstfmt.Success("Deleted key '%s' from KV Store '%s'", itemKey, storeID), }, { Name: "validate --if-generation-match with invalid generation value", Args: fmt.Sprintf("--store-id %s --key %s --if-generation-match invalid", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{Generation: 123}, nil }, }, WantError: `invalid generation value: invalid`, }, { Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), API: &mock.API{ NewListKVStoreKeysPaginatorFn: func(_ context.Context, _ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { return &mockKVStoresEntriesPaginator{ next: true, keys: []string{"foo", "bar", "baz"}, } }, DeleteKVStoreKeyFn: func(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { return nil }, }, WantOutput: "Deleting keys...", }, { Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), API: &mock.API{ NewListKVStoreKeysPaginatorFn: func(_ context.Context, _ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { return &mockKVStoresEntriesPaginator{ next: true, keys: []string{"foo", "bar", "baz"}, } }, DeleteKVStoreKeyFn: func(_ context.Context, _ *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{ { Name: "validate missing --store-id flag", Args: "--key a-key", WantError: "error parsing arguments: required flag --store-id not provided", }, { Name: "validate missing --key flag", Args: "--store-id " + storeID, WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate API error handling", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{}, errors.New("invalid request") }, }, WantError: "invalid request", }, { Name: "validate successful get operation", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Value: io.NopCloser(strings.NewReader(itemValue)), }, nil }, }, WantOutput: itemValue, }, { Name: "validate --json flag output", Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Value: io.NopCloser(strings.NewReader(itemValue)), }, nil }, }, WantOutput: fmt.Sprintf(`{"%s": "%s"}`, itemKey, base64.StdEncoding.EncodeToString([]byte(itemValue))) + "\n", }, { Name: "validate --verbose flag error", Args: fmt.Sprintf("--store-id %s --key %s --verbose", storeID, itemKey), WantError: "the 'get' command does not support the --verbose flag", }, { Name: "validate --if-generation-match with matching generation", Args: fmt.Sprintf("--store-id %s --key %s --if-generation-match 123", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Value: io.NopCloser(strings.NewReader(itemValue)), }, nil }, }, WantOutput: itemValue, }, { Name: "validate --if-generation-match with non-matching generation", Args: fmt.Sprintf("--store-id %s --key %s --if-generation-match 123", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 456, Value: io.NopCloser(strings.NewReader(itemValue)), }, nil }, }, WantError: "generation value does not match: expected 456, got 123", }, { Name: "validate --if-generation-match with invalid generation value", Args: fmt.Sprintf("--store-id %s --key %s --if-generation-match invalid", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Value: io.NopCloser(strings.NewReader(itemValue)), }, nil }, }, WantError: "invalid generation value: invalid", }, { Name: "validate handling of nil value reader", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Value: nil, }, nil }, }, WantOutput: "", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "get"}, scenarios) } func TestDescribeCommand(t *testing.T) { const ( storeID = "store-id-123" itemKey = "foo" itemMetadata = "test-metadata" ) scenarios := []testutil.CLIScenario{ { Name: "validate missing --store-id flag", Args: "--key a-key", WantError: "error parsing arguments: required flag --store-id not provided", }, { Name: "validate missing --key flag", Args: "--store-id " + storeID, WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate API error handling", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{}, errors.New("invalid request") }, }, WantError: "invalid request", }, { Name: "validate successful describe operation", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Metadata: itemMetadata, }, nil }, }, WantOutput: fmt.Sprintf("Key: %s\nGeneration: %d\nMetadata: %s\n", itemKey, 123, itemMetadata), }, { Name: "validate --json flag output", Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 123, Metadata: itemMetadata, }, nil }, }, WantOutput: fmt.Sprintf("{\n \"generation\": \"%d\",\n \"key\": \"%s\",\n \"metadata\": \"%s\"\n}\n", 123, itemKey, itemMetadata), }, { Name: "validate --verbose flag error", Args: fmt.Sprintf("--store-id %s --key %s --verbose", storeID, itemKey), WantError: "the 'describe' command does not support the --verbose flag", }, { Name: "validate handling of empty metadata", Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), API: &mock.API{ GetKVStoreItemFn: func(_ context.Context, _ *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return fastly.GetKVStoreItemOutput{ Generation: 456, Metadata: "", }, nil }, }, WantOutput: fmt.Sprintf("Key: %s\nGeneration: %d\nMetadata: \n", itemKey, 456), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, 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(_ context.Context, _ *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(_ context.Context, _ *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { return &fastly.ListKVStoreKeysResponse{Data: testItems}, nil }, }, WantOutput: strings.Join(testItems, "\n") + "\n", }, { Name: "validate --prefix param", Args: fmt.Sprintf("--store-id %s --prefix=foo", storeID), API: &mock.API{ ListKVStoreKeysFn: func(_ context.Context, _ *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { return &fastly.ListKVStoreKeysResponse{Data: []string{"foo-key1", "foo-key2"}}, nil }, }, WantOutput: "✓ Getting data\nfoo-key1\nfoo-key2\n", }, { Args: fmt.Sprintf("--store-id %s --json", storeID), API: &mock.API{ ListKVStoreKeysFn: func(_ context.Context, _ *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 } ================================================ FILE: pkg/commands/kvstoreentry/list.go ================================================ package kvstoreentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 prefix 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.CmdClause.Flag("prefix", "Restrict results to items whose keys match this prefix").StringVar(&c.prefix) 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 } if c.prefix != "" { c.Input.Prefix = c.prefix } for { o, err := c.Globals.APIClient.ListKVStoreKeys(context.TODO(), &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 } ================================================ FILE: pkg/commands/kvstoreentry/root.go ================================================ 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") } ================================================ FILE: pkg/commands/kvstoreentry/testdata/data.json ================================================ { "key": "file-example-1", "value": "VkFMVUU=" } { "key": "file-example-2", "value": "VkFMVUU=" } ================================================ FILE: pkg/commands/kvstoreentry/testdata/example/.hiddenfile ================================================ This file is hidden and should not be uploaded by default unless --dir-allow-hidden flag is set. ================================================ FILE: pkg/commands/kvstoreentry/testdata/example/foo.txt ================================================ FOO ================================================ FILE: pkg/commands/logtail/doc.go ================================================ // Package logtail contains commands to inspect and manipulate Fastly streaming // log data. package logtail ================================================ FILE: pkg/commands/logtail/root.go ================================================ 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/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/logtail/tail_test.go ================================================ package logtail import ( "net/http" "os" "reflect" "testing" "time" "github.com/google/go-cmp/cmp" ) const responseFile = "testdata/response.json" // TestAdjustTimes tests that the from and to times are adjusted accordingly // based on the searchPadding. func TestAdjustTimes(t *testing.T) { dur, _ := time.ParseDuration("10s") for i, test := range []struct { in cfg exp cfg }{ { in: cfg{from: 1601480668, to: 1601480768, searchPadding: dur}, exp: cfg{from: 1601480658, to: 1601480778, searchPadding: dur}, }, { in: cfg{from: 1601480668, to: 1601480768}, exp: cfg{from: 1601480668, to: 1601480768}, }, { in: cfg{searchPadding: dur}, exp: cfg{searchPadding: dur}, }, } { 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) } } } // TestSplitByReqID tests that logs are properly grouped and sorted // by their RequestID and SequenceNum. func TestSplitByReqID(t *testing.T) { full := []Log{ {SequenceNum: 1, RequestID: "41f82900"}, {SequenceNum: 2, RequestID: "41f82900"}, {SequenceNum: 3, RequestID: "2bef4613"}, {SequenceNum: 4, RequestID: "2bef4613"}, {SequenceNum: 5, RequestID: "41f82900"}, {SequenceNum: 6, RequestID: "41f82900"}, {SequenceNum: 6, RequestID: "2bef4613"}, {SequenceNum: 1, RequestID: "2bef4613"}, {SequenceNum: 5, RequestID: "2bef4613"}, {SequenceNum: 2, RequestID: "2bef4613"}, {SequenceNum: 3, RequestID: "41f82900"}, {SequenceNum: 4, RequestID: "41f82900"}, } expfull := map[string][]Log{ "41f82900": { {SequenceNum: 1, RequestID: "41f82900"}, {SequenceNum: 2, RequestID: "41f82900"}, {SequenceNum: 5, RequestID: "41f82900"}, {SequenceNum: 6, RequestID: "41f82900"}, {SequenceNum: 3, RequestID: "41f82900"}, {SequenceNum: 4, RequestID: "41f82900"}, }, "2bef4613": { {SequenceNum: 3, RequestID: "2bef4613"}, {SequenceNum: 4, RequestID: "2bef4613"}, {SequenceNum: 6, RequestID: "2bef4613"}, {SequenceNum: 1, RequestID: "2bef4613"}, {SequenceNum: 5, RequestID: "2bef4613"}, {SequenceNum: 2, RequestID: "2bef4613"}, }, } single := []Log{ {SequenceNum: 1, RequestID: "41f82900"}, } expsingle := map[string][]Log{ "41f82900": {{SequenceNum: 1, RequestID: "41f82900"}}, } for i, test := range []struct { in []Log want map[string][]Log }{ {in: full, want: expfull}, {in: single, want: expsingle}, {in: []Log{}, want: make(map[string][]Log)}, } { got := splitByReqID(test.in) if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("#%d: splitByReqID mismatch (-want +got):\n%s", i, diff) } } } // TestParseResponseData validates we're correctly decoding a batch JSON log // response into a logs.Batch type. func TestParseResponseData(t *testing.T) { data, err := os.ReadFile(responseFile) if err != nil { t.Fatalf("cannot read from file: %v", err) } got, err := parseResponseData(data) if err != nil { t.Fatalf("error parsing response data: %v", err) } want := Batch{ ID: "MC0x", Logs: []Log{ {SequenceNum: 1, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1"}, {SequenceNum: 2, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2"}, {SequenceNum: 3, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3"}, {SequenceNum: 4, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4"}, {SequenceNum: 5, RequestStart: 1601645172164667, Stream: "stderr", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5"}, {SequenceNum: 6, RequestStart: 1601645172164667, Stream: "stderr", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6"}, {SequenceNum: 7, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7"}, {SequenceNum: 8, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8"}, {SequenceNum: 9, RequestStart: 1601645172164667, Stream: "stderr", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9"}, {SequenceNum: 10, RequestStart: 1601645172164667, Stream: "stdout", RequestID: "44a1eedd-5831-49fe-b094-7435908ba1fb", Message: "10 10 10 10 10 10 10 10 10 10 10 10 10 10"}, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("parseResponseData mismatch (-want +got):\n%s", diff) } } // TestFilterStream tests that a passed in stream will filter out // unwanted output. func TestFilterStream(t *testing.T) { for i, test := range []struct { stream string logs []Log explen int }{ { stream: "stdout", logs: []Log{ {Stream: "stdout"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stderr"}, }, explen: 5, }, { stream: "stderr", logs: []Log{ {Stream: "stdout"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stderr"}, }, explen: 3, }, { logs: []Log{ {Stream: "stdout"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stderr"}, {Stream: "stdout"}, {Stream: "stderr"}, }, explen: 8, }, } { out := filterStream(test.stream, test.logs) if len(out) != test.explen { t.Errorf("#%d: exp: %d != got: %d", i, test.explen, len(out)) } } } // TestGetLinks tests that we can parse next and prev links from a Link HTTP // header. func TestGetLinks(t *testing.T) { rawNexPrev := `; rel="next", ; rel="prev"` head := make(http.Header) head.Set("Link", rawNexPrev) prev, next := getLinks(head) prevexp := "/service/sid/log_stream/managed/instance_output%3Ffrom=1601412620" if prev != prevexp { t.Errorf("prev header exp: %s != got: %s", prevexp, prev) } nextexp := "/service/sid/log_stream/managed/instance_output%3Ffrom=1601412640" if next != nextexp { t.Errorf("next header exp: %s != got: %s", nextexp, next) } pTime, err := getTimeFromLink(prev) if err != nil { t.Fatalf("unexpected error parsing prev link: %s", err) } exp := int64(1601412620) if pTime != exp { t.Errorf("prev time exp: %v != got: %v", exp, pTime) } nTime, err := getTimeFromLink(next) if err != nil { t.Fatalf("unexpected error parsing next link: %s", err) } exp = int64(1601412640) if nTime != exp { t.Errorf("next time exp: %v != got: %v", exp, nTime) } } // TestSplitOnIdx tests both findIdxBySeq() and the split functionality // that is used when a timer expires in the outputLoop() function. Indexes // and splitting (especially at slice boundaries) are particularly error prone. func TestSplitOnIdx(t *testing.T) { for i, test := range []struct { seq int logs []Log expleft int expright int }{ { seq: 4, logs: []Log{ {SequenceNum: 0}, {SequenceNum: 1}, {SequenceNum: 2}, {SequenceNum: 3}, {SequenceNum: 4}, {SequenceNum: 5}, {SequenceNum: 6}, {SequenceNum: 7}, }, expleft: 5, expright: 3, }, { seq: 1, logs: []Log{ {SequenceNum: 0}, {SequenceNum: 1}, {SequenceNum: 2}, {SequenceNum: 3}, }, expleft: 2, expright: 2, }, { seq: 4, logs: []Log{ {SequenceNum: 1}, {SequenceNum: 2}, {SequenceNum: 3}, {SequenceNum: 4}, }, expleft: 4, expright: 0, }, { seq: 6, logs: []Log{ {SequenceNum: 1}, {SequenceNum: 3}, {SequenceNum: 5}, }, expleft: 3, expright: 0, }, } { idx := findIdxBySeq(test.logs, test.seq) left, right := test.logs[:idx], test.logs[idx:] if len(left) != test.expleft { t.Errorf("#%d: exp: %d != got: %d", i, test.expleft, len(left)) } if len(right) != test.expright { t.Errorf("#%d: exp: %d != got: %d", i, test.expright, len(right)) } } } // TestHighSequence tests that we correctly get the highest // SequenceNum in a slice of logs. func TestHighSequence(t *testing.T) { for i, test := range []struct { logs []Log exp int }{ { logs: []Log{ {SequenceNum: 0}, {SequenceNum: 1}, {SequenceNum: 2}, }, exp: 2, }, { logs: []Log{ {SequenceNum: 2}, {SequenceNum: 1}, {SequenceNum: 0}, }, exp: 2, }, { logs: []Log{ {SequenceNum: 1}, {SequenceNum: 1}, }, exp: 1, }, { logs: []Log{}, exp: 0, }, } { if got := highSequence(test.logs); got != test.exp { t.Errorf("#%d: exp: %d != got: %d", i, test.exp, got) } } } ================================================ FILE: pkg/commands/logtail/testdata/response.json ================================================ { "batch_id": "MC0x", "logs": [ { "sequence_number": 1, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1" }, { "sequence_number": 2, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2" }, { "sequence_number": 3, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3" }, { "sequence_number": 4, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4" }, { "sequence_number": 5, "request_start_us": 1601645172164667, "stream": "stderr", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5" }, { "sequence_number": 6, "request_start_us": 1601645172164667, "stream": "stderr", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6" }, { "sequence_number": 7, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7" }, { "sequence_number": 8, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8" }, { "sequence_number": 9, "request_start_us": 1601645172164667, "stream": "stderr", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9" }, { "sequence_number": 10, "request_start_us": 1601645172164667, "stream": "stdout", "id": "44a1eedd-5831-49fe-b094-7435908ba1fb", "message": "10 10 10 10 10 10 10 10 10 10 10 10 10 10" } ] } ================================================ FILE: pkg/commands/ngwaf/countrylist/countrylist_test.go ================================================ package countrylist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/countrylist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "us" listType = "country" listName = "listName" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } func TestCountryListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s", listName), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s", listEntries), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), 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("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Account Country List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --json", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestCountryListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Account Country List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestCountryListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestCountryListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } 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 workspaces)", 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(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { 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(listsObject))), }, }, }, WantOutput: listListsString, }, { 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(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestCountryListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Account Country List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --json", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList country account us 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 country account us 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: country Entries: us Scope: account Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/countrylist/create.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level country lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string // Optional. description 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 account-level country list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, Name: c.name, Type: "country", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Account Country List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/countrylist/delete.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level country list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Account Country List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/countrylist/doc.go ================================================ // Package countrylist contains commands to inspect and manipulate NGWAF account-level country lists. package countrylist ================================================ FILE: pkg/commands/ngwaf/countrylist/get.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get an account-level country list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account-level country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/countrylist/list.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all country lists for your API token. 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 all country lists for your account") // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeAccount, Type: "country", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/countrylist/root.go ================================================ package countrylist 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 = "country-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, "Manage NGWAF Account Country Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/countrylist/update.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account country list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string // Optional. description argparser.OptionalString entries 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 account-level country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Account Country List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/customsignal/create.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 account-level custom signals. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. name string // Optional. description 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 account-level custom signal").Alias("add") // Required. c.CmdClause.Flag("name", "User submitted display name of a custom signal. Is immutable and must be between 3 and 25 characters").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of a custom signal.").Action(c.description.Set).StringVar(&c.description.Value) 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 } var err error input := &signals.CreateInput{ Name: &c.name, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, } if c.description.WasSet { input.Description = &c.description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := signals.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created account-level custom signal '%s' (signal-id: %s)", data.Name, data.SignalID) return nil } ================================================ FILE: pkg/commands/ngwaf/customsignal/customsignal_test.go ================================================ package customsignal_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/customsignal" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" ) const ( customSignalDescription = "NGWAFCLICustomSignal" customSignalID = "someID" customSignalName = "CLICustomSignalName" ) var customSignal = signals.Signal{ CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, Scope: signals.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, } func TestCustomSignalCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --name flag", Args: fmt.Sprintf("--description %s", customSignalDescription), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--description %s --name %s", customSignalDescription, customSignalName), 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 --name %s", customSignalDescription, customSignalName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: fstfmt.Success("Created account-level custom signal '%s' (signal-id: %s)", customSignalName, customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--description %s --name %s --json", customSignalDescription, customSignalName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignal))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestCustomSignalDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: "", WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate bad request", Args: "--signal-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 signal ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s", customSignalID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted account-level custom signal (id: %s)", customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --json", customSignalID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, customSignalID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestCustomSignalGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: "", WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate bad request", Args: "--signal-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 Custom Signal ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s", customSignalID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: customSignalString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --json", customSignalID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestCustomSignalList(t *testing.T) { customSignalsObject := signals.Signals{ Data: []signals.Signal{ { CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, Scope: signals.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, }, { CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName + "2", SignalID: customSignalID + "2", Scope: signals.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, }, }, Meta: signals.MetaSignals{}, } 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 account-level custom signals)", 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(signals.Signals{ Data: []signals.Signal{}, Meta: signals.MetaSignals{}, }))), }, }, }, WantOutput: zeroListCustomSignalsString, }, { 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(customSignalsObject))), }, }, }, WantOutput: listCustomSignalsString, }, { 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(customSignalsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignalsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestCustomSignalUpdate(t *testing.T) { customSignalObject := signals.Signal{ CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: fmt.Sprintf("--description %s", customSignalDescription), WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate missing --description flag", Args: fmt.Sprintf("--signal-id %s", customSignalID), WantError: "error parsing arguments: required flag --description not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s --description %s", customSignalID, customSignalDescription), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignalObject))), }, }, }, WantOutput: fstfmt.Success("Updated account-level signal '%s' (signal-id: %s)", customSignalName, customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --description %s --json", customSignalID, customSignalDescription), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignal))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listCustomSignalsString = strings.TrimSpace(` ID Name Description Scope Updated At Created At someID CLICustomSignalName NGWAFCLICustomSignal account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someID2 CLICustomSignalName2 NGWAFCLICustomSignal account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListCustomSignalsString = strings.TrimSpace(` ID Name Description Scope Updated At Created At `) + "\n" var customSignalString = strings.TrimSpace(` ID: someID Name: CLICustomSignalName Description: NGWAFCLICustomSignal Scope: account Updated (UTC): 0001-01-01 00:00 Created (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/customsignal/delete.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 account-level custom signal. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. signalID 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 account-level custom signal") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) // 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 := signals.Delete(context.TODO(), fc, &signals.DeleteInput{ SignalID: &c.signalID, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, }) 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.signalID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted account-level custom signal (id: %s)", c.signalID) return nil } ================================================ FILE: pkg/commands/ngwaf/customsignal/doc.go ================================================ // Package customsignal contains commands to inspect and manipulate NGWAF account-level custom signals. package customsignal ================================================ FILE: pkg/commands/ngwaf/customsignal/get.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 account-level custom signal. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. signalID 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 a custom signal") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) // 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") } data, err := signals.Get(context.TODO(), fc, &signals.GetInput{ SignalID: &c.signalID, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintCustomSignal(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/customsignal/list.go ================================================ package customsignal import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" ) // ListCommand calls the Fastly API to list all account-level custom signals for your API token. 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 all account-level custom signals") // 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") } signals, err := signals.List(context.TODO(), fc, &signals.ListInput{ Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, signals); ok { return err } text.PrintCustomSignalTbl(out, signals.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/customsignal/root.go ================================================ package customsignal 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 = "customsignal" // 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 NGWAF Account-Level Custom Signals") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/customsignal/update.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 account-level custom signals. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. signalID string description 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 workspace") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) c.CmdClause.Flag("description", "User submitted description of a custom signal.").Required().StringVar(&c.description) // Optional. c.RegisterFlagBool(c.JSONFlag()) 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 } var err error input := &signals.UpdateInput{ SignalID: &c.signalID, Description: &c.description, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := signals.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated account-level signal '%s' (signal-id: %s)", data.Name, data.SignalID) return nil } ================================================ FILE: pkg/commands/ngwaf/doc.go ================================================ // Package ngwaf contains commands to inspect and manipulate NGWAF objects. package ngwaf ================================================ FILE: pkg/commands/ngwaf/iplist/create.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level ip lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string // Optional. description 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 account-level ip list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, Name: c.name, Type: "ip", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Account IP List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/iplist/delete.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level ip list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Account IP List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/iplist/doc.go ================================================ // Package iplist contains commands to inspect and manipulate NGWAF account-level ip lists. package iplist ================================================ FILE: pkg/commands/ngwaf/iplist/get.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get an account-level ip list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account-level ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/iplist/iplist_test.go ================================================ package iplist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/iplist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "ip" listName = "listName" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } func TestIPListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s", listName), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s", listEntries), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), 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("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Account IP List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --json", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestIPListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Account IP List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestIPListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestIPListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } 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 workspaces)", 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(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { 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(listsObject))), }, }, }, WantOutput: listListsString, }, { 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(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestIPListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Account IP List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --json", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList ip account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 ip account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: ip Entries: 1.0.0.0 Scope: account Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/iplist/list.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all ip lists for your API token. 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 all ip lists for your account") // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeAccount, Type: "ip", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/iplist/root.go ================================================ package iplist 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 = "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, "Manage NGWAF Account IP Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/iplist/update.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account ip list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string // Optional. description argparser.OptionalString entries 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 account-level ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Account IP List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/ngwaflist/api.go ================================================ package ngwaflist import ( "context" "strings" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) type ListCreateInput struct { CommandScope scope.Type Description argparser.OptionalString Entries string Name string Type string WorkspaceID *argparser.OptionalWorkspaceID FC *fastly.Client } func ListCreate(argsInput ListCreateInput) (*lists.List, error) { input := lists.CreateInput{ Entries: fastly.ToPointer(strings.Split(argparser.Content(argsInput.Entries), ",")), Name: &argsInput.Name, Type: &argsInput.Type, } if argsInput.Description.WasSet { input.Description = &argsInput.Description.Value } inputWorkspaceID := "" if argsInput.CommandScope == scope.ScopeTypeWorkspace { if err := argsInput.WorkspaceID.Parse(); err != nil { return nil, err } inputWorkspaceID = argsInput.WorkspaceID.Value } var err error input.Scope, err = generateScope(argsInput.CommandScope, inputWorkspaceID) if err != nil { return nil, err } return lists.Create(context.TODO(), argsInput.FC, &input) } type ListDeleteInput struct { CommandScope scope.Type ListID string WorkspaceID *argparser.OptionalWorkspaceID FC *fastly.Client } func ListDelete(argsInput ListDeleteInput) error { input := lists.DeleteInput{ ListID: &argsInput.ListID, } inputWorkspaceID := "" if argsInput.CommandScope == scope.ScopeTypeWorkspace { if err := argsInput.WorkspaceID.Parse(); err != nil { return err } inputWorkspaceID = argsInput.WorkspaceID.Value } var err error input.Scope, err = generateScope(argsInput.CommandScope, inputWorkspaceID) if err != nil { return err } return lists.Delete(context.TODO(), argsInput.FC, &input) } type ListGetInput struct { CommandScope scope.Type ListID string WorkspaceID *argparser.OptionalWorkspaceID FC *fastly.Client } func ListGet(argsInput ListGetInput) (*lists.List, error) { input := lists.GetInput{ ListID: &argsInput.ListID, } inputWorkspaceID := "" if argsInput.CommandScope == scope.ScopeTypeWorkspace { if err := argsInput.WorkspaceID.Parse(); err != nil { return nil, err } inputWorkspaceID = argsInput.WorkspaceID.Value } var err error input.Scope, err = generateScope(argsInput.CommandScope, inputWorkspaceID) if err != nil { return nil, err } return lists.Get(context.TODO(), argsInput.FC, &input) } type ListListInput struct { CommandScope scope.Type ListID string Type string WorkspaceID *argparser.OptionalWorkspaceID FC *fastly.Client } func ListList(argsInput ListListInput) (*lists.Lists, error) { input := lists.ListInput{} inputWorkspaceID := "" if argsInput.CommandScope == scope.ScopeTypeWorkspace { if err := argsInput.WorkspaceID.Parse(); err != nil { return nil, err } inputWorkspaceID = argsInput.WorkspaceID.Value } var err error input.Scope, err = generateScope(argsInput.CommandScope, inputWorkspaceID) if err != nil { return nil, err } data, err := lists.ListLists(context.TODO(), argsInput.FC, &input) if err != nil { return nil, err } listFilteredByType := []lists.List{} for _, list := range data.Data { if list.Type == argsInput.Type { listFilteredByType = append(listFilteredByType, list) } } data.Data = listFilteredByType return data, nil } type ListUpdateInput struct { CommandScope scope.Type Description argparser.OptionalString Entries argparser.OptionalString ListID string WorkspaceID *argparser.OptionalWorkspaceID FC *fastly.Client } func ListUpdate(argsInput ListUpdateInput) (*lists.List, error) { input := lists.UpdateInput{ ListID: &argsInput.ListID, } if argsInput.Description.WasSet { input.Description = &argsInput.Description.Value } if argsInput.Entries.WasSet { input.Entries = fastly.ToPointer(strings.Split(argparser.Content(argsInput.Entries.Value), ",")) } inputWorkspaceID := "" if argsInput.CommandScope == scope.ScopeTypeWorkspace { if err := argsInput.WorkspaceID.Parse(); err != nil { return nil, err } inputWorkspaceID = argsInput.WorkspaceID.Value } var err error input.Scope, err = generateScope(argsInput.CommandScope, inputWorkspaceID) if err != nil { return nil, err } return lists.Update(context.TODO(), argsInput.FC, &input) } func generateScope(inputScope scope.Type, workspaceID string) (*scope.Scope, error) { if inputScope == scope.ScopeTypeAccount { return &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: ngwaf.DefaultAccountScope, }, nil } if inputScope == scope.ScopeTypeWorkspace { return &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{workspaceID}, }, nil } return nil, fsterr.ErrInvalidNGWAFScopeType } ================================================ FILE: pkg/commands/ngwaf/ngwaflist/doc.go ================================================ // Package list contains generic api calls to inspect and manipulate NGWAF account lists. package ngwaflist ================================================ FILE: pkg/commands/ngwaf/root.go ================================================ package ngwaf import ( "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // 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 = "ngwaf" // 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 NGWAF") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } var ScopeTypes = []string{string(scope.ScopeTypeAccount), string(scope.ScopeTypeWorkspace)} var DefaultAccountScope = []string{"*"} ================================================ FILE: pkg/commands/ngwaf/rule/create.go ================================================ package rule import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 account-level rules. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. path 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 account-level rule").Alias("add") // Required. c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) // 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 } var err error rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { return fmt.Errorf("error parsing path '%s': %q", c.path, err) } jsonFile, err := os.Open(path) if err != nil { return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() byteValue, err := io.ReadAll(jsonFile) if err != nil { return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } input := &rules.CreateInput{ Actions: []*rules.CreateAction{}, Conditions: []*rules.CreateCondition{}, Description: &rule.Description, GroupConditions: []*rules.CreateGroupCondition{}, MultivalConditions: []*rules.CreateMultivalCondition{}, Enabled: &rule.Enabled, Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, } for _, action := range rule.Actions { input.Actions = append(input.Actions, &rules.CreateAction{ AllowInteractive: action.AllowInteractive, DeceptionType: &action.DeceptionType, RedirectURL: &action.RedirectURL, ResponseCode: &action.ResponseCode, Signal: &action.Signal, Type: &action.Type, }) } if rule.RateLimit != nil { input.RateLimit = &rules.CreateRateLimit{ ClientIdentifiers: []*rules.CreateClientIdentifier{}, Duration: &rule.RateLimit.Duration, Interval: &rule.RateLimit.Interval, Signal: &rule.RateLimit.Signal, Threshold: &rule.RateLimit.Threshold, } for _, rateLimit := range rule.RateLimit.ClientIdentifiers { input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.CreateClientIdentifier{ Key: &rateLimit.Key, Name: &rateLimit.Name, Type: &rateLimit.Type, }) } } for _, jsonCondition := range rule.Conditions { switch jsonCondition.Type { case "single": if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { input.Conditions = append(input.Conditions, &rules.CreateCondition{ Field: &sc.Field, Operator: &sc.Operator, Value: &sc.Value, }) } else { return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) } case "group": if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { parsedGroupCondition := &rules.CreateGroupCondition{ GroupOperator: &gc.GroupOperator, Conditions: []*rules.CreateCondition{}, } for _, groupCondition := range gc.Conditions { switch groupCondition.Type { case "single": if gsc, ok := groupCondition.Fields.(rules.Condition); ok { parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.CreateCondition{ Field: &gsc.Field, Operator: &gsc.Operator, Value: &gsc.Value, }) } else { return fmt.Errorf("expected Condition, got %T", groupCondition.Fields) } case "multival": if gmvc, ok := groupCondition.Fields.(rules.MultivalCondition); ok { createMultivalCondition := &rules.CreateMultivalCondition{ Field: &gmvc.Field, Operator: &gmvc.Operator, GroupOperator: &gmvc.GroupOperator, Conditions: []*rules.CreateConditionMult{}, } for _, groupMultivalSingleCondition := range gmvc.Conditions { createMultivalCondition.Conditions = append(createMultivalCondition.Conditions, &rules.CreateConditionMult{ Field: &groupMultivalSingleCondition.Field, Operator: &groupMultivalSingleCondition.Operator, Value: &groupMultivalSingleCondition.Value, }) } parsedGroupCondition.MultivalConditions = append(parsedGroupCondition.MultivalConditions, createMultivalCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", groupCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", groupCondition.Type) } } input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) } else { return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) } case "multival": if mvc, ok := jsonCondition.Fields.(rules.CreateMultivalCondition); ok { parsedMultiValCondition := &rules.CreateMultivalCondition{ Field: mvc.Field, GroupOperator: mvc.GroupOperator, Operator: mvc.Operator, Conditions: []*rules.CreateConditionMult{}, } for _, multiSingleCondition := range mvc.Conditions { parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.CreateConditionMult{ Field: multiSingleCondition.Field, Operator: multiSingleCondition.Operator, Value: multiSingleCondition.Value, }) } input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) } } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := rules.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created account-level rule with ID %s", data.RuleID) return nil } ================================================ FILE: pkg/commands/ngwaf/rule/delete.go ================================================ package rule import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 account-level rule. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. ruleID 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 account-level rule") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) // 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 := rules.Delete(context.TODO(), fc, &rules.DeleteInput{ RuleID: &c.ruleID, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, }) 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.ruleID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted account-level rule with id: %s", c.ruleID) return nil } ================================================ FILE: pkg/commands/ngwaf/rule/doc.go ================================================ // Package rule contains commands to inspect and manipulate NGWAF account-level rules. package rule ================================================ FILE: pkg/commands/ngwaf/rule/get.go ================================================ package rule import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 account-level rule. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. ruleID 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 account-level rule") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) // 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") } data, err := rules.Get(context.TODO(), fc, &rules.GetInput{ RuleID: &c.ruleID, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintRule(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/rule/list.go ================================================ package rule import ( "context" "errors" "io" "strconv" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all account-level rules for your API token. type ListCommand struct { argparser.Base argparser.JSONOutput // Optional. action argparser.OptionalString enabled 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 all account-level rules") // Optional. c.CmdClause.Flag("action", "Filter rules based on action.").Action(c.action.Set).StringVar(&c.action.Value) c.CmdClause.Flag("enabled", "Filter rules based on whether the rule is enabled.").Action(c.enabled.Set).StringVar(&c.enabled.Value) 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") } input := &rules.ListInput{ Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, } if c.action.WasSet { input.Action = &c.action.Value } if c.enabled.WasSet { enabled, _ := strconv.ParseBool(c.enabled.Value) input.Enabled = &enabled } rules, err := rules.List(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, rules); ok { return err } text.PrintRuleTbl(out, rules.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/rule/root.go ================================================ package rule 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 = "rule" // 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 NGWAF Account-Level Rules") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/rule/rule_test.go ================================================ package rule_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/rule" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( complexRulePath = "testdata/test_complex_rule.json" complexRuleID = "someComplexID" ruleDescription = "Utility requests" ruleEnabled = true ruleAction = "allow" ruleID = "someID" rulePath = "testdata/test_rule.json" ruleType = "request" ) var rule = rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: ruleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, } var complexRule = rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: complexRuleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, } func TestRuleCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --path flag", Args: "", WantError: "error parsing arguments: required flag --path not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--path %s", rulePath), 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("--path %s", rulePath), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: fstfmt.Success("Created account-level rule with ID %s", ruleID), }, { Name: "validate API success with complex rule", Args: fmt.Sprintf("--path %s", complexRulePath), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(complexRule)))), }, }, }, WantOutput: fstfmt.Success("Created account-level rule with ID %s", complexRuleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--path %s --json", rulePath), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestRuleDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --rule-id flag", Args: "", WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate bad request", Args: "--rule-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 rule ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s", ruleID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted account-level rule with id: %s", ruleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --json", ruleID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, ruleID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestRuleGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --rule-id flag", Args: "", WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate bad request", Args: "--rule-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 Rule ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s", ruleID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: ruleString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --json", ruleID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestRuleList(t *testing.T) { rulesObject := rules.Rules{ Data: []rules.Rule{ { CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: ruleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, }, { CreatedAt: testutil.Date, Description: ruleDescription + "2", Enabled: ruleEnabled, RuleID: ruleID + "2", Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeAccount), AppliesTo: []string{"*"}, }, }, }, Meta: rules.MetaRules{}, } 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 account-level Rules)", 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(rules.Rules{ Data: []rules.Rule{}, Meta: rules.MetaRules{}, }))), }, }, }, WantOutput: zeroListRulesString, }, { 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(rulesObject))), }, }, }, WantOutput: listRulesString, }, { 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(rulesObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(rulesObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestRuleUpdate(t *testing.T) { ruleObject := rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, RuleID: ruleID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --rule-id flag", Args: fmt.Sprintf("--path %s", rulePath), WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate missing --path flag", Args: fmt.Sprintf("--rule-id %s", ruleID), WantError: "error parsing arguments: required flag --path not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s --path %s", ruleID, rulePath), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(ruleObject))), }, }, }, WantOutput: fstfmt.Success("Updated account-level rule with id: %s", ruleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --path %s --json", ruleID, rulePath), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listRulesString = strings.TrimSpace(` ID Action Description Enabled Type Scope Updated At Created At someID allow Utility requests true request account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someID2 allow Utility requests2 true request account 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListRulesString = strings.TrimSpace(` ID Action Description Enabled Type Scope Updated At Created At `) + "\n" var ruleString = strings.TrimSpace(` ID: someID Action: allow Description: Utility requests Enabled: true Type: request Scope: account Updated (UTC): 0001-01-01 00:00 Created (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/rule/testdata/test_complex_rule.json ================================================ { "type": "request", "description": "complex_test", "enabled": true, "expires_at": "", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "equals", "value": "1.2.3.4" }, { "type": "single", "field": "country", "operator": "equals", "value": "AE" }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "equals", "value": "2.4.5.6" }, { "type": "single", "field": "country", "operator": "equals", "value": "AD" } ] }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "domain", "operator": "equals", "value": "test.com" }, { "type": "single", "field": "agent_name", "operator": "equals", "value": "test" } ] }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "equals", "value": "0.0.0.0" }, { "type": "multival", "field": "request_header", "operator": "exists", "group_operator": "all", "conditions": [ { "type": "single", "field": "name", "operator": "equals", "value": "x-something" }, { "type": "single", "field": "value_string", "operator": "equals", "value": "abc-123" } ] } ] } ], "actions": [ { "type": "allow" } ], "request_logging": "none" } ================================================ FILE: pkg/commands/ngwaf/rule/testdata/test_rule.json ================================================ { "type": "request", "enabled": true, "description": "Utility requests", "group_operator": "all", "request_logging": "sampled", "conditions": [ { "type": "single", "field": "path", "operator": "equals", "value": "/echo.json" } ], "actions": [ { "type": "allow" } ] } ================================================ FILE: pkg/commands/ngwaf/rule/update.go ================================================ package rule import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 an account-level rule. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. path string ruleID 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 workspace") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) // Optional. c.RegisterFlagBool(c.JSONFlag()) 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 } var err error rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { return fmt.Errorf("error parsing path '%s': %q", c.path, err) } jsonFile, err := os.Open(path) if err != nil { return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() byteValue, err := io.ReadAll(jsonFile) if err != nil { return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } input := &rules.UpdateInput{ RuleID: &c.ruleID, Actions: []*rules.UpdateAction{}, Conditions: []*rules.UpdateCondition{}, Description: &rule.Description, GroupConditions: []*rules.UpdateGroupCondition{}, MultivalConditions: []*rules.UpdateMultivalCondition{}, Enabled: &rule.Enabled, Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, Scope: &scope.Scope{ Type: scope.ScopeTypeAccount, AppliesTo: []string{"*"}, }, } for _, action := range rule.Actions { input.Actions = append(input.Actions, &rules.UpdateAction{ AllowInteractive: action.AllowInteractive, DeceptionType: &action.DeceptionType, RedirectURL: &action.RedirectURL, ResponseCode: &action.ResponseCode, Signal: &action.Signal, Type: &action.Type, }) } if rule.RateLimit != nil { input.RateLimit = &rules.UpdateRateLimit{ ClientIdentifiers: []*rules.UpdateClientIdentifier{}, Duration: &rule.RateLimit.Duration, Interval: &rule.RateLimit.Interval, Signal: &rule.RateLimit.Signal, Threshold: &rule.RateLimit.Threshold, } for _, rateLimit := range rule.RateLimit.ClientIdentifiers { input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.UpdateClientIdentifier{ Key: &rateLimit.Key, Name: &rateLimit.Name, Type: &rateLimit.Type, }) } } for _, jsonCondition := range rule.Conditions { switch jsonCondition.Type { case "single": if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { input.Conditions = append(input.Conditions, &rules.UpdateCondition{ Field: &sc.Field, Operator: &sc.Operator, Value: &sc.Value, }) } else { return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) } case "group": if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { parsedGroupCondition := &rules.UpdateGroupCondition{ GroupOperator: &gc.GroupOperator, Conditions: []*rules.UpdateCondition{}, } for _, groupCondition := range gc.Conditions { switch groupCondition.Type { case "single": if gsc, ok := groupCondition.Fields.(rules.Condition); ok { parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.UpdateCondition{ Field: &gsc.Field, Operator: &gsc.Operator, Value: &gsc.Value, }) } else { return fmt.Errorf("expected Condition, got %T", groupCondition.Fields) } case "multival": if gmvc, ok := groupCondition.Fields.(rules.MultivalCondition); ok { updateMultivalCondition := &rules.UpdateMultivalCondition{ Field: &gmvc.Field, Operator: &gmvc.Operator, GroupOperator: &gmvc.GroupOperator, Conditions: []*rules.UpdateConditionMult{}, } for _, groupMultivalSingleCondition := range gmvc.Conditions { updateMultivalCondition.Conditions = append(updateMultivalCondition.Conditions, &rules.UpdateConditionMult{ Field: &groupMultivalSingleCondition.Field, Operator: &groupMultivalSingleCondition.Operator, Value: &groupMultivalSingleCondition.Value, }) } parsedGroupCondition.MultivalConditions = append(parsedGroupCondition.MultivalConditions, updateMultivalCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", groupCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", groupCondition.Type) } } input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) } else { return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) } case "multival": if mvc, ok := jsonCondition.Fields.(rules.UpdateMultivalCondition); ok { parsedMultiValCondition := &rules.UpdateMultivalCondition{ Field: mvc.Field, GroupOperator: mvc.GroupOperator, Operator: mvc.Operator, Conditions: []*rules.UpdateConditionMult{}, } for _, multiSingleCondition := range mvc.Conditions { parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.UpdateConditionMult{ Field: multiSingleCondition.Field, Operator: multiSingleCondition.Operator, Value: multiSingleCondition.Value, }) } input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) } } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := rules.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated account-level rule with id: %s", data.RuleID) return nil } ================================================ FILE: pkg/commands/ngwaf/signallist/create.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level signal lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string // Optional. description 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 account-level signal list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, Name: c.name, Type: "signal", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Account Signal List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/signallist/delete.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level signal list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Account Signal List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/signallist/doc.go ================================================ // Package signallist contains commands to inspect and manipulate NGWAF account-level signal lists. package signallist ================================================ FILE: pkg/commands/ngwaf/signallist/get.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get an account-level signal list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account-level signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/signallist/list.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all signal lists for your API token. 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 all signal lists for your account") // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeAccount, Type: "signal", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/signallist/root.go ================================================ package signallist 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 = "signal-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, "Manage NGWAF Account Signal Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/signallist/signallist_test.go ================================================ package signallist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/signallist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "BHH" listType = "signal" listName = "listName" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } func TestSignalListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s", listName), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s", listEntries), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), 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("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Account Signal List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --json", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestSignalListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Account Signal List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestSignalListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestSignalListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } 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 workspaces)", 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(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { 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(listsObject))), }, }, }, WantOutput: listListsString, }, { 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(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestSignalListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Account Signal List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --json", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList signal account BHH 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 signal account BHH 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: signal Entries: BHH Scope: account Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/signallist/update.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account signal list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string // Optional. description argparser.OptionalString entries 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 account-level signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Account Signal List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/stringlist/create.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level string lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string // Optional. description 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 account-level string list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, Name: c.name, Type: "string", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Account String List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/stringlist/delete.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level string list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Account String List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/stringlist/doc.go ================================================ // Package stringlist contains commands to inspect and manipulate NGWAF account-level string lists. package stringlist ================================================ FILE: pkg/commands/ngwaf/stringlist/get.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get an account-level string list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account-level string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/stringlist/list.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all string lists for your API token. 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 all string lists for your account") // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeAccount, Type: "string", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/stringlist/root.go ================================================ package stringlist 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 = "string-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, "Manage NGWAF Account String Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/stringlist/stringlist_test.go ================================================ package stringlist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "string" listName = "listName" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } func TestStringListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s", listName), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s", listEntries), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), 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("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Account String List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --json", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestStringListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Account String List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestStringListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestStringListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } 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 workspaces)", 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(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { 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(listsObject))), }, }, }, WantOutput: listListsString, }, { 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(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestStringListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Account String List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --json", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList string account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 string account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: string Entries: 1.0.0.0 Scope: account Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/stringlist/update.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account string list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string // Optional. description argparser.OptionalString entries 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 account-level string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Account String List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/create.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level wildcard lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string // Optional. description 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 account-level wildcard list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, Name: c.name, Type: "wildcard", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Account Wildcard List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/delete.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level wildcardip list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Account Wildcard List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/doc.go ================================================ // Package wildcardlist contains commands to inspect and manipulate NGWAF account-level wildcard lists. package wildcardlist ================================================ FILE: pkg/commands/ngwaf/wildcardlist/get.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get an account-level wildcard list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID 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 account-level wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeAccount, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/list.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all wildcard lists for your API token. 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 all wildcard lists for your account") // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeAccount, Type: "wildcard", WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/root.go ================================================ package wildcardlist 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 = "wildcard-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, "Manage NGWAF Account Wildcard Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/update.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account wildcard list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string // Optional. description argparser.OptionalString entries 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 account-level wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeAccount, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: nil, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Account Wildcard List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/wildcardlist/wildcardlist_test.go ================================================ package wildcardlist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/wildcardlist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "wildcard" listName = "listName" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } func TestWildcardListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s", listName), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s", listEntries), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), 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("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Account Wildcard List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --json", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestWildcardListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Account Wildcard List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestWildcardListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate bad request", Args: "--list-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --json", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestWildcardListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } 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 workspaces)", 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(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { 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(listsObject))), }, }, }, WantOutput: listListsString, }, { 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(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestWildcardListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeAccount), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: "", WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Account Wildcard List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --json", listID, listDescription+"2", listEntries+"2"), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList wildcard account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 wildcard account 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: wildcard Entries: 1.0.0.0 Scope: account Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/create.go ================================================ package datadog import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) // CreateCommand calls the Fastly API to create Datadog alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Key string Site string // Optional. Description 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 alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("key", "Datadog integration key.").Required().StringVar(&c.Key) c.CmdClause.Flag("site", "Datadog site.").Required().StringVar(&c.Site) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &datadog.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &datadog.CreateConfig{ Key: &c.Key, Site: &c.Site, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := datadog.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/datadog_test.go ================================================ package datadog_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/datadog" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) const ( alertID = "7890abcdef12345678901234" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestDatadogAlert" ) var ( key = "a1b2c3d4e5f67890abcdef1234567890" site = "datadoghq.com" datadogAlert = datadog.Alert{ ID: alertID, Type: "datadog", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: datadog.ResponseConfig{ Key: &key, Site: &site, }, } ) func TestDatadogAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--key %s --site %s", key, site), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --key flag", Args: fmt.Sprintf("--workspace-id %s --site %s", workspaceID, site), WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate missing --site flag", Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), WantError: "error parsing arguments: required flag --site not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --key %s --site %s", workspaceID, key, site), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", datadogAlert.Type, datadogAlert.ID, workspaceID), }, { Name: "validate API success with description", Args: fmt.Sprintf("--workspace-id %s --key %s --site %s --description %s", workspaceID, key, site, description), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", datadogAlert.Type, datadogAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --key %s --site %s --json", workspaceID, key, site), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(datadogAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestDatadogAlertList(t *testing.T) { alertsObject := datadog.Alerts{ Data: []datadog.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "datadog", Description: "First Datadog alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: datadog.ResponseConfig{ Key: &key, Site: &site, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "datadog", Description: "Second Datadog alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: datadog.ResponseConfig{ Key: &key, Site: &site, }, }, }, Meta: datadog.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(datadog.Alerts{ Data: []datadog.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestDatadogAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(datadogAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestDatadogAlertUpdate(t *testing.T) { updatedKey := "updated-key-9876543210" updatedSite := "datadoghq.eu" updatedDescription := "Updated description" updatedAlert := datadog.Alert{ ID: alertID, Type: "datadog", Description: updatedDescription, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: datadog.ResponseConfig{ Key: &updatedKey, Site: &updatedSite, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --key %s --site %s", alertID, key, site), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --key %s --site %s", workspaceID, key, site), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-9876543210 --site datadoghq.eu", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with key and site", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --site datadoghq.eu", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ // First response for GET (fetching current alert) { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, // Second response for PATCH (updating alert) { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(updatedAlert)))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --site datadoghq.eu --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ // First response for GET (fetching current alert) { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(datadogAlert)))), }, // Second response for PATCH (updating alert) { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(updatedAlert)))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestDatadogAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 7890abcdef12345678901234 Type: datadog Description: TestDatadogAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Key: Site: datadoghq.com `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 datadog First Datadog alert 2025-11-25T16:40:12Z test@example.com Site: datadoghq.com, Key: 2b3c4d5e6f7890abcdef1234 datadog Second Datadog alert 2025-11-25T16:40:12Z test@example.com Site: datadoghq.com, Key: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/delete.go ================================================ package datadog import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) // DeleteCommand calls the Fastly API to delete Datadog alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := datadog.Delete(context.TODO(), fc, &datadog.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/doc.go ================================================ // Package datadog contains commands to inspect and manipulate NGWAF Datadog alerts. package datadog ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/get.go ================================================ package datadog import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) // GetCommand calls the Fastly API to get Datadog alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Datadog alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &datadog.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := datadog.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/list.go ================================================ package datadog import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) // ListCommand calls the Fastly API to list Datadog alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &datadog.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := datadog.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/root.go ================================================ 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, "Manage Datadog workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } // ConfigFlags contains Datadog specific configuration flags. type ConfigFlags struct { Key string Site string } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/datadog/update.go ================================================ package datadog import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" ) // UpdateCommand calls the Fastly API to update Datadog alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Key string Site 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 Datadog alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("key", "Datadog integration key.").Required().StringVar(&c.Key) c.CmdClause.Flag("site", "Datadog site.").Required().StringVar(&c.Site) // Optional. c.RegisterFlagBool(c.JSONFlag()) 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } input := &datadog.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &datadog.UpdateConfig{ Key: &c.Key, Site: &c.Site, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := datadog.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/doc.go ================================================ // Package alert contains commands to inspect and manipulate NGWAF alerts. package alert ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/create.go ================================================ package jira import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) // CreateCommand calls the Fastly API to create Jira alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Host string Key string Project string Username string // Optional. Description argparser.OptionalString IssueType 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 Jira alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("host", "Host name of the Jira instance.").Required().StringVar(&c.Host) c.CmdClause.Flag("key", "Jira API key.").Required().StringVar(&c.Key) c.CmdClause.Flag("project", "Specifies the Jira project where the issue will be created.").Required().StringVar(&c.Project) c.CmdClause.Flag("username", "Jira username of the user who created the ticket.").Required().StringVar(&c.Username) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.CmdClause.Flag("issue-type", "An optional Jira issue type associated with the ticket. (Default Task)").StringVar(&c.IssueType) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &jira.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &jira.CreateConfig{ Host: &c.Host, Key: &c.Key, Project: &c.Project, Username: &c.Username, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.IssueType != "" { input.Config.IssueType = &c.IssueType } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := jira.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/delete.go ================================================ package jira import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) // DeleteCommand calls the Fastly API to delete Jira alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Jira alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := jira.Delete(context.TODO(), fc, &jira.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/doc.go ================================================ // Package jira contains commands to inspect and manipulate NGWAF Jira alerts. package jira ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/get.go ================================================ package jira import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) // GetCommand calls the Fastly API to get Jira alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Jira alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &jira.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := jira.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/jira_test.go ================================================ package jira_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/jira" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) const ( alertID = "890abcdef1234567890123ab" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestJiraAlert" ) var ( host = "example.atlassian.net" key = "jira-api-key-123456" project = "PROJ" username = "user@example.com" issueType = "Task" jiraAlert = jira.Alert{ ID: alertID, Type: "jira", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: jira.ResponseConfig{ Host: &host, Key: &key, Project: &project, Username: &username, IssueType: &issueType, }, } ) func TestJiraAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--host %s --key %s --project %s --username %s", host, key, project, username), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --host flag", Args: fmt.Sprintf("--workspace-id %s --key %s --project %s --username %s", workspaceID, key, project, username), WantError: "error parsing arguments: required flag --host not provided", }, { Name: "validate missing --key flag", Args: fmt.Sprintf("--workspace-id %s --host %s --project %s --username %s", workspaceID, host, project, username), WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate missing --project flag", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --username %s", workspaceID, host, key, username), WantError: "error parsing arguments: required flag --project not provided", }, { Name: "validate missing --username flag", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s", workspaceID, host, key, project), WantError: "error parsing arguments: required flag --username not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s", workspaceID, host, key, project, username), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), }, { Name: "validate API success with description", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --description %s", workspaceID, host, key, project, username, description), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), }, { Name: "validate API success with issue-type", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --issue-type %s", workspaceID, host, key, project, username, issueType), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", jiraAlert.Type, jiraAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s --json", workspaceID, host, key, project, username), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(jiraAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestJiraAlertList(t *testing.T) { alertsObject := jira.Alerts{ Data: []jira.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "jira", Description: "First Jira alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: jira.ResponseConfig{ Host: &host, Key: &key, Project: &project, Username: &username, IssueType: &issueType, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "jira", Description: "Second Jira alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: jira.ResponseConfig{ Host: &host, Key: &key, Project: &project, Username: &username, IssueType: &issueType, }, }, }, Meta: jira.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jira.Alerts{ Data: []jira.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestJiraAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(jiraAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(jiraAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestJiraAlertUpdate(t *testing.T) { updatedHost := "updated.atlassian.net" updatedKey := "updated-jira-key-456" updatedProject := "UPDT" updatedUsername := "updated@example.com" updatedIssueType := "Bug" updatedAlert := jira.Alert{ ID: alertID, Type: "jira", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: jira.ResponseConfig{ Host: &updatedHost, Key: &updatedKey, Project: &updatedProject, Username: &updatedUsername, IssueType: &updatedIssueType, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --host %s --key %s --project %s --username %s", alertID, host, key, project, username), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --host %s --key %s --project %s --username %s", workspaceID, host, key, project, username), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with all config fields", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com --issue-type Bug", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jiraAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --host updated.atlassian.net --key updated-jira-key-456 --project UPDT --username updated@example.com --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(jiraAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestJiraAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 890abcdef1234567890123ab Type: jira Description: TestJiraAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Host: example.atlassian.net Username: user@example.com Project: PROJ Issue Type: Task Key: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 jira First Jira alert 2025-11-25T16:40:12Z test@example.com Host: example.atlassian.net, Issue Type: Task, Key: , Project: PROJ, Username: user@example.com 2b3c4d5e6f7890abcdef1234 jira Second Jira alert 2025-11-25T16:40:12Z test@example.com Host: example.atlassian.net, Issue Type: Task, Key: , Project: PROJ, Username: user@example.com `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/list.go ================================================ package jira import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) // ListCommand calls the Fastly API to list Jira alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Jira alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &jira.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := jira.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/root.go ================================================ package jira 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 = "jira" // 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 Jira workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } // ConfigFlags contains Jira specific configuration flags. type ConfigFlags struct { Host string Key string Project string Username string } // OptConfigFlags contains optional Jira specific configuration flags. type OptConfigFlags struct { IssueType string } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/jira/update.go ================================================ package jira import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" ) // UpdateCommand calls the Fastly API to update Jira alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Host string Key string Project string Username string // Optional IssueType 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 Jira alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("host", "Host name of the Jira instance.").Required().StringVar(&c.Host) c.CmdClause.Flag("key", "Jira API key.").Required().StringVar(&c.Key) c.CmdClause.Flag("project", "Specifies the Jira project where the issue will be created.").Required().StringVar(&c.Project) c.CmdClause.Flag("username", "Jira username of the user who created the ticket.").Required().StringVar(&c.Username) // Optional. c.CmdClause.Flag("issue-type", "An optional Jira issue type associated with the ticket. (Default Task)").StringVar(&c.IssueType) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &jira.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &jira.UpdateConfig{ Host: &c.Host, Key: &c.Key, Project: &c.Project, Username: &c.Username, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.IssueType != "" { input.Config.IssueType = &c.IssueType } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := jira.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/create.go ================================================ package mailinglist import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) // CreateCommand calls the Fastly API to create Mailing List alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Address string // Optional. Description 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 Mailing List alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("address", "An email address.").Required().StringVar(&c.Address) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &mailinglist.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &mailinglist.CreateConfig{ Address: &c.Address, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := mailinglist.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/delete.go ================================================ package mailinglist import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) // DeleteCommand calls the Fastly API to delete Mailing List alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Mailing List alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := mailinglist.Delete(context.TODO(), fc, &mailinglist.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/doc.go ================================================ // Package mailinglist contains commands to inspect and manipulate NGWAF Mailing List alerts. package mailinglist ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/get.go ================================================ package mailinglist import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) // GetCommand calls the Fastly API to get Mailing List alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Mailing List alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &mailinglist.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := mailinglist.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/list.go ================================================ package mailinglist import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) // ListCommand calls the Fastly API to list Mailing List alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Mailing List alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &mailinglist.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := mailinglist.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/mailinglist_test.go ================================================ package mailinglist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/mailinglist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) const ( alertID = "2b3c4d5e6f7890abcdef1234" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestMailingListAlert" ) var ( address = "alerts@example.com" alertAddress1 = "alerts1@example.com" alertAddress2 = "alerts2@example.com" mailinglistAlert = mailinglist.Alert{ ID: alertID, Type: "mailinglist", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: mailinglist.ResponseConfig{ Address: &address, }, } ) func TestMailingListAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--address %s", address), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --address flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --address not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --address %s", workspaceID, address), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", mailinglistAlert.Type, mailinglistAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --address %s --json", workspaceID, address), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(mailinglistAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestMailingListAlertList(t *testing.T) { alertsObject := mailinglist.Alerts{ Data: []mailinglist.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "mailinglist", Description: "First mailing list alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: mailinglist.ResponseConfig{ Address: &alertAddress1, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "mailinglist", Description: "Second mailing list alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: mailinglist.ResponseConfig{ Address: &alertAddress2, }, }, }, Meta: mailinglist.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglist.Alerts{ Data: []mailinglist.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestMailingListAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(mailinglistAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(mailinglistAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestMailingListAlertUpdate(t *testing.T) { updatedAddress := "updated@example.com" updatedAlert := mailinglist.Alert{ ID: alertID, Type: "mailingList", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: mailinglist.ResponseConfig{ Address: &updatedAddress, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --address %s", alertID, address), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --address %s", workspaceID, address), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --address updated@example.com", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with address", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --address updated@example.com", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglistAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --address updated@example.com --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(mailinglistAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestMailingListAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 2b3c4d5e6f7890abcdef1234 Type: mailinglist Description: TestMailingListAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Address: alerts@example.com `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 mailinglist First mailing list alert 2025-11-25T16:40:12Z test@example.com Address: alerts1@example.com 2b3c4d5e6f7890abcdef1234 mailinglist Second mailing list alert 2025-11-25T16:40:12Z test@example.com Address: alerts2@example.com `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/root.go ================================================ package mailinglist 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 = "mailinglist" // 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 Mailing List workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } // AddressConfigFlags contains Address configurations used by mailing lists. type AddressConfigFlags struct { Address string } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/mailinglist/update.go ================================================ package mailinglist import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" ) // UpdateCommand calls the Fastly API to update Mailing List alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Address 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 Mailing List alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("address", "An email address.").Required().StringVar(&c.Address) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &mailinglist.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &mailinglist.UpdateConfig{ Address: &c.Address, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := mailinglist.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/create.go ================================================ package microsoftteams import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) // CreateCommand calls the Fastly API to create Microsoft Teams alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Webhook string // Optional. Description 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 Microsoft Teams alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("webhook", "Microsoft Teams webhook.").Required().StringVar(&c.Webhook) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := µsoftteams.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: µsoftteams.CreateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := microsoftteams.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/delete.go ================================================ package microsoftteams import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) // DeleteCommand calls the Fastly API to delete Microsoft Teams alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Microsoft Teams alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := microsoftteams.Delete(context.TODO(), fc, µsoftteams.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/doc.go ================================================ // Package microsoftteams contains commands to inspect and manipulate NGWAF Microsoft Teams alerts. package microsoftteams ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/get.go ================================================ package microsoftteams import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) // GetCommand calls the Fastly API to get Microsoft Teams alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Microsoft Teams alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := µsoftteams.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := microsoftteams.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/list.go ================================================ package microsoftteams import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) // ListCommand calls the Fastly API to list Microsoft Teams alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Microsoft Teams alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := µsoftteams.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := microsoftteams.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/microsoftteams_test.go ================================================ package microsoftteams_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/microsoftteams" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) const ( alertID = "3c4d5e6f7890abcdef123456" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestMicrosoftTeamsAlert" ) var ( webhook = "https://outlook.office.com/webhook/example" teamsAlert = microsoftteams.Alert{ ID: alertID, Type: "microsoftteams", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: microsoftteams.ResponseConfig{ Webhook: &webhook, }, } ) func TestMicrosoftTeamsAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--webhook %s", webhook), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --webhook flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --webhook not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", teamsAlert.Type, teamsAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhook), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(teamsAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestMicrosoftTeamsAlertList(t *testing.T) { alertsObject := microsoftteams.Alerts{ Data: []microsoftteams.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "microsoftteams", Description: "First Microsoft Teams alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: microsoftteams.ResponseConfig{ Webhook: &webhook, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "microsoftteams", Description: "Second Microsoft Teams alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: microsoftteams.ResponseConfig{ Webhook: &webhook, }, }, }, Meta: microsoftteams.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(microsoftteams.Alerts{ Data: []microsoftteams.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestMicrosoftTeamsAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(teamsAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(teamsAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestMicrosoftTeamsAlertUpdate(t *testing.T) { updatedWebhook := "https://outlook.office.com/webhook/updated" updatedAlert := microsoftteams.Alert{ ID: alertID, Type: "microsoftteams", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: microsoftteams.ResponseConfig{ Webhook: &updatedWebhook, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhook), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://outlook.office.com/webhook/updated", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with webhook", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://outlook.office.com/webhook/updated", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(teamsAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://outlook.office.com/webhook/updated --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(teamsAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestMicrosoftTeamsAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 3c4d5e6f7890abcdef123456 Type: microsoftteams Description: TestMicrosoftTeamsAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Webhook: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 microsoftteams First Microsoft Teams alert 2025-11-25T16:40:12Z test@example.com Webhook: 2b3c4d5e6f7890abcdef1234 microsoftteams Second Microsoft Teams alert 2025-11-25T16:40:12Z test@example.com Webhook: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/root.go ================================================ package microsoftteams 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 = "microsoftteams" // 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 Microsoft Teams workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/microsoftteams/update.go ================================================ package microsoftteams import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" ) // UpdateCommand calls the Fastly API to update Microsoft Teams alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Webhook 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 Microsoft Teams alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("webhook", "Microsoft Teams webhook.").Required().StringVar(&c.Webhook) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := µsoftteams.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: µsoftteams.UpdateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := microsoftteams.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/create.go ================================================ package opsgenie import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) // CreateCommand calls the Fastly API to create Opsgenie alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Key string // Optional. Description 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 Opsgenie alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("key", "Opsgenie integration key.").Required().StringVar(&c.Key) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &opsgenie.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &opsgenie.CreateConfig{ Key: &c.Key, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := opsgenie.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/delete.go ================================================ package opsgenie import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) // DeleteCommand calls the Fastly API to delete Opsgenie alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Opsgenie alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := opsgenie.Delete(context.TODO(), fc, &opsgenie.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/doc.go ================================================ // Package opsgenie contains commands to inspect and manipulate NGWAF Opsgenie alerts. package opsgenie ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/get.go ================================================ package opsgenie import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) // GetCommand calls the Fastly API to get Opsgenie alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Opsgenie alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &opsgenie.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := opsgenie.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/list.go ================================================ package opsgenie import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) // ListCommand calls the Fastly API to list Opsgenie alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Opsgenie alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &opsgenie.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := opsgenie.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/opsgenie_test.go ================================================ package opsgenie_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/opsgenie" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) const ( alertID = "4d5e6f7890abcdef12345678" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestOpsgenieAlert" ) var ( key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" opsgenieAlert = opsgenie.Alert{ ID: alertID, Type: "opsgenie", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: opsgenie.ResponseConfig{ Key: &key, }, } ) func TestOpsgenieAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--key %s", key), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --key flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", opsgenieAlert.Type, opsgenieAlert.ID, workspaceID), }, { Name: "validate API success with description", Args: fmt.Sprintf("--workspace-id %s --key %s --description %s", workspaceID, key, description), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", opsgenieAlert.Type, opsgenieAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --key %s --json", workspaceID, key), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(opsgenieAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestOpsgenieAlertList(t *testing.T) { alertsObject := opsgenie.Alerts{ Data: []opsgenie.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "opsgenie", Description: "First Opsgenie alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: opsgenie.ResponseConfig{ Key: &key, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "opsgenie", Description: "Second Opsgenie alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: opsgenie.ResponseConfig{ Key: &key, }, }, }, Meta: opsgenie.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenie.Alerts{ Data: []opsgenie.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestOpsgenieAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(opsgenieAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(opsgenieAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestOpsgenieAlertUpdate(t *testing.T) { updatedKey := "updated-key-1234" updatedAlert := opsgenie.Alert{ ID: alertID, Type: "opsgenie", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: opsgenie.ResponseConfig{ Key: &updatedKey, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --key %s", alertID, key), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-1234", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with key", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-1234", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenieAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-1234 --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(opsgenieAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestOpsgenieAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 4d5e6f7890abcdef12345678 Type: opsgenie Description: TestOpsgenieAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Key: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 opsgenie First Opsgenie alert 2025-11-25T16:40:12Z test@example.com Key: 2b3c4d5e6f7890abcdef1234 opsgenie Second Opsgenie alert 2025-11-25T16:40:12Z test@example.com Key: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/root.go ================================================ package opsgenie 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 = "opsgenie" // 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 Opsgenie workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/opsgenie/update.go ================================================ package opsgenie import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" ) // UpdateCommand calls the Fastly API to update Opsgenie alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Key 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 Opsgenie alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("key", "Opsgenie integration key.").Required().StringVar(&c.Key) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &opsgenie.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &opsgenie.UpdateConfig{ Key: &c.Key, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := opsgenie.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/create.go ================================================ package pagerduty import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) // CreateCommand calls the Fastly API to create PagerDuty alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Key string // Optional. Description 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 PagerDuty alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("key", "PagerDuty integration key.").Required().StringVar(&c.Key) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &pagerduty.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &pagerduty.CreateConfig{ Key: &c.Key, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := pagerduty.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/delete.go ================================================ package pagerduty import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) // DeleteCommand calls the Fastly API to delete PagerDuty alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 PagerDuty alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := pagerduty.Delete(context.TODO(), fc, &pagerduty.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/doc.go ================================================ // Package pagerduty contains commands to inspect and manipulate NGWAF PagerDuty alerts. package pagerduty ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/get.go ================================================ package pagerduty import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) // GetCommand calls the Fastly API to get PagerDuty alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a PagerDuty alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &pagerduty.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := pagerduty.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/list.go ================================================ package pagerduty import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) // ListCommand calls the Fastly API to list PagerDuty alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 PagerDuty alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &pagerduty.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := pagerduty.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/pagerduty_test.go ================================================ package pagerduty_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/pagerduty" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) const ( alertID = "5e6f7890abcdef1234567890" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestPagerDutyAlert" ) var ( key = "a1b2c3d4e5f67890abcdef1234567890" pagerdutyAlert = pagerduty.Alert{ ID: alertID, Type: "pagerduty", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: pagerduty.ResponseConfig{ Key: &key, }, } ) func TestPagerDutyAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--key %s", key), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --key flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --key not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", pagerdutyAlert.Type, pagerdutyAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --key %s --json", workspaceID, key), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(pagerdutyAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestPagerDutyAlertList(t *testing.T) { alertsObject := pagerduty.Alerts{ Data: []pagerduty.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "pagerduty", Description: "First PagerDuty alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: pagerduty.ResponseConfig{ Key: &key, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "pagerduty", Description: "Second PagerDuty alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: pagerduty.ResponseConfig{ Key: &key, }, }, }, Meta: pagerduty.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerduty.Alerts{ Data: []pagerduty.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestPagerDutyAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(pagerdutyAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(pagerdutyAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestPagerDutyAlertUpdate(t *testing.T) { updatedKey := "updated-key-9876543210" updatedAlert := pagerduty.Alert{ ID: alertID, Type: "pagerduty", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: pagerduty.ResponseConfig{ Key: &updatedKey, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --key %s", alertID, key), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --key %s", workspaceID, key), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --key updated-key-9876543210", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with key", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerdutyAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --key updated-key-9876543210 --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(pagerdutyAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestPagerDutyAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 5e6f7890abcdef1234567890 Type: pagerduty Description: TestPagerDutyAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Key: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 pagerduty First PagerDuty alert 2025-11-25T16:40:12Z test@example.com Key: 2b3c4d5e6f7890abcdef1234 pagerduty Second PagerDuty alert 2025-11-25T16:40:12Z test@example.com Key: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/root.go ================================================ package pagerduty 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 = "pagerduty" // 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 PagerDuty workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/pagerduty/update.go ================================================ package pagerduty import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" ) // UpdateCommand calls the Fastly API to update PagerDuty alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Key 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 PagerDuty alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("key", "PagerDuty integration key.").Required().StringVar(&c.Key) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &pagerduty.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &pagerduty.UpdateConfig{ Key: &c.Key, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := pagerduty.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/root.go ================================================ package alert 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 = "alert" // 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 workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } // GetDefaultEvents returns the hardcoded events value for all alerts. // Currently the only supported value is "flag". func GetDefaultEvents() *[]string { events := []string{"flag"} return &events } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/create.go ================================================ package slack import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) // CreateCommand calls the Fastly API to create Slack alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Webhook string // Optional. Description 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 Slack alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("webhook", "Slack webhook.").Required().StringVar(&c.Webhook) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &slack.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &slack.CreateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := slack.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/delete.go ================================================ package slack import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) // DeleteCommand calls the Fastly API to delete Slack alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Slack alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := slack.Delete(context.TODO(), fc, &slack.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/doc.go ================================================ // Package slack contains commands to inspect and manipulate NGWAF Slack alerts. package slack ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/get.go ================================================ package slack import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) // GetCommand calls the Fastly API to get Slack alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Slack alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &slack.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := slack.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/list.go ================================================ package slack import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) // ListCommand calls the Fastly API to list Slack alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Slack alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &slack.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := slack.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/root.go ================================================ package slack 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 = "slack" // 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 Slack workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/slack_test.go ================================================ package slack_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/slack" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) const ( alertID = "1a2b3c4d5e6f7890abcdef12" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestSlackAlert" ) var ( webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX" slackAlert = slack.Alert{ ID: alertID, Type: "slack", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: slack.ResponseConfig{ Webhook: &webhook, }, } ) func TestSlackAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--webhook %s", webhook), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --webhook flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --webhook not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", slackAlert.Type, slackAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhook), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(slackAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestSlackAlertList(t *testing.T) { alertsObject := slack.Alerts{ Data: []slack.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "slack", Description: "First slack alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: slack.ResponseConfig{ Webhook: &webhook, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "slack", Description: "Second slack alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: slack.ResponseConfig{ Webhook: &webhook, }, }, }, Meta: slack.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slack.Alerts{ Data: []slack.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestSlackAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(slackAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(slackAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestSlackAlertUpdate(t *testing.T) { updatedWebhook := "https://hooks.slack.com/services/updated" updatedAlert := slack.Alert{ ID: alertID, Type: "slack", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: slack.ResponseConfig{ Webhook: &updatedWebhook, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhook), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhook), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://hooks.slack.com/services/updated", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with webhook", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://hooks.slack.com/services/updated", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slackAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://hooks.slack.com/services/updated --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(slackAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestSlackAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } var alertString = strings.TrimSpace(` ID: 1a2b3c4d5e6f7890abcdef12 Type: slack Description: TestSlackAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Webhook: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 slack First slack alert 2025-11-25T16:40:12Z test@example.com Webhook: 2b3c4d5e6f7890abcdef1234 slack Second slack alert 2025-11-25T16:40:12Z test@example.com Webhook: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/alert/slack/update.go ================================================ package slack import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" ) // UpdateCommand calls the Fastly API to update Slack alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Webhook 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 Slack alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("webhook", "Slack webhook.").Required().StringVar(&c.Webhook) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &slack.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &slack.UpdateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := slack.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/create.go ================================================ package webhook import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // CreateCommand calls the Fastly API to create Webhook alerts. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID Webhook string // Optional. Description 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 Webhook alert").Alias("add") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.CmdClause.Flag("webhook", "Webhook webhook.").Required().StringVar(&c.Webhook) // Optional. c.CmdClause.Flag("description", "An optional description for the alert.").Action(c.Description.Set).StringVar(&c.Description.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.CreateInput{ WorkspaceID: &c.WorkspaceID.Value, Config: &webhook.CreateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } if c.Description.WasSet { input.Description = &c.Description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created a '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/delete.go ================================================ package webhook import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // DeleteCommand calls the Fastly API to delete Webhook alerts. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 Webhook alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } 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 := webhook.Delete(context.TODO(), fc, &webhook.DeleteInput{ WorkspaceID: &c.WorkspaceID.Value, AlertID: &c.AlertID, }) if err != nil { return err } if c.JSONOutput.Enabled { o := struct { ID string `json:"id"` Deleted bool `json:"deleted"` }{ c.AlertID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted alert '%s' (workspace-id: %s)", c.AlertID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/doc.go ================================================ // Package webhook contains commands to inspect and manipulate NGWAF Webhook alerts. package webhook ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/get-signing-key.go ================================================ package webhook import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // GetSigningKeyCommand calls the Fastly API to get Webhook alerts. type GetSigningKeyCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // NewGetSigningKeyCommand returns a usable command registered under the parent. func NewGetSigningKeyCommand(parent argparser.Registerer, g *global.Data) *GetSigningKeyCommand { c := GetSigningKeyCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("get-signing-key", "Retrieves details of a webhook alert signing key") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *GetSigningKeyCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.GetKeyInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.GetKey(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Signing Key: '%s' (Workspace: %s)", data.SigningKey, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/get.go ================================================ package webhook import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // GetCommand calls the Fastly API to get Webhook alerts. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // 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 a Webhook alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.GetInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlert(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/list.go ================================================ package webhook import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // ListCommand calls the Fastly API to list Webhook alerts. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. WorkspaceID argparser.OptionalWorkspaceID } // 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 Webhook alerts") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.ListInput{ WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintAlertTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/root.go ================================================ package webhook 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 = "webhook" // 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 Webhook workspace alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/rotate-signing-key.go ================================================ package webhook import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // RotateSigningKeyCommand calls the Fastly API to get Webhook alerts. type RotateSigningKeyCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID } // NewRotateSigningKeyCommand returns a usable command registered under the parent. func NewRotateSigningKeyCommand(parent argparser.Registerer, g *global.Data) *RotateSigningKeyCommand { c := RotateSigningKeyCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("rotate-signing-key", "Rotate webhook alert signing key") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *RotateSigningKeyCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.RotateKeyInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.RotateKey(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Signing Key: '%s' (Workspace: %s)", data.SigningKey, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/update.go ================================================ package webhook import ( "context" "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // UpdateCommand calls the Fastly API to update Webhook alerts. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. AlertID string WorkspaceID argparser.OptionalWorkspaceID Webhook 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 Webhook alert") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.WorkspaceID.Value, Action: c.WorkspaceID.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFAlertID, Description: argparser.FlagNGWAFAlertIDDesc, Dst: &c.AlertID, Required: true, }) c.CmdClause.Flag("webhook", "Webhook webhook.").Required().StringVar(&c.Webhook) // Optional. c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.WorkspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &webhook.UpdateInput{ AlertID: &c.AlertID, WorkspaceID: &c.WorkspaceID.Value, Config: &webhook.UpdateConfig{ Webhook: &c.Webhook, }, // Set 'Events' to the only possible value, 'flag' Events: alert.GetDefaultEvents(), } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := webhook.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated '%s' alert '%s' (workspace-id: %s)", data.Type, data.ID, c.WorkspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/alert/webhook/webhook_test.go ================================================ package webhook_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspaceroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace" alertroot "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/webhook" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) const ( alertID = "6f7890abcdef123456789012" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" description = "TestWebhookAlert" signingKey = "a1b2c3d4e5f67890abcdef1234567890" ) var ( webhookURL = "https://example.com/webhook" webhookAlert = webhook.Alert{ ID: alertID, Type: "webhook", Description: description, CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: webhook.ResponseConfig{ Webhook: &webhookURL, }, } ) func TestWebhookAlertCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--webhook %s", webhookURL), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --webhook flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --webhook not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhookURL), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), }, }, }, WantOutput: fstfmt.Success("Created a '%s' alert '%s' (workspace-id: %s)", webhookAlert.Type, webhookAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s --json", workspaceID, webhookURL), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusCreated, Status: http.StatusText(http.StatusCreated), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(webhookAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "create"}, scenarios) } func TestWebhookAlertList(t *testing.T) { alertsObject := webhook.Alerts{ Data: []webhook.Alert{ { ID: "1a2b3c4d5e6f7890abcdef12", Type: "webhook", Description: "First webhook alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: webhook.ResponseConfig{ Webhook: &webhookURL, }, }, { ID: "2b3c4d5e6f7890abcdef1234", Type: "webhook", Description: "Second webhook alert", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: webhook.ResponseConfig{ Webhook: &webhookURL, }, }, }, Meta: webhook.MetaAlerts{ Total: 2, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 alerts)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhook.Alerts{ Data: []webhook.Alert{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(alertsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(alertsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "list"}, scenarios) } func TestWebhookAlertGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), }, }, }, WantOutput: alertString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(webhookAlert)))), }, }, }, WantOutput: fstfmt.EncodeJSON(webhookAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get"}, scenarios) } func TestWebhookAlertUpdate(t *testing.T) { updatedWebhookURL := "https://example.com/webhook/updated" updatedAlert := webhook.Alert{ ID: alertID, Type: "webhook", Description: "Updated description", CreatedAt: "2025-11-25T16:40:12Z", CreatedBy: "test@example.com", Config: webhook.ResponseConfig{ Webhook: &updatedWebhookURL, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s --webhook %s", alertID, webhookURL), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s --webhook %s", workspaceID, webhookURL), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid --webhook https://example.com/webhook/updated", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success with webhook", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://example.com/webhook/updated", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhookAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.Success("Updated '%s' alert '%s' (workspace-id: %s)", updatedAlert.Type, updatedAlert.ID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --webhook https://example.com/webhook/updated --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MultiResponseRoundTripper{ Responses: []*http.Response{ { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(webhookAlert))), }, { StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedAlert))), }, }, }, }, WantOutput: fstfmt.EncodeJSON(updatedAlert), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "update"}, scenarios) } func TestWebhookAlertDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted alert '%s' (workspace-id: %s)", alertID, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "delete"}, scenarios) } func TestWebhookGetSigningKey(t *testing.T) { signingKeyResponse := webhook.AlertsKey{ SigningKey: signingKey, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), }, }, }, WantOutput: fstfmt.Success("Signing Key: '%s' (Workspace: %s)", signingKeyResponse.SigningKey, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), }, }, }, WantOutput: fstfmt.EncodeJSON(signingKeyResponse), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "get-signing-key"}, scenarios) } func TestWebhookRotateSigningKey(t *testing.T) { newSigningKey := "new-signing-key-0987654321" signingKeyResponse := webhook.AlertsKey{ SigningKey: newSigningKey, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--alert-id %s", alertID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --alert-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --alert-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --alert-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --alert-id %s", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), }, }, }, WantOutput: fstfmt.Success("Signing Key: '%s' (Workspace: %s)", signingKeyResponse.SigningKey, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --alert-id %s --json", workspaceID, alertID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(signingKeyResponse)))), }, }, }, WantOutput: fstfmt.EncodeJSON(signingKeyResponse), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspaceroot.CommandName, alertroot.CommandName, sub.CommandName, "rotate-signing-key"}, scenarios) } var alertString = strings.TrimSpace(` ID: 6f7890abcdef123456789012 Type: webhook Description: TestWebhookAlert Created At: 2025-11-25T16:40:12Z Created By: test@example.com Config: Webhook: `) var listString = strings.TrimSpace(` ID Type Description Created At Created By Config 1a2b3c4d5e6f7890abcdef12 webhook First webhook alert 2025-11-25T16:40:12Z test@example.com Webhook: 2b3c4d5e6f7890abcdef1234 webhook Second webhook alert 2025-11-25T16:40:12Z test@example.com Webhook: `) + "\n" var zeroListString = strings.TrimSpace(` ID Type Description Created At Created By Config `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/countrylist_test.go ================================================ package countrylist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/countrylist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "us" listType = "country" listName = "listName" workspaceID = "someWorkspaceID" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } func TestCountryListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s --workspace-id %s", listName, workspaceID), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s --workspace-id %s", listEntries, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), 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("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Workspace Country List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s --json", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestCountryListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id bar --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Workspace Country List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestCountryListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id baz --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestCountryListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspaces)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: listListsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestCountryListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Workspace Country List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s --json", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList country workspace us 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 country workspace us 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: country Entries: us Scope: workspace Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/create.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level country lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level country list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, Name: c.name, Type: "country", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Workspace Country List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/delete.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level country list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Workspace Country List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/doc.go ================================================ // Package countrylist contains commands to inspect and manipulate NGWAF workspace-level country lists. package countrylist ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/get.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get a workspace-level country list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/list.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all country lists for your workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 country lists for your workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeWorkspace, Type: "country", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/root.go ================================================ package countrylist 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 = "country-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, "Manage NGWAF Workspace Country Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/countrylist/update.go ================================================ package countrylist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 an account country list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID // Optional. description argparser.OptionalString entries 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 account-level country list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Workspace Country List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/create.go ================================================ package workspace import ( "context" "errors" "io" "strconv" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" "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 workspaces. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. description string blockingMode string name string // Optional. attackThresholds argparser.OptionalString defaultBlockingCode argparser.OptionalInt defaultRedirectURL argparser.OptionalString clientIPHeaders argparser.OptionalString ipAnonymization 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 workspace").Alias("add") // Required. c.CmdClause.Flag("description", "User submitted description of a workspace.").Required().StringVar(&c.description) c.CmdClause.Flag("blockingMode", "User configured mode blocking mode.").Required().StringVar(&c.blockingMode) c.CmdClause.Flag("name", "User submitted display name of a workspace.").Required().StringVar(&c.name) // Optional. c.CmdClause.Flag("attackThresholds", "Attack threshold parameters for system site alerts. Each threshold value is the number of attack signals per IP address that must be detected during the interval before the related IP address is flagged. Input accepted as colon separated string: Immediate:OneMinute:TenMinutes:OneHour").Action(c.attackThresholds.Set).StringVar(&c.attackThresholds.Value) c.CmdClause.Flag("clientIPHeaders", "Specify the request header containing the client IP address. Input accepted as colon separated string.").Action(c.clientIPHeaders.Set).StringVar(&c.clientIPHeaders.Value) c.CmdClause.Flag("defaultBlockingCode", "Default status code that is returned when a request to your web application is blocked.").Action(c.defaultBlockingCode.Set).IntVar(&c.defaultBlockingCode.Value) c.CmdClause.Flag("defaultRedirectURL", "Redirect url to be used if code 301 or 302 is used.").Action(c.defaultRedirectURL.Set).StringVar(&c.defaultRedirectURL.Value) c.CmdClause.Flag("ipAnonymization", "Agents will anonymize IP addresses according to the option selected.").Action(c.ipAnonymization.Set).StringVar(&c.ipAnonymization.Value) 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 } var err error input := &workspaces.CreateInput{ Description: &c.description, Mode: &c.blockingMode, Name: &c.name, } if c.attackThresholds.WasSet { input.AttackSignalThresholds, err = parseCreateAttackSignalThresholds(c.attackThresholds.Value) if err != nil { return err } } if c.clientIPHeaders.WasSet { input.ClientIPHeaders = strings.Split(c.clientIPHeaders.Value, ":") } if c.defaultBlockingCode.WasSet { input.DefaultBlockingResponseCode = &c.defaultBlockingCode.Value } if c.defaultRedirectURL.WasSet { input.DefaultRedirectURL = &c.defaultRedirectURL.Value } if c.ipAnonymization.WasSet { input.IPAnonymization = &c.ipAnonymization.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := workspaces.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created workspace '%s' (workspace-id: %s)", data.Name, data.WorkspaceID) return nil } func parseCreateAttackSignalThresholds(thresholds string) (*workspaces.AttackSignalThresholdsCreateInput, error) { thresholdsArray := strings.Split(thresholds, ":") if len(thresholdsArray) != 4 { return nil, errors.New("wrong number of inputs for Attack Signal Thresholds") } immediate, err := strconv.ParseBool(thresholdsArray[0]) if err != nil { return nil, err } oneMinute, err := strconv.Atoi(thresholdsArray[1]) if err != nil { return nil, err } tenMinutes, err := strconv.Atoi(thresholdsArray[2]) if err != nil { return nil, err } oneHour, err := strconv.Atoi(thresholdsArray[3]) if err != nil { return nil, err } return &workspaces.AttackSignalThresholdsCreateInput{ OneMinute: &oneMinute, TenMinutes: &tenMinutes, OneHour: &oneHour, Immediate: &immediate, }, nil } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/create.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 workspace-level custom signals. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level custom signal").Alias("add") // Required. c.CmdClause.Flag("name", "User submitted display name of a custom signal. Is immutable and must be between 3 and 25 characters").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, Required: true, }) // Optional. c.CmdClause.Flag("description", "User submitted description of a custom signal.").Action(c.description.Set).StringVar(&c.description.Value) 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 } var err error input := &signals.CreateInput{ Name: &c.name, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, }, } if err := c.workspaceID.Parse(); err != nil { return err } input.Scope.AppliesTo = []string{c.workspaceID.Value} if c.description.WasSet { input.Description = &c.description.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := signals.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created workspace-level custom signal '%s' (signal-id: %s)", data.Name, data.SignalID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/customsignal_test.go ================================================ package customsignal_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/customsignal" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" ) const ( customSignalDescription = "NGWAFCLICustomSignal" customSignalID = "someID" customSignalName = "CLICustomSignalName" workspaceID = "WorkspaceID" ) var customSignal = signals.Signal{ CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, Scope: signals.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{workspaceID}, }, } func TestCustomSignalCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --name flag", Args: fmt.Sprintf("--description %s --workspace-id %s", customSignalDescription, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--description %s --name %s", customSignalDescription, customSignalName), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--description %s --name %s --workspace-id %s", customSignalDescription, customSignalName, workspaceID), 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 --name %s --workspace-id %s", customSignalDescription, customSignalName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: fstfmt.Success("Created workspace-level custom signal '%s' (signal-id: %s)", customSignalName, customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--description %s --name %s --workspace-id %s --json", customSignalDescription, customSignalName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignal))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestCustomSignalDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--signal-id %s", customSignalID), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate bad request", Args: "--signal-id bar --workspace-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 signal ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s --workspace-id %s", customSignalID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted workspace-level custom signal (id: %s)", customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --workspace-id %s --json", customSignalID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, customSignalID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestCustomSignalGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--signal-id %s", customSignalID), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate bad request", Args: "--signal-id baz --workspace-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 Custom Signal ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s --workspace-id %s", customSignalID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: customSignalString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --workspace-id %s --json", customSignalID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(customSignal)))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestCustomSignalList(t *testing.T) { customSignalsObject := signals.Signals{ Data: []signals.Signal{ { CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, Scope: signals.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{workspaceID}, }, }, { CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName + "2", SignalID: customSignalID + "2", Scope: signals.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{workspaceID}, }, }, }, Meta: signals.MetaSignals{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspace-level custom signals)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(signals.Signals{ Data: []signals.Signal{}, Meta: signals.MetaSignals{}, }))), }, }, }, WantOutput: zeroListCustomSignalsString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignalsObject))), }, }, }, WantOutput: listCustomSignalsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignalsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignalsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestCustomSignalUpdate(t *testing.T) { customSignalObject := signals.Signal{ CreatedAt: testutil.Date, Description: customSignalDescription, Name: customSignalName, SignalID: customSignalID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --signal-id flag", Args: fmt.Sprintf("--description %s --workspace-id %s", customSignalDescription+"2", workspaceID), WantError: "error parsing arguments: required flag --signal-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--description %s --signal-id %s", customSignalDescription+"2", customSignalID), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate missing --description flag", Args: fmt.Sprintf("--workspace-id %s --signal-id %s", workspaceID, customSignalID), WantError: "error parsing arguments: required flag --description not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--signal-id %s --description %s --workspace-id %s", customSignalID, customSignalDescription, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignalObject))), }, }, }, WantOutput: fstfmt.Success("Updated workspace-level signal '%s' (signal-id: %s)", customSignalName, customSignalID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--signal-id %s --description %s --workspace-id %s --json", customSignalID, customSignalDescription, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(customSignal))), }, }, }, WantOutput: fstfmt.EncodeJSON(customSignal), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listCustomSignalsString = strings.TrimSpace(` ID Name Description Scope Updated At Created At someID CLICustomSignalName NGWAFCLICustomSignal workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someID2 CLICustomSignalName2 NGWAFCLICustomSignal workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListCustomSignalsString = strings.TrimSpace(` ID Name Description Scope Updated At Created At `) + "\n" var customSignalString = strings.TrimSpace(` ID: someID Name: CLICustomSignalName Description: NGWAFCLICustomSignal Scope: workspace Updated (UTC): 0001-01-01 00:00 Created (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/delete.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 workspace-level custom signal. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. signalID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace-level custom signal") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, Required: true, }) // 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") } input := &signals.DeleteInput{ SignalID: &c.signalID, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, }, } if err := c.workspaceID.Parse(); err != nil { return err } input.Scope.AppliesTo = []string{c.workspaceID.Value} err := signals.Delete(context.TODO(), fc, 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.signalID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted workspace-level custom signal (id: %s)", c.signalID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/doc.go ================================================ // Package customsignal contains commands to inspect and manipulate NGWAF workspace-level custom signals. package customsignal ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/get.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 a workspace-level custom signal. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. signalID string workspaceID argparser.OptionalWorkspaceID } // 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 a custom signal") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, Required: true, }) // 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") } input := &signals.GetInput{ SignalID: &c.signalID, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, }, } if err := c.workspaceID.Parse(); err != nil { return err } input.Scope.AppliesTo = []string{c.workspaceID.Value} data, err := signals.Get(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintCustomSignal(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/list.go ================================================ package customsignal import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" ) // ListCommand calls the Fastly API to list all workspace-level custom signals for your API token. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 workspace-level custom signals") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, Required: true, }) // 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") } input := &signals.ListInput{ Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, }, } if err := c.workspaceID.Parse(); err != nil { return err } input.Scope.AppliesTo = []string{c.workspaceID.Value} signals, err := signals.List(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, signals); ok { return err } text.PrintCustomSignalTbl(out, signals.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/root.go ================================================ package customsignal 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 = "customsignal" // 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 NGWAF Workspace-Level Custom Signals") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/customsignal/update.go ================================================ package customsignal import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" "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 workspace-level custom signals. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. signalID string description string workspaceID argparser.OptionalWorkspaceID } // 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 workspace") // Required. c.CmdClause.Flag("signal-id", "Custom Signal ID").Required().StringVar(&c.signalID) c.CmdClause.Flag("description", "User submitted description of a custom signal.").Required().StringVar(&c.description) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, Required: true, }) // Optional. c.RegisterFlagBool(c.JSONFlag()) 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 } var err error input := &signals.UpdateInput{ SignalID: &c.signalID, Description: &c.description, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, }, } if err := c.workspaceID.Parse(); err != nil { return err } input.Scope.AppliesTo = []string{c.workspaceID.Value} fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := signals.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated workspace-level signal '%s' (signal-id: %s)", data.Name, data.SignalID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/delete.go ================================================ package workspace import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" "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 workspace. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID 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 workspace") // Required. c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // 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 := workspaces.Delete(context.TODO(), fc, &workspaces.DeleteInput{ WorkspaceID: &c.workspaceID, }) 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.workspaceID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted workspace (id: %s)", c.workspaceID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/doc.go ================================================ // Package workspace contains commands to inspect and manipulate NGWAF workspaces. package workspace ================================================ FILE: pkg/commands/ngwaf/workspace/get.go ================================================ package workspace import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" "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 a workspace. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID 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 a workspace") // Required. c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // 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") } data, err := workspaces.Get(context.TODO(), fc, &workspaces.GetInput{ WorkspaceID: &c.workspaceID, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintWorkspace(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/create.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level ip lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level ip list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, Name: c.name, Type: "ip", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Workspace IP List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/delete.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 account-level ip list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 account ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Workspace IP List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/doc.go ================================================ // Package iplist contains commands to inspect and manipulate NGWAF workspace-level ip lists. package iplist ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/get.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get a workspace-level ip list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/iplist_test.go ================================================ package iplist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/iplist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "ip" listName = "listName" workspaceID = "someWorkspaceID" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } func TestIPListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s --workspace-id %s", listName, workspaceID), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s --workspace-id %s", listEntries, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), 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("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Workspace IP List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s --json", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestIPListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id bar --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Workspace IP List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestIPListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id baz --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestIPListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspaces)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: listListsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestIPListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Workspace IP List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s --json", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList ip workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 ip workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: ip Entries: 1.0.0.0 Scope: workspace Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/list.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all ip lists for your workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 ip lists for your workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeWorkspace, Type: "ip", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/root.go ================================================ package iplist 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 = "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, "Manage NGWAF Workspace IP Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/iplist/update.go ================================================ package iplist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace ip list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID // Optional. description argparser.OptionalString entries 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 workspace-level ip list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Workspace IP List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/list.go ================================================ package workspace import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" ) // ListCommand calls the Fastly API to list all Workspaces for your API token. 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 all workspaces") // 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") } workspaces, err := workspaces.List(context.TODO(), fc, &workspaces.ListInput{}) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, workspaces); ok { return err } text.PrintWorkspaceTbl(out, workspaces.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/create.go ================================================ package redaction import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" "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 redaction. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. field string redactionType string workspaceID argparser.OptionalWorkspaceID } // 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 redaction").Alias("add") // Required. c.CmdClause.Flag("field", "The name of the field that should be redacted.").Required().StringVar(&c.field) c.CmdClause.Flag("type", "The type of field that is being redacted.").Required().StringVar(&c.redactionType) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } var err error input := &redactions.CreateInput{ Field: &c.field, Type: &c.redactionType, WorkspaceID: &c.workspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := redactions.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created redaction '%s' (field: %s, type: %s)", data.RedactionID, data.Field, data.Type) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/delete.go ================================================ package redaction import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" "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 redaction. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID redactionID 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 redaction") // Required. c.CmdClause.Flag("redaction-id", "Redaction ID").Required().StringVar(&c.redactionID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } 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 := redactions.Delete(context.TODO(), fc, &redactions.DeleteInput{ RedactionID: &c.redactionID, WorkspaceID: &c.workspaceID.Value, }) 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.redactionID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted redaction (id: %s)", c.redactionID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/list.go ================================================ package redaction import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" "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 redactions in a workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID // Optional. limit argparser.OptionalInt } // 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 redactions in a workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("limit", "Limit how many results are returned").Action(c.limit.Set).IntVar(&c.limit.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } 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 := &redactions.ListInput{ WorkspaceID: &c.workspaceID.Value, } if c.limit.WasSet { input.Limit = &c.limit.Value } data, err := redactions.List(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintRedactionTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/redaction_test.go ================================================ package redaction_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspace "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/redaction" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" ) const ( redactionField = "password" redactionID = "someID" redactionType = "request" workspaceID = "workspaceID" ) var redaction = redactions.Redaction{ CreatedAt: testutil.Date, Field: redactionField, RedactionID: redactionID, Type: redactionType, } func TestRedactionCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --field flag", Args: fmt.Sprintf("--type %s --workspace-id %s", redactionType, workspaceID), WantError: "error parsing arguments: required flag --field not provided", }, { Name: "validate missing --type flag", Args: fmt.Sprintf("--field %s --workspace-id %s", redactionField, workspaceID), WantError: "error parsing arguments: required flag --type not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--field %s --type %s", redactionField, redactionType), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--field %s --type %s --workspace-id %s", redactionField, redactionType, workspaceID), 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("--field %s --type %s --workspace-id %s", redactionField, redactionType, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(redaction)))), }, }, }, WantOutput: fstfmt.Success("Created redaction '%s' (field: %s, type: %s)", redactionID, redactionField, redactionType), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--field %s --type %s --workspace-id %s --json", redactionField, redactionType, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redaction))), }, }, }, WantOutput: fstfmt.EncodeJSON(redaction), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "create"}, scenarios) } func TestRedactionDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --redaction-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --redaction-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--redaction-id %s", redactionID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s", redactionID, workspaceID), 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 Redaction ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s", redactionID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted redaction (id: %s)", redactionID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s --json", redactionID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, redactionID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "delete"}, scenarios) } func TestRedactionRetrieve(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --redaction-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --redaction-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--redaction-id %s", redactionID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s", redactionID, workspaceID), 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 Redaction ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s", redactionID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(redaction)))), }, }, }, WantOutput: redactionString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s --json", redactionID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(redaction)))), }, }, }, WantOutput: fstfmt.EncodeJSON(redaction), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "retrieve"}, scenarios) } func TestRedactionList(t *testing.T) { redactionsObject := redactions.Redactions{ Data: []redactions.Redaction{ { CreatedAt: testutil.Date, Field: redactionField, RedactionID: redactionID, Type: redactionType, }, { CreatedAt: testutil.Date, Field: "username", RedactionID: redactionID + "2", Type: redactionType, }, }, Meta: redactions.MetaRedactions{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 redactions)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redactions.Redactions{ Data: []redactions.Redaction{}, Meta: redactions.MetaRedactions{}, }))), }, }, }, WantOutput: zeroListRedactionString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redactionsObject))), }, }, }, WantOutput: listRedactionString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redactionsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(redactionsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "list"}, scenarios) } func TestRedactionUpdate(t *testing.T) { redactionsObject := redactions.Redaction{ CreatedAt: testutil.Date, Field: redactionField, RedactionID: redactionID, Type: redactionType, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --redaction-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --redaction-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--redaction-id %s", redactionID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s --field %s --type %s", redactionID, workspaceID, redactionField, redactionType), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redactionsObject))), }, }, }, WantOutput: fstfmt.Success("Updated redaction '%s' (field: %s, type: %s)", redactionID, redactionField, redactionType), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--redaction-id %s --workspace-id %s --field %s --type %s --json", redactionID, workspaceID, redactionField, redactionType), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(redaction))), }, }, }, WantOutput: fstfmt.EncodeJSON(redaction), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "update"}, scenarios) } var listRedactionString = strings.TrimSpace(` Field ID Type Created At password someID request 2021-06-15 23:00:00 +0000 UTC username someID2 request 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListRedactionString = strings.TrimSpace(` Field ID Type Created At `) + "\n" var redactionString = strings.TrimSpace(` Field: password ID: someID Type: request Created At: 2021-06-15 23:00:00 +0000 UTC `) ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/retrieve.go ================================================ package redaction import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" "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 a redaction. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. redactionID string workspaceID argparser.OptionalWorkspaceID } // NewGetCommand returns a usable command registered under the parent. func NewRetrieveCommand(parent argparser.Registerer, g *global.Data) *GetCommand { c := GetCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("retrieve", "Retrieve a redaction").Alias("get") // Required. c.CmdClause.Flag("redaction-id", "Redaction ID").Required().StringVar(&c.redactionID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } 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") } data, err := redactions.Get(context.TODO(), fc, &redactions.GetInput{ RedactionID: &c.redactionID, WorkspaceID: &c.workspaceID.Value, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintRedaction(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/root.go ================================================ package redaction 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 = "redaction" // 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 NGWAF Redactions") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/redaction/update.go ================================================ package redaction import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" "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 redactions. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. redactionID string workspaceID argparser.OptionalWorkspaceID // Optional. field argparser.OptionalString redactionType 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 redaction") // Required. c.CmdClause.Flag("redaction-id", "A base62-encoded representation of a UUID used to uniquely identify a redaction.").Required().StringVar(&c.redactionID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("field", "The name of the field that should be redacted.").Action(c.field.Set).StringVar(&c.field.Value) c.CmdClause.Flag("type", "The type of field that is being redacted.").Action(c.redactionType.Set).StringVar(&c.redactionType.Value) c.RegisterFlagBool(c.JSONFlag()) return &c } // Exec invokes the application logic for the command. func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } var err error input := &redactions.UpdateInput{ RedactionID: &c.redactionID, WorkspaceID: &c.workspaceID.Value, } if c.field.WasSet { input.Field = &c.field.Value } if c.redactionType.WasSet { input.Type = &c.redactionType.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := redactions.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated redaction '%s' (field: %s, type: %s)", data.RedactionID, data.Field, data.Type) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/root.go ================================================ package workspace 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 = "workspace" // 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 NGWAF Workspaces") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/create.go ================================================ package rule import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 workspace-level rules. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. path string workspaceID argparser.OptionalWorkspaceID } // 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 workspace-level rule").Alias("add") // Required. c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } if err := c.workspaceID.Parse(); err != nil { return err } rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { return fmt.Errorf("error parsing path '%s': %q", c.path, err) } jsonFile, err := os.Open(path) if err != nil { return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() byteValue, err := io.ReadAll(jsonFile) if err != nil { return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } input := &rules.CreateInput{ Actions: []*rules.CreateAction{}, Conditions: []*rules.CreateCondition{}, Description: &rule.Description, GroupConditions: []*rules.CreateGroupCondition{}, MultivalConditions: []*rules.CreateMultivalCondition{}, Enabled: &rule.Enabled, Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, }, } for _, action := range rule.Actions { input.Actions = append(input.Actions, &rules.CreateAction{ AllowInteractive: action.AllowInteractive, DeceptionType: &action.DeceptionType, RedirectURL: &action.RedirectURL, ResponseCode: &action.ResponseCode, Signal: &action.Signal, Type: &action.Type, }) } if rule.RateLimit != nil { input.RateLimit = &rules.CreateRateLimit{ ClientIdentifiers: []*rules.CreateClientIdentifier{}, Duration: &rule.RateLimit.Duration, Interval: &rule.RateLimit.Interval, Signal: &rule.RateLimit.Signal, Threshold: &rule.RateLimit.Threshold, } for _, rateLimit := range rule.RateLimit.ClientIdentifiers { input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.CreateClientIdentifier{ Key: &rateLimit.Key, Name: &rateLimit.Name, Type: &rateLimit.Type, }) } } for _, jsonCondition := range rule.Conditions { switch jsonCondition.Type { case "single": if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { input.Conditions = append(input.Conditions, &rules.CreateCondition{ Field: &sc.Field, Operator: &sc.Operator, Value: &sc.Value, }) } else { return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) } case "group": if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { parsedGroupCondition := &rules.CreateGroupCondition{ GroupOperator: &gc.GroupOperator, Conditions: []*rules.CreateCondition{}, } for _, groupCondition := range gc.Conditions { switch groupCondition.Type { case "single": if gsc, ok := groupCondition.Fields.(rules.Condition); ok { parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.CreateCondition{ Field: &gsc.Field, Operator: &gsc.Operator, Value: &gsc.Value, }) } else { return fmt.Errorf("expected Condition, got %T", groupCondition.Fields) } case "multival": if gmvc, ok := groupCondition.Fields.(rules.MultivalCondition); ok { createMultivalCondition := &rules.CreateMultivalCondition{ Field: &gmvc.Field, Operator: &gmvc.Operator, GroupOperator: &gmvc.GroupOperator, Conditions: []*rules.CreateConditionMult{}, } for _, groupMultivalSingleCondition := range gmvc.Conditions { createMultivalCondition.Conditions = append(createMultivalCondition.Conditions, &rules.CreateConditionMult{ Field: &groupMultivalSingleCondition.Field, Operator: &groupMultivalSingleCondition.Operator, Value: &groupMultivalSingleCondition.Value, }) } parsedGroupCondition.MultivalConditions = append(parsedGroupCondition.MultivalConditions, createMultivalCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", groupCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", groupCondition.Type) } } input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) } else { return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) } case "multival": if mvc, ok := jsonCondition.Fields.(rules.CreateMultivalCondition); ok { parsedMultiValCondition := &rules.CreateMultivalCondition{ Field: mvc.Field, GroupOperator: mvc.GroupOperator, Operator: mvc.Operator, Conditions: []*rules.CreateConditionMult{}, } for _, multiSingleCondition := range mvc.Conditions { parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.CreateConditionMult{ Field: multiSingleCondition.Field, Operator: multiSingleCondition.Operator, Value: multiSingleCondition.Value, }) } input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) } } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := rules.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created workspace-level rule with ID %s", data.RuleID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/delete.go ================================================ package rule import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 workspace-level rule. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. ruleID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace-level rule") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } if err := c.workspaceID.Parse(); err != nil { return err } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := rules.Delete(context.TODO(), fc, &rules.DeleteInput{ RuleID: &c.ruleID, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, }, }) 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.ruleID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted workspace-level rule with id: %s", c.ruleID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/doc.go ================================================ // Package rule contains commands to inspect and manipulate NGWAF workspace-level rules. package rule ================================================ FILE: pkg/commands/ngwaf/workspace/rule/get.go ================================================ package rule import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 a workspace-level rule. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. ruleID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level rule") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } if err := c.workspaceID.Parse(); err != nil { return err } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := rules.Get(context.TODO(), fc, &rules.GetInput{ RuleID: &c.ruleID, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, }, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintRule(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/list.go ================================================ package rule import ( "context" "errors" "io" "strconv" "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/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all workspace-level rules for your API token. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID // Optional. action argparser.OptionalString enabled 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 all workspace-level rules") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("action", "Filter rules based on action.").Action(c.action.Set).StringVar(&c.action.Value) c.CmdClause.Flag("enabled", "Filter rules based on whether the rule is enabled.").Action(c.enabled.Set).StringVar(&c.enabled.Value) 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 } if err := c.workspaceID.Parse(); err != nil { return err } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } input := &rules.ListInput{ Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, }, } if c.action.WasSet { input.Action = &c.action.Value } if c.enabled.WasSet { enabled, _ := strconv.ParseBool(c.enabled.Value) input.Enabled = &enabled } rules, err := rules.List(context.TODO(), fc, input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, rules); ok { return err } text.PrintRuleTbl(out, rules.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/root.go ================================================ package rule 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 = "rule" // 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 NGWAF Account-Level Rules") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/rule_test.go ================================================ package rule_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/rule" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( complexRulePath = "testdata/test_complex_rule.json" complexRuleID = "someComplexID" ruleDescription = "Utility requests" ruleEnabled = true ruleAction = "allow" ruleID = "someID" rulePath = "testdata/test_rule.json" ruleType = "request" ruleWorkspaceID = "someWorkspaceID" ) var rule = rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: ruleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{ruleWorkspaceID}, }, } var complexRule = rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: complexRuleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{ruleWorkspaceID}, }, } func TestRuleCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --path flag", Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), WantError: "error parsing arguments: required flag --path not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--path %s", rulePath), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), 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("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: fstfmt.Success("Created workspace-level rule with ID %s", ruleID), }, { Name: "validate API success with complex rule", Args: fmt.Sprintf("--path %s --workspace-id %s", complexRulePath, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(complexRule)))), }, }, }, WantOutput: fstfmt.Success("Created workspace-level rule with ID %s", complexRuleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--path %s --workspace-id %s --json", rulePath, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestRuleDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--rule-id %s", ruleID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --rule-id flag", Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate bad request", Args: "--rule-id bar --workspace-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 rule ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted workspace-level rule with id: %s", ruleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --workspace-id %s --json", ruleID, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, ruleID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestRuleGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--rule-id %s", ruleID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate missing --rule-id flag", Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate bad request", Args: "--rule-id baz --workspace-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 Rule ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: ruleString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --workspace-id %s --json", ruleID, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(rule)))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestRuleList(t *testing.T) { rulesObject := rules.Rules{ Data: []rules.Rule{ { CreatedAt: testutil.Date, Description: ruleDescription, Enabled: ruleEnabled, RuleID: ruleID, Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{ruleWorkspaceID}, }, }, { CreatedAt: testutil.Date, Description: ruleDescription + "2", Enabled: ruleEnabled, RuleID: ruleID + "2", Actions: []rules.Action{ { Type: ruleAction, }, }, Type: ruleType, Scope: rules.Scope{ Type: string(scope.ScopeTypeWorkspace), AppliesTo: []string{ruleWorkspaceID}, }, }, }, Meta: rules.MetaRules{}, } scenarios := []testutil.CLIScenario{ { Name: "validate internal server error", Args: "--workspace-id baz", 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 workspace-level Rules)", Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rules.Rules{ Data: []rules.Rule{}, Meta: rules.MetaRules{}, }))), }, }, }, WantOutput: zeroListRulesString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), }, }, }, WantOutput: listRulesString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rulesObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(rulesObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestRuleUpdate(t *testing.T) { ruleObject := rules.Rule{ CreatedAt: testutil.Date, Description: ruleDescription, RuleID: ruleID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --rule-id flag", Args: fmt.Sprintf("--path %s --workspace-id %s", rulePath, ruleWorkspaceID), WantError: "error parsing arguments: required flag --rule-id not provided", }, { Name: "validate missing --path flag", Args: fmt.Sprintf("--rule-id %s --workspace-id %s", ruleID, ruleWorkspaceID), WantError: "error parsing arguments: required flag --path not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--path %s --rule-id %s", rulePath, ruleID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--rule-id %s --path %s --workspace-id %s", ruleID, rulePath, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(ruleObject))), }, }, }, WantOutput: fstfmt.Success("Updated workspace-level rule with id: %s", ruleID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--rule-id %s --path %s --workspace-id %s --json", ruleID, rulePath, ruleWorkspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(rule))), }, }, }, WantOutput: fstfmt.EncodeJSON(rule), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listRulesString = strings.TrimSpace(` ID Action Description Enabled Type Scope Updated At Created At someID allow Utility requests true request workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someID2 allow Utility requests2 true request workspace 0001-01-01 00:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListRulesString = strings.TrimSpace(` ID Action Description Enabled Type Scope Updated At Created At `) + "\n" var ruleString = strings.TrimSpace(` ID: someID Action: allow Description: Utility requests Enabled: true Type: request Scope: workspace Updated (UTC): 0001-01-01 00:00 Created (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/rule/testdata/test_complex_rule.json ================================================ { "type": "request", "description": "complex_test", "enabled": true, "expires_at": "", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "equals", "value": "1.2.3.4" }, { "type": "single", "field": "country", "operator": "equals", "value": "AE" }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "equals", "value": "2.4.5.6" }, { "type": "single", "field": "country", "operator": "equals", "value": "AD" } ] }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "domain", "operator": "equals", "value": "test.com" }, { "type": "single", "field": "agent_name", "operator": "equals", "value": "test" } ] }, { "type": "group", "group_operator": "all", "conditions": [ { "type": "single", "field": "ip", "operator": "in_list", "value": "site.blacklist" }, { "type": "multival", "field": "request_header", "operator": "exists", "group_operator": "all", "conditions": [ { "type": "single", "field": "name", "operator": "equals", "value": "x-something" }, { "type": "single", "field": "value_string", "operator": "equals", "value": "abc-123" } ] } ] } ], "actions": [ { "type": "allow" } ], "request_logging": "sampled" } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/testdata/test_rule.json ================================================ { "type": "request", "enabled": true, "description": "Utility requests", "group_operator": "all", "request_logging": "sampled", "conditions": [ { "type": "single", "field": "path", "operator": "equals", "value": "/echo.json" } ], "actions": [ { "type": "allow" } ] } ================================================ FILE: pkg/commands/ngwaf/workspace/rule/update.go ================================================ package rule import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "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 workspace-level rule. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. path string ruleID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace") // Required. c.CmdClause.Flag("rule-id", "Rule ID").Required().StringVar(&c.ruleID) c.CmdClause.Flag("path", "Path to a json file that contains the rule schema.").Required().StringVar(&c.path) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.RegisterFlagBool(c.JSONFlag()) 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 } if err := c.workspaceID.Parse(); err != nil { return err } rule := &rules.Rule{} if c.path != "" { path, err := filepath.Abs(c.path) if err != nil { return fmt.Errorf("error parsing path '%s': %q", c.path, err) } jsonFile, err := os.Open(path) if err != nil { return fmt.Errorf("error reading path '%s': %q", c.path, err) } defer jsonFile.Close() byteValue, err := io.ReadAll(jsonFile) if err != nil { return fmt.Errorf("failed to read json file: %v", err) } if err := json.Unmarshal(byteValue, rule); err != nil { return fmt.Errorf("failed to unmarshal json data: %v", err) } } input := &rules.UpdateInput{ Actions: []*rules.UpdateAction{}, Conditions: []*rules.UpdateCondition{}, Description: &rule.Description, GroupConditions: []*rules.UpdateGroupCondition{}, MultivalConditions: []*rules.UpdateMultivalCondition{}, Enabled: &rule.Enabled, Type: &rule.Type, GroupOperator: &rule.GroupOperator, RequestLogging: &rule.RequestLogging, RuleID: &c.ruleID, Scope: &scope.Scope{ Type: scope.ScopeTypeWorkspace, AppliesTo: []string{c.workspaceID.Value}, }, } for _, action := range rule.Actions { input.Actions = append(input.Actions, &rules.UpdateAction{ AllowInteractive: action.AllowInteractive, DeceptionType: &action.DeceptionType, RedirectURL: &action.RedirectURL, ResponseCode: &action.ResponseCode, Signal: &action.Signal, Type: &action.Type, }) } if rule.RateLimit != nil { input.RateLimit = &rules.UpdateRateLimit{ ClientIdentifiers: []*rules.UpdateClientIdentifier{}, Duration: &rule.RateLimit.Duration, Interval: &rule.RateLimit.Interval, Signal: &rule.RateLimit.Signal, Threshold: &rule.RateLimit.Threshold, } for _, rateLimit := range rule.RateLimit.ClientIdentifiers { input.RateLimit.ClientIdentifiers = append(input.RateLimit.ClientIdentifiers, &rules.UpdateClientIdentifier{ Key: &rateLimit.Key, Name: &rateLimit.Name, Type: &rateLimit.Type, }) } } for _, jsonCondition := range rule.Conditions { switch jsonCondition.Type { case "single": if sc, ok := jsonCondition.Fields.(rules.SingleCondition); ok { input.Conditions = append(input.Conditions, &rules.UpdateCondition{ Field: &sc.Field, Operator: &sc.Operator, Value: &sc.Value, }) } else { return fmt.Errorf("expected SingleCondition, got %T", jsonCondition.Fields) } case "group": if gc, ok := jsonCondition.Fields.(rules.GroupCondition); ok { parsedGroupCondition := &rules.UpdateGroupCondition{ GroupOperator: &gc.GroupOperator, Conditions: []*rules.UpdateCondition{}, } for _, groupCondition := range gc.Conditions { switch groupCondition.Type { case "single": if gsc, ok := groupCondition.Fields.(rules.SingleCondition); ok { parsedGroupCondition.Conditions = append(parsedGroupCondition.Conditions, &rules.UpdateCondition{ Field: &gsc.Field, Operator: &gsc.Operator, Value: &gsc.Value, }) } else { return fmt.Errorf("expected Condition, got %T", groupCondition.Fields) } case "multival": if gmvc, ok := groupCondition.Fields.(rules.MultivalCondition); ok { updateMultivalCondition := &rules.UpdateMultivalCondition{ Field: &gmvc.Field, Operator: &gmvc.Operator, GroupOperator: &gmvc.GroupOperator, Conditions: []*rules.UpdateConditionMult{}, } for _, groupMultivalSingleCondition := range gmvc.Conditions { updateMultivalCondition.Conditions = append(updateMultivalCondition.Conditions, &rules.UpdateConditionMult{ Field: &groupMultivalSingleCondition.Field, Operator: &groupMultivalSingleCondition.Operator, Value: &groupMultivalSingleCondition.Value, }) } parsedGroupCondition.MultivalConditions = append(parsedGroupCondition.MultivalConditions, updateMultivalCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", groupCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", groupCondition.Type) } } input.GroupConditions = append(input.GroupConditions, parsedGroupCondition) } else { return fmt.Errorf("expected GroupCondition, got %T", jsonCondition.Fields) } case "multival": if mvc, ok := jsonCondition.Fields.(rules.UpdateMultivalCondition); ok { parsedMultiValCondition := &rules.UpdateMultivalCondition{ Field: mvc.Field, GroupOperator: mvc.GroupOperator, Operator: mvc.Operator, Conditions: []*rules.UpdateConditionMult{}, } for _, multiSingleCondition := range mvc.Conditions { parsedMultiValCondition.Conditions = append(parsedMultiValCondition.Conditions, &rules.UpdateConditionMult{ Field: multiSingleCondition.Field, Operator: multiSingleCondition.Operator, Value: multiSingleCondition.Value, }) } input.MultivalConditions = append(input.MultivalConditions, parsedMultiValCondition) } else { return fmt.Errorf("expected MultivalCondition, got %T", jsonCondition.Fields) } default: return fmt.Errorf("unknown condition type: %s", jsonCondition.Type) } } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := rules.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated workspace-level rule with id: %s", data.RuleID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/create.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level signal lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level signal list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, Name: c.name, Type: "signal", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Workspace Signal List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/delete.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level signal list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Workspace Signal List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/doc.go ================================================ // Package signallist contains commands to inspect and manipulate NGWAF workspace-level signal lists. package signallist ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/get.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get a workspace-level signal list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/list.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all signal lists for your workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 signal lists for your workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeWorkspace, Type: "signal", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/root.go ================================================ package signallist 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 = "signal-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, "Manage NGWAF Workspace Signal Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/signallist_test.go ================================================ package signallist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/signallist" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "BHH" listType = "signal" listName = "listName" workspaceID = "someWorkspaceID" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } func TestSignalListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s --workspace-id %s", listName, workspaceID), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s --workspace-id %s", listEntries, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), 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("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Workspace Signal List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s --json", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestSignalListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id bar --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Workspace Signal List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestSignalListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id baz --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestSignalListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspaces)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: listListsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestSignalListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Workspace Signal List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s --json", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList signal workspace BHH 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 signal workspace BHH 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: signal Entries: BHH Scope: workspace Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/signallist/update.go ================================================ package signallist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace signal list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID // Optional. description argparser.OptionalString entries 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 workspace-level signal list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Workspace Signal List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/create.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level string lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level string list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, Name: c.name, Type: "string", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Workspace String List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/delete.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level string list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Workspace String List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/doc.go ================================================ // Package stringlist contains commands to inspect and manipulate NGWAF workspace-level string lists. package stringlist ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/get.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get a workspace-level string list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/list.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all string lists for your workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 string lists for your workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeWorkspace, Type: "string", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/root.go ================================================ package stringlist 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 = "string-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, "Manage NGWAF Workspace String Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/stringlist_test.go ================================================ package stringlist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "string" listName = "listName" workspaceID = "someWorkspaceID" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } func TestStringListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s --workspace-id %s", listName, workspaceID), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s --workspace-id %s", listEntries, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), 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("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Workspace String List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s --json", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestStringListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id bar --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Workspace String List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestStringListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id baz --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestStringListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspaces)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: listListsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestStringListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Workspace String List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s --json", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList string workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 string workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: string Entries: 1.0.0.0 Scope: workspace Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/stringlist/update.go ================================================ package stringlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace string list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID // Optional. description argparser.OptionalString entries 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 workspace-level string list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Workspace String List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/create.go ================================================ package threshold import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" "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 workspace threshold. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. action string dontNotify argparser.OptionalString duration int enabled argparser.OptionalString interval int limit int name string signal string workspaceID argparser.OptionalWorkspaceID // Optional. } // 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 workspace threshold").Alias("add") // Required. c.CmdClause.Flag("action", "The action to take when the threshold is exceeded. [block, log]").Required().StringVar(&c.action) c.CmdClause.Flag("do-not-notify", "Whether to silence notifications when action is taken. [true, false]").Required().Action(c.dontNotify.Set).StringVar(&c.dontNotify.Value) c.CmdClause.Flag("duration", "The duration the action is in place in seconds. Default duration is 86,400 seconds (1 day).").Required().IntVar(&c.duration) c.CmdClause.Flag("enabled", "Whether the threshold is active. [true, false]").Required().Action(c.enabled.Set).StringVar(&c.enabled.Value) c.CmdClause.Flag("interval", "The threshold interval in seconds. The default interval is 3600 seconds (1 hour).").Required().IntVar(&c.interval) c.CmdClause.Flag("limit", "The threshold limit. Input must be between 1 and 10000. Default limit is 10.").Required().IntVar(&c.limit) c.CmdClause.Flag("name", "User submitted display name of a signal threshold. Input must be between 3 and 50 characters").Required().StringVar(&c.name) c.CmdClause.Flag("signal", "The name of the signal this threshold is acting on").Required().StringVar(&c.signal) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } enabled, err := argparser.ConvertBoolFromStringFlag(c.enabled.Value, "enabled") if err != nil { c.Globals.ErrLog.Add(err) return err } dontNotify, err := argparser.ConvertBoolFromStringFlag(c.dontNotify.Value, "do-not-notify") if err != nil { c.Globals.ErrLog.Add(err) return err } input := &thresholds.CreateInput{ Action: &c.action, Duration: &c.duration, Enabled: enabled, Interval: &c.interval, Limit: &c.limit, Name: &c.name, DontNotify: dontNotify, Signal: &c.signal, WorkspaceID: &c.workspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := thresholds.Create(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created threshold '%s' for workspace '%s'", data.ThresholdID, c.workspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/delete.go ================================================ package threshold import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" "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 workspace threshold. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. thresholdID string workspaceID argparser.OptionalWorkspaceID // Optional. } // 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", "Deletes a workspace threshold") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) // 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := thresholds.Delete(context.TODO(), fc, &thresholds.DeleteInput{ ThresholdID: &c.thresholdID, WorkspaceID: &c.workspaceID.Value, }) 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.thresholdID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted threshold (id: %s)", c.thresholdID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/doc.go ================================================ // Package threshold contains commands to inspect and manipulate NGWAF workspace thresholds. package threshold ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/get.go ================================================ package threshold import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" "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 a workspace threshold. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. thresholdID string workspaceID argparser.OptionalWorkspaceID // Optional. } // 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", "Retrieves a workspace threshold") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) // 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } input := &thresholds.GetInput{ ThresholdID: &c.thresholdID, WorkspaceID: &c.workspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := thresholds.Get(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintThreshold(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/list.go ================================================ package threshold import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" "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 workspace thresholds. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID // Optional. } // 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 workspace thresholds") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } input := &thresholds.ListInput{ WorkspaceID: &c.workspaceID.Value, } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := thresholds.List(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintThresholdTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/root.go ================================================ package threshold 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 = "threshold" // 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 NGWAF Workspace Thresholds") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/threshold_test.go ================================================ package threshold_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspace "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" ) const ( thresholdAction = "block" thresholdDuration = 86400 thresholdEnabled = true thresholdID = "thresholdID" thresholdInterval = 3600 thresholdLimit = 10 thresholdName = "Test_Threshold" thresholdSignal = "test-signal" thresholdDontNotify = false workspaceID = "workspaceID" ) var threshold = thresholds.Threshold{ Action: thresholdAction, CreatedAt: testutil.Date, DontNotify: thresholdDontNotify, Duration: thresholdDuration, Enabled: thresholdEnabled, Interval: thresholdInterval, Limit: thresholdLimit, Name: thresholdName, Signal: thresholdSignal, ThresholdID: thresholdID, } func TestThresholdCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --action flag", Args: fmt.Sprintf("--name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --action not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--action %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --signal flag", Args: fmt.Sprintf("--action %s --name %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --signal not provided", }, { Name: "validate missing --do-not-notify flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --do-not-notify not provided", }, { Name: "validate missing --duration flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --duration not provided", }, { Name: "validate missing --enabled flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdInterval, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --enabled not provided", }, { Name: "validate missing --interval flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdLimit, workspaceID), WantError: "error parsing arguments: required flag --interval not provided", }, { Name: "validate missing --limit flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, workspaceID), WantError: "error parsing arguments: required flag --limit not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), 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("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), }, }, }, WantOutput: fstfmt.Success("Created threshold '%s' for workspace '%s'", thresholdID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s --json", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(threshold))), }, }, }, WantOutput: fstfmt.EncodeJSON(threshold), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "create"}, scenarios) } func TestThresholdDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --threshold-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --threshold-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--threshold-id %s", thresholdID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), 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 Threshold ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted threshold (id: %s)", thresholdID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --json", thresholdID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, thresholdID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "delete"}, scenarios) } func TestThresholdGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --threshold-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --threshold-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--threshold-id %s", thresholdID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), 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 Threshold ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), }, }, }, WantOutput: thresholdString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --json", thresholdID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), }, }, }, WantOutput: fstfmt.EncodeJSON(threshold), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "get"}, scenarios) } func TestThresholdList(t *testing.T) { thresholdsObject := thresholds.Thresholds{ Data: []thresholds.Threshold{ { Action: thresholdAction, CreatedAt: testutil.Date, DontNotify: thresholdDontNotify, Duration: thresholdDuration, Enabled: thresholdEnabled, Interval: thresholdInterval, Limit: thresholdLimit, Name: thresholdName, Signal: thresholdSignal, ThresholdID: thresholdID, }, { Action: "log", CreatedAt: testutil.Date, DontNotify: true, Duration: 43200, Enabled: false, Interval: 600, Limit: 20, Name: "Test_Threshold_2", Signal: "test-signal-2", ThresholdID: thresholdID + "2", }, }, Meta: thresholds.MetaThresholds{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 thresholds)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholds.Thresholds{ Data: []thresholds.Threshold{}, Meta: thresholds.MetaThresholds{}, }))), }, }, }, WantOutput: zeroListThresholdString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), }, }, }, WantOutput: listThresholdString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(thresholdsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "list"}, scenarios) } func TestThresholdUpdate(t *testing.T) { thresholdsObject := thresholds.Threshold{ Action: thresholdAction, CreatedAt: testutil.Date, DontNotify: thresholdDontNotify, Duration: thresholdDuration, Enabled: thresholdEnabled, Interval: thresholdInterval, Limit: thresholdLimit, Name: thresholdName, Signal: thresholdSignal, ThresholdID: thresholdID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --threshold-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --threshold-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--threshold-id %s", thresholdID), WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d", thresholdID, workspaceID, thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), }, }, }, WantOutput: fstfmt.Success("Updated threshold '%s' for workspace '%s'", thresholdID, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --json", thresholdID, workspaceID, thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(threshold))), }, }, }, WantOutput: fstfmt.EncodeJSON(threshold), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "update"}, scenarios) } var listThresholdString = strings.TrimSpace(` Signal Name ID Action Enabled Do Not Notify Limit Interval Duration Created At test-signal Test_Threshold thresholdID block true false 10 3600 86400 2021-06-15T23:00:00Z test-signal-2 Test_Threshold_2 thresholdID2 log false true 20 600 43200 2021-06-15T23:00:00Z `) + "\n" var zeroListThresholdString = strings.TrimSpace(` Signal Name ID Action Enabled Do Not Notify Limit Interval Duration Created At `) + "\n" var thresholdString = strings.TrimSpace(` Signal: test-signal Name: Test_Threshold Action: block Do Not Notify: false Duration: 86400 Enabled: true Interval: 3600 Limit: 10 `) ================================================ FILE: pkg/commands/ngwaf/workspace/threshold/update.go ================================================ package threshold import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" "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 workspace threshold. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. thresholdID string workspaceID argparser.OptionalWorkspaceID // Optional. action argparser.OptionalString dontNotify argparser.OptionalString duration argparser.OptionalInt enabled argparser.OptionalString interval argparser.OptionalInt limit argparser.OptionalInt name argparser.OptionalString signal 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 workspace threshold") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) // Optional. c.CmdClause.Flag("action", "The action to take when the threshold is exceeded. [block, log]").Action(c.action.Set).StringVar(&c.action.Value) c.CmdClause.Flag("do-not-notify", "Whether to silence notifications when action is taken. [true, false]").Action(c.dontNotify.Set).StringVar(&c.dontNotify.Value) c.CmdClause.Flag("duration", "The duration the action is in place in seconds. Default duration is 86,400 seconds (1 day).").Action(c.duration.Set).IntVar(&c.duration.Value) c.CmdClause.Flag("enabled", "Whether the threshold is active. [true, false]").Action(c.enabled.Set).StringVar(&c.enabled.Value) c.CmdClause.Flag("interval", "The threshold interval in seconds. The default interval is 3600 seconds (1 hour).").Action(c.interval.Set).IntVar(&c.interval.Value) c.CmdClause.Flag("limit", "The threshold limit. Input must be between 1 and 10000. Default limit is 10.").Action(c.limit.Set).IntVar(&c.limit.Value) c.CmdClause.Flag("name", "User submitted display name of a signal threshold. Input must be between 3 and 50 characters").Action(c.name.Set).StringVar(&c.name.Value) c.CmdClause.Flag("signal", "The name of the signal this threshold is acting on").Action(c.signal.Set).StringVar(&c.signal.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } // Call Parse() to ensure that we check if workspaceID // is set or to throw the appropriate error. if err := c.workspaceID.Parse(); err != nil { return err } input := &thresholds.UpdateInput{ ThresholdID: &c.thresholdID, WorkspaceID: &c.workspaceID.Value, } if c.action.WasSet { input.Action = &c.action.Value } if c.dontNotify.WasSet { dontNotify, err := argparser.ConvertBoolFromStringFlag(c.dontNotify.Value, "do-not-notify") if err != nil { c.Globals.ErrLog.Add(err) return err } input.DontNotify = dontNotify } if c.duration.WasSet { input.Duration = &c.duration.Value } if c.enabled.WasSet { enabled, err := argparser.ConvertBoolFromStringFlag(c.enabled.Value, "enabled") if err != nil { c.Globals.ErrLog.Add(err) return err } input.Enabled = enabled } if c.interval.WasSet { input.Interval = &c.interval.Value } if c.limit.WasSet { input.Limit = &c.limit.Value } if c.name.WasSet { input.Name = &c.name.Value } if c.signal.WasSet { input.Signal = &c.signal.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := thresholds.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated threshold '%s' for workspace '%s'", data.ThresholdID, c.workspaceID.Value) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/update.go ================================================ package workspace import ( "context" "errors" "io" "strconv" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" "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 workspaces. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID string // Optional. description argparser.OptionalString blockingMode argparser.OptionalString name argparser.OptionalString attackThresholds argparser.OptionalString defaultBlockingCode argparser.OptionalInt defaultRedirectURL argparser.OptionalString clientIPHeaders argparser.OptionalString ipAnonymization 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 workspace") // Required. c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // Optional. c.CmdClause.Flag("description", "User submitted description of a workspace.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("blockingMode", "User configured mode blocking mode.").Action(c.blockingMode.Set).StringVar(&c.blockingMode.Value) c.CmdClause.Flag("name", "User submitted display name of a workspace.").Action(c.name.Set).StringVar(&c.name.Value) c.CmdClause.Flag("attackThresholds", "Attack threshold parameters for system site alerts. Each threshold value is the number of attack signals per IP address that must be detected during the interval before the related IP address is flagged. Input accepted as colon separated string: Immediate:OneMinute:TenMinutes:OneHour").Action(c.attackThresholds.Set).StringVar(&c.attackThresholds.Value) c.CmdClause.Flag("clientIPHeaders", "Specify the request header containing the client IP address. Input accepted as colon separated string.").Action(c.clientIPHeaders.Set).StringVar(&c.clientIPHeaders.Value) c.CmdClause.Flag("defaultBlockingCode", "Default status code that is returned when a request to your web application is blocked.").Action(c.defaultBlockingCode.Set).IntVar(&c.defaultBlockingCode.Value) c.CmdClause.Flag("defaultRedirectURL", "Redirect url to be used if code 301 or 302 is used.").Action(c.defaultRedirectURL.Set).StringVar(&c.defaultRedirectURL.Value) c.CmdClause.Flag("ipAnonymization", "Agents will anonymize IP addresses according to the option selected.").Action(c.ipAnonymization.Set).StringVar(&c.ipAnonymization.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } var err error input := &workspaces.UpdateInput{ WorkspaceID: &c.workspaceID, } if c.blockingMode.WasSet { input.Mode = &c.blockingMode.Value } if c.description.WasSet { input.Description = &c.description.Value } if c.name.WasSet { input.Name = &c.name.Value } if c.attackThresholds.WasSet { input.AttackSignalThresholds, err = parseUpdateAttackSignalThresholds(c.attackThresholds.Value) if err != nil { return err } } if c.clientIPHeaders.WasSet { input.ClientIPHeaders = strings.Split(c.clientIPHeaders.Value, ":") } if c.defaultBlockingCode.WasSet { input.DefaultBlockingResponseCode = &c.defaultBlockingCode.Value } if c.defaultRedirectURL.WasSet { input.DefaultRedirectURL = &c.defaultRedirectURL.Value } if c.ipAnonymization.WasSet { input.IPAnonymization = &c.ipAnonymization.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := workspaces.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated workspace '%s' (workspace-id: %s)", data.Name, data.WorkspaceID) return nil } func parseUpdateAttackSignalThresholds(thresholds string) (*workspaces.AttackSignalThresholdsUpdateInput, error) { thresholdsArray := strings.Split(thresholds, ":") if len(thresholdsArray) != 4 { return nil, errors.New("wrong number of inputs for Attack Signal Thresholds") } immediate, err := strconv.ParseBool(thresholdsArray[0]) if err != nil { return nil, err } oneMinute, err := strconv.Atoi(thresholdsArray[1]) if err != nil { return nil, err } tenMinutes, err := strconv.Atoi(thresholdsArray[2]) if err != nil { return nil, err } oneHour, err := strconv.Atoi(thresholdsArray[3]) if err != nil { return nil, err } return &workspaces.AttackSignalThresholdsUpdateInput{ OneMinute: &oneMinute, TenMinutes: &tenMinutes, OneHour: &oneHour, Immediate: &immediate, }, nil } ================================================ FILE: pkg/commands/ngwaf/workspace/virtualpatch/list.go ================================================ package virtualpatch import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/virtualpatches" "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 virtual patches in a workspace. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID 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 virtual patches in a workspace") // Required. c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // 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") } data, err := virtualpatches.List(context.TODO(), fc, &virtualpatches.ListInput{ WorkspaceID: &c.workspaceID, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } // Currently we are leaving the table to output the default // number of virtual patches, which is 100. At this time // this is sufficient as there are only 40 total, however, // we may need to rework this in the future. text.PrintVirtualPatchTbl(out, data.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/virtualpatch/retrieve.go ================================================ package virtualpatch import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/virtualpatches" "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 a virtual patch. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. virtualpatchID string workspaceID string } // NewGetCommand returns a usable command registered under the parent. func NewRetrieveCommand(parent argparser.Registerer, g *global.Data) *GetCommand { c := GetCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("retrieve", "Retrieve a virtual patch").Alias("get") // Required. c.CmdClause.Flag("virtual-patch-id", "Virtual Patch ID").Required().StringVar(&c.virtualpatchID) c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // 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") } data, err := virtualpatches.Get(context.TODO(), fc, &virtualpatches.GetInput{ VirtualPatchID: &c.virtualpatchID, WorkspaceID: &c.workspaceID, }) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.PrintVirtualPatch(out, data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/virtualpatch/root.go ================================================ package virtualpatch 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 = "virtualpatch" // 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 NGWAF Virtual Patches") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/virtualpatch/update.go ================================================ package virtualpatch import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/virtualpatches" "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 virtual patches. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. virtualpatchID string workspaceID string // Optional. enabled argparser.OptionalString mode 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 virtual patch") // Required. c.CmdClause.Flag("virtual-patch-id", "Virtual Patch ID").Required().StringVar(&c.virtualpatchID) c.CmdClause.Flag("workspace-id", "Workspace ID").Required().StringVar(&c.workspaceID) // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: "enabled", Description: "Specify the toggle status indicator of the virtual patch.", Action: c.enabled.Set, Dst: &c.enabled.Value, }) c.CmdClause.Flag("mode", "Specify the action to take when a signal for virtual patch is detected.").Action(c.mode.Set).StringVar(&c.mode.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } var err error input := &virtualpatches.UpdateInput{ VirtualPatchID: &c.virtualpatchID, WorkspaceID: &c.workspaceID, } if c.enabled.WasSet { enabled, err := argparser.ConvertBoolFromStringFlag(c.enabled.Value, "enabled") if err != nil { c.Globals.ErrLog.Add(err) return err } input.Enabled = enabled } if c.mode.WasSet { input.Mode = &c.mode.Value } fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := virtualpatches.Update(context.TODO(), fc, input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated virtual patch '%s' (enabled: %t, mode: %s)", data.ID, data.Enabled, data.Mode) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/virtualpatch/virtualpatch_test.go ================================================ package virtualpatch_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" workspace "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/virtualpatch" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/virtualpatches" ) const ( virtualpatchID = "CVE-2017-5638" virtualpatchDescription = "Apache Struts multipart/form remote execution" virtualpatchEnabled = false virtualpatchMode = "log" workspaceID = "nBw2ENWfOY1M2dpSwK1l5R" ) var virtualpatch = virtualpatches.VirtualPatch{ ID: virtualpatchID, Description: virtualpatchDescription, Enabled: virtualpatchEnabled, Mode: virtualpatchMode, } func TestVirtualPatchRetrieve(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--virtual-patch-id %s", virtualpatchID), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate missing --virtual-patch-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --virtual-patch-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id invalid", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(virtualpatch)))), }, }, }, WantOutput: virtualpatchString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --json", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(virtualpatch)))), }, }, }, WantOutput: fstfmt.EncodeJSON(virtualpatch), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "retrieve"}, scenarios) } func TestVirtualPatchList(t *testing.T) { virtualpatchesObject := virtualpatches.VirtualPatches{ Data: []virtualpatches.VirtualPatch{ { ID: "CVE-2024-5806", Description: "Progress MOVEit Transfer Authentication Bypass Vulnerability", Enabled: false, Mode: "log", }, { ID: "CVE-2024-34102", Description: "Adobe Commerce and Magento Open Source Unauthenticated XML Entity Injection", Enabled: false, Mode: "log", }, }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 virtual patches)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(virtualpatches.VirtualPatches{ Data: []virtualpatches.VirtualPatch{}, }))), }, }, }, WantOutput: zeroListVirtualPatchString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(virtualpatchesObject))), }, }, }, WantOutput: listVirtualPatchString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(virtualpatchesObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(virtualpatchesObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "list"}, scenarios) } func TestVirtualPatchUpdate(t *testing.T) { updatedVirtualPatch := virtualpatches.VirtualPatch{ ID: virtualpatchID, Description: virtualpatchDescription, Enabled: true, Mode: "block", } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--virtual-patch-id %s", virtualpatchID), WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate missing --virtual-patch-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --virtual-patch-id not provided", }, { Name: "validate not found", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id invalid --enabled true", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNotFound, Status: http.StatusText(http.StatusNotFound), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` { "title": "This resource does not exist", "status": 404 } `))), }, }, }, WantError: "404 - Not Found", }, { Name: "validate invalid enabled flag value", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --enabled maybe", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedVirtualPatch))), }, }, }, WantError: "'enabled' flag must be one of the following [true, false]", }, { Name: "validate API success with enabled flag", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --enabled true", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedVirtualPatch))), }, }, }, WantOutput: fstfmt.Success("Updated virtual patch '%s' (enabled: %t, mode: %s)", updatedVirtualPatch.ID, updatedVirtualPatch.Enabled, updatedVirtualPatch.Mode), }, { Name: "validate API success with mode flag", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --mode block", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedVirtualPatch))), }, }, }, WantOutput: fstfmt.Success("Updated virtual patch '%s' (enabled: %t, mode: %s)", updatedVirtualPatch.ID, updatedVirtualPatch.Enabled, updatedVirtualPatch.Mode), }, { Name: "validate API success with both flags", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --enabled true --mode block", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedVirtualPatch))), }, }, }, WantOutput: fstfmt.Success("Updated virtual patch '%s' (enabled: %t, mode: %s)", updatedVirtualPatch.ID, updatedVirtualPatch.Enabled, updatedVirtualPatch.Mode), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --virtual-patch-id %s --enabled true --mode block --json", workspaceID, virtualpatchID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatedVirtualPatch))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatedVirtualPatch), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "update"}, scenarios) } var virtualpatchString = strings.TrimSpace(` ID: CVE-2017-5638 Description: Apache Struts multipart/form remote execution Enabled: false Mode: log `) var listVirtualPatchString = strings.TrimSpace(` ID Description Enabled Mode CVE-2024-5806 Progress MOVEit Transfer Authentication Bypass Vulnerability false log CVE-2024-34102 Adobe Commerce and Magento Open Source Unauthenticated XML Entity Injection false log `) + "\n" var zeroListVirtualPatchString = strings.TrimSpace(` ID Description Enabled Mode `) + "\n" ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/create.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level wildcard lists. type CreateCommand struct { argparser.Base argparser.JSONOutput // Required. entries string name string workspaceID argparser.OptionalWorkspaceID // Optional. description 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 workspace-level wildcard list").Alias("add") // Required. c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Required().StringVar(&c.entries) c.CmdClause.Flag("name", "User submitted display name of a list.").Required().StringVar(&c.name) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) 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 } input := ngwaflist.ListCreateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, Name: c.name, Type: "wildcard", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListCreate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Created Workspace Wildcard List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/delete.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace-level wildcardip list. type DeleteCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 workspace wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListDeleteInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } err := ngwaflist.ListDelete(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.listID, true, } _, err := c.WriteJSON(out, o) return err } text.Success(out, "Deleted Workspace Wildcard List (list id: %s)", c.listID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/doc.go ================================================ // Package wildcardlist contains commands to inspect and manipulate NGWAF workspace-level wildcard lists. package wildcardlist ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/get.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // GetCommand calls the Fastly API to get a workspace-level wildcard list. type GetCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID } // 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 a workspace-level wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListGetInput{ CommandScope: scope.ScopeTypeWorkspace, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } list, err := ngwaflist.ListGet(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, list); ok { return err } text.PrintList(out, list) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/list.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) // ListCommand calls the Fastly API to list all wildcard lists for your API token. type ListCommand struct { argparser.Base argparser.JSONOutput // Required. workspaceID argparser.OptionalWorkspaceID } // 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 wildcard lists for your workspace") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // 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 } input := ngwaflist.ListListInput{ CommandScope: scope.ScopeTypeWorkspace, Type: "wildcard", WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } lists, err := ngwaflist.ListList(input) if err != nil { c.Globals.ErrLog.Add(err) return err } if ok, err := c.WriteJSON(out, *lists); ok { return err } text.PrintListTbl(out, lists.Data) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/root.go ================================================ package wildcardlist 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 = "wildcard-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, "Manage NGWAF Account Wildcard Lists") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/update.go ================================================ package wildcardlist import ( "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/ngwaf/ngwaflist" 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 workspace wildcard list. type UpdateCommand struct { argparser.Base argparser.JSONOutput // Required. listID string workspaceID argparser.OptionalWorkspaceID // Optional. description argparser.OptionalString entries 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 workspace-level wildcard list") // Required. c.CmdClause.Flag("list-id", "List ID").Required().StringVar(&c.listID) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagNGWAFWorkspaceID, Description: argparser.FlagNGWAFWorkspaceIDDesc, Dst: &c.workspaceID.Value, Action: c.workspaceID.Set, }) // Optional. c.CmdClause.Flag("description", "User submitted description of the list.").Action(c.description.Set).StringVar(&c.description.Value) c.CmdClause.Flag("entries", "Entries for the list. Can either be a comma separated list or a path to a file.").Action(c.entries.Set).StringVar(&c.entries.Value) c.RegisterFlagBool(c.JSONFlag()) 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 } input := ngwaflist.ListUpdateInput{ CommandScope: scope.ScopeTypeWorkspace, Description: c.description, Entries: c.entries, ListID: c.listID, WorkspaceID: &c.workspaceID, } var ok bool input.FC, ok = c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } data, err := ngwaflist.ListUpdate(input) if err != nil { return err } if ok, err := c.WriteJSON(out, data); ok { return err } text.Success(out, "Updated Workspace Wildcard List '%s' (list id: %s)", data.Name, data.ListID) return nil } ================================================ FILE: pkg/commands/ngwaf/workspace/wildcardlist/wildcardlist_test.go ================================================ package wildcardlist_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" sub2 "github.com/fastly/cli/pkg/commands/ngwaf/workspace/wildcardlist" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" ) const ( listID = "someListID" listDescription = "NGWAFCLIList" listEntries = "1.0.0.0" listType = "wildcard" listName = "listName" workspaceID = "someWorkspaceID" ) var stringlist = lists.List{ ListID: listID, Description: listDescription, Entries: []string{listEntries}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } var stringlist2 = lists.List{ ListID: listID + "2", Description: listDescription + "2", Entries: []string{listEntries}, Name: listName + "2", Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } func TestWildcardListCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --entries flag", Args: fmt.Sprintf("--name %s --workspace-id %s", listName, workspaceID), WantError: "error parsing arguments: required flag --entries not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--entries %s --workspace-id %s", listEntries, workspaceID), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--entries %s --name %s", listEntries, listName), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), 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("--entries %s --name %s --workspace-id %s", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.Success("Created Workspace Wildcard List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--entries %s --name %s --workspace-id %s --json", listEntries, listName, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(stringlist))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "create"}, scenarios) } func TestWildcardListDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id bar --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted Workspace Wildcard List (list id: %s)", listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, listID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "delete"}, scenarios) } func TestWildcardListGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate bad request", Args: "--list-id baz --workspace-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 List ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --workspace-id %s", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: listString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --workspace-id %s --json", listID, workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(stringlist)))), }, }, }, WantOutput: fstfmt.EncodeJSON(stringlist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "get"}, scenarios) } func TestWildcardListList(t *testing.T) { listsObject := lists.Lists{ Data: []lists.List{ stringlist, stringlist2, }, Meta: lists.MetaLists{}, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate internal server error", Args: fmt.Sprintf("--workspace-id %s", workspaceID), 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 workspaces)", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(lists.Lists{ Data: []lists.List{}, Meta: lists.MetaLists{}, }))), }, }, }, WantOutput: zeroListString, }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: listListsString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(listsObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(listsObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "list"}, scenarios) } func TestWildcardListUpdate(t *testing.T) { updatelist := lists.List{ ListID: listID, Description: listDescription + "2", Entries: []string{listEntries + "2"}, Name: listName, Type: listType, CreatedAt: testutil.Date, UpdatedAt: testutil.Date, Scope: lists.Scope{ Type: string(scope.ScopeTypeWorkspace), }, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --list-id flag", Args: fmt.Sprintf("--workspace-id %s", workspaceID), WantError: "error parsing arguments: required flag --list-id not provided", }, { Name: "validate missing --workspace-id flag", Args: fmt.Sprintf("--list-id %s", listID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantError: "error reading workspace ID: no workspace ID found", }, { Name: "validate API success", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.Success("Updated Workspace Wildcard List '%s' (list id: %s)", listName, listID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--list-id %s --description %s --entries %s --workspace-id %s --json", listID, listDescription+"2", listEntries+"2", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updatelist))), }, }, }, WantOutput: fstfmt.EncodeJSON(updatelist), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, sub2.CommandName, "update"}, scenarios) } var listListsString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At someListID listName NGWAFCLIList wildcard workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC someListID2 listName2 NGWAFCLIList2 wildcard workspace 1.0.0.0 2021-06-15 23:00:00 +0000 UTC 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListString = strings.TrimSpace(` ID Name Description Type Scope Entries Updated At Created At `) + "\n" var listString = strings.TrimSpace(` ID: someListID Name: listName Description: NGWAFCLIList Type: wildcard Entries: 1.0.0.0 Scope: workspace Updated (UTC): 2021-06-15 23:00 `) ================================================ FILE: pkg/commands/ngwaf/workspace/workspace_test.go ================================================ package workspace_test import ( "bytes" "fmt" "io" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/ngwaf" sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace" fstfmt "github.com/fastly/cli/pkg/fmt" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" ) const ( workspaceDescription = "NGWAFCLIWorkspace" workspaceClientIPHeaders = "these:are:headers" workspaceID = "someID" workspaceMode = "log" workspaceName = "CLIWorkspace" ) var workspace = workspaces.Workspace{ AttackSignalThresholds: workspaces.AttackSignalThresholds{ Immediate: false, OneMinute: 0, TenMinutes: 0, OneHour: 0, }, ClientIPHeaders: []string{"these", "are", "headers"}, CreatedAt: testutil.Date, Description: workspaceDescription, Mode: workspaceMode, Name: workspaceName, WorkspaceID: workspaceID, } func TestWorkspacesCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --description flag", Args: fmt.Sprintf("--blockingMode %s --name %s", workspaceMode, workspaceName), WantError: "error parsing arguments: required flag --description not provided", }, { Name: "validate missing --blockingMode flag", Args: fmt.Sprintf("--description %s --name %s", workspaceDescription, workspaceName), WantError: "error parsing arguments: required flag --blockingMode not provided", }, { Name: "validate missing --name flag", Args: fmt.Sprintf("--description %s --blockingMode %s", workspaceDescription, workspaceMode), WantError: "error parsing arguments: required flag --name not provided", }, { Name: "validate internal server error", Args: fmt.Sprintf("--description %s --name %s --blockingMode %s", workspaceDescription, workspaceName, "invalidMode"), 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 --name %s --blockingMode %s --clientIPHeaders %s", workspaceDescription, workspaceName, workspaceMode, workspaceClientIPHeaders), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(workspace)))), }, }, }, WantOutput: fstfmt.Success("Created workspace '%s' (workspace-id: %s)", workspaceName, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--description %s --name %s --blockingMode %s --clientIPHeaders %s --json", workspaceDescription, workspaceName, workspaceMode, workspaceClientIPHeaders), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(workspace))), }, }, }, WantOutput: fstfmt.EncodeJSON(workspace), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestWorkspaceDelete(t *testing.T) { const workspaceID = "workspaceID" scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate bad request", Args: "--workspace-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 Workspace ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.Success("Deleted workspace (id: %s)", workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusNoContent, Status: http.StatusText(http.StatusNoContent), }, }, }, WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, workspaceID), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestWorkspaceGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate bad request", Args: "--workspace-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 Workspace ID", "status": 400 } `))), }, }, }, WantError: "400 - Bad Request", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(workspace)))), }, }, }, WantOutput: workspaceString, }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(workspace)))), }, }, }, WantOutput: fstfmt.EncodeJSON(workspace), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func TestWorkspaceList(t *testing.T) { workspacesObject := workspaces.Workspaces{ Data: []workspaces.Workspace{ { CreatedAt: testutil.Date, Description: workspaceDescription, Mode: workspaceMode, Name: workspaceName, WorkspaceID: workspaceID, }, { CreatedAt: testutil.Date, Description: workspaceDescription, Mode: workspaceMode, Name: workspaceName + "2", WorkspaceID: workspaceID + "2", }, }, Meta: workspaces.MetaWorkspaces{}, } 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 workspaces)", 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(workspaces.Workspaces{ Data: []workspaces.Workspace{}, Meta: workspaces.MetaWorkspaces{}, }))), }, }, }, WantOutput: zeroListWorkspaceString, }, { 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(workspacesObject))), }, }, }, WantOutput: listWorkspaceString, }, { 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(workspacesObject))), }, }, }, WantOutput: fstfmt.EncodeJSON(workspacesObject), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestWorkspaceUpdate(t *testing.T) { workspacesObject := workspaces.Workspace{ CreatedAt: testutil.Date, Description: workspaceDescription, Mode: workspaceMode, Name: workspaceName, WorkspaceID: workspaceID, } scenarios := []testutil.CLIScenario{ { Name: "validate missing --workspace-id flag", Args: "", WantError: "error parsing arguments: required flag --workspace-id not provided", }, { Name: "validate API success", Args: fmt.Sprintf("--workspace-id %s --description %s --name %s --blockingMode %s --clientIPHeaders %s", workspaceID, workspaceDescription, workspaceName, workspaceMode, workspaceClientIPHeaders), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(workspacesObject))), }, }, }, WantOutput: fstfmt.Success("Updated workspace '%s' (workspace-id: %s)", workspaceName, workspaceID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--workspace-id %s --description %s --name %s --blockingMode %s --clientIPHeaders %s --json", workspaceID, workspaceDescription, workspaceName, workspaceMode, workspaceClientIPHeaders), Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(workspace))), }, }, }, WantOutput: fstfmt.EncodeJSON(workspace), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } var listWorkspaceString = strings.TrimSpace(` ID Name Description Mode Created At someID CLIWorkspace NGWAFCLIWorkspace log 2021-06-15 23:00:00 +0000 UTC someID2 CLIWorkspace2 NGWAFCLIWorkspace log 2021-06-15 23:00:00 +0000 UTC `) + "\n" var zeroListWorkspaceString = strings.TrimSpace(` ID Name Description Mode Created At `) + "\n" var workspaceString = strings.TrimSpace(` ID: someID Name: CLIWorkspace Description: NGWAFCLIWorkspace Mode: log Attack Signal Thresholds: Immediate: false One Minute: 0 Ten Minutes: 0 One Hour: 0 Client IP Headers: these, are, headers Updated (UTC): 0001-01-01 00:00 `) ================================================ FILE: pkg/commands/objectstorage/accesskeys/accesskeys_test.go ================================================ 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/v15/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 Access 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 Access 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, Buckets: []string{"b1", "b2"}, }, }, 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 Permission Buckets Created At foo bar bat read-only-objects all 0001-01-01 00:00:00 +0000 UTC foobar baz bizz read-only-objects [b1 b2] 0001-01-01 00:00:00 +0000 UTC `) + "\n" var zeroListAccessKeysString = strings.TrimSpace(` ID Secret Description Permission Buckets Created At `) + "\n" ================================================ FILE: pkg/commands/objectstorage/accesskeys/create.go ================================================ package accesskeys import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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 (read-write-admin, read-only-admin, read-write-objects, read-only-objects)").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. If omitted, all buckets are associated").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(context.TODO(), 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 } ================================================ FILE: pkg/commands/objectstorage/accesskeys/delete.go ================================================ package accesskeys import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/objectstorage/accesskeys/doc.go ================================================ // Package accesskeys contains commands to inspect and manipulate access keys. package accesskeys ================================================ FILE: pkg/commands/objectstorage/accesskeys/get.go ================================================ package accesskeys import ( "context" "errors" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/objectstorage/accesskeys/list.go ================================================ package accesskeys import ( "context" "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/v15/fastly" "github.com/fastly/go-fastly/v15/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", "List all access keys").Alias("list-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(context.TODO(), 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 } ================================================ FILE: pkg/commands/objectstorage/accesskeys/root.go ================================================ 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") } ================================================ FILE: pkg/commands/objectstorage/doc.go ================================================ // Package objectstorage contains commands to inspect and manipulate stored objects. package objectstorage ================================================ FILE: pkg/commands/objectstorage/root.go ================================================ 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") } ================================================ FILE: pkg/commands/pop/doc.go ================================================ // Package pop contains commands to inspect and manipulate Fastly POP data. package pop ================================================ FILE: pkg/commands/pop/pop_test.go ================================================ package pop_test import ( "bytes" "context" "io" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context) ([]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()) } ================================================ FILE: pkg/commands/pop/root.go ================================================ package pop import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO()) 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 "" } ================================================ FILE: pkg/commands/products/doc.go ================================================ // Package products contains commands to inspect and manipulate Fastly products. package products ================================================ FILE: pkg/commands/products/products_test.go ================================================ package products_test import ( "testing" root "github.com/fastly/cli/pkg/commands/products" "github.com/fastly/cli/pkg/testutil" ) func TestProductEnablement(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing Service ID", EnvVars: map[string]string{"FASTLY_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 flag parsing error for enabling product", Args: "--service-id 123 --enable foo", WantError: "error parsing arguments: enum value must be one of api_discovery,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 api_discovery,bot_management,brotli_compression,domain_inspector,fanout,image_optimizer,log_explorer_insights,origin_inspector,websockets, got 'foo'", }, { Name: "validate invalid json/verbose flag combo", Args: "--service-id 123 --json --verbose", WantError: "invalid flag combination, --verbose and --json", }, } testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) } ================================================ FILE: pkg/commands/products/root.go ================================================ package products import ( "context" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/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/go-fastly/v15/fastly/products/apidiscovery" "github.com/fastly/go-fastly/v15/fastly/products/botmanagement" "github.com/fastly/go-fastly/v15/fastly/products/brotlicompression" "github.com/fastly/go-fastly/v15/fastly/products/domaininspector" "github.com/fastly/go-fastly/v15/fastly/products/fanout" "github.com/fastly/go-fastly/v15/fastly/products/imageoptimizer" "github.com/fastly/go-fastly/v15/fastly/products/logexplorerinsights" "github.com/fastly/go-fastly/v15/fastly/products/origininspector" "github.com/fastly/go-fastly/v15/fastly/products/websockets" ) // 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{ "api_discovery", "bot_management", "brotli_compression", "domain_inspector", "fanout", "image_optimizer", "log_explorer_insights", "origin_inspector", "websockets", } // ProductStatus indicates the status for each product. type ProductStatus struct { APIDiscovery bool `json:"api_discovery"` 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"` } // 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 { 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) } ac, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") } if c.enableProduct != "" { switch c.enableProduct { case "api_discovery": _, err = apidiscovery.Enable(context.TODO(), ac, serviceID) case "bot_management": _, err = botmanagement.Enable(context.TODO(), ac, serviceID) case "brotli_compression": _, err = brotlicompression.Enable(context.TODO(), ac, serviceID) case "domain_inspector": _, err = domaininspector.Enable(context.TODO(), ac, serviceID) case "fanout": _, err = fanout.Enable(context.TODO(), ac, serviceID) case "image_optimizer": _, err = imageoptimizer.Enable(context.TODO(), ac, serviceID) case "log_explorer_insights": _, err = logexplorerinsights.Enable(context.TODO(), ac, serviceID) case "origin_inspector": _, err = origininspector.Enable(context.TODO(), ac, serviceID) case "websockets": _, err = websockets.Enable(context.TODO(), ac, serviceID) default: return errors.New("unrecognised product") } if 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 != "" { switch c.disableProduct { case "api_discovery": err = apidiscovery.Disable(context.TODO(), ac, serviceID) case "bot_management": err = botmanagement.Disable(context.TODO(), ac, serviceID) case "brotli_compression": err = brotlicompression.Disable(context.TODO(), ac, serviceID) case "domain_inspector": err = domaininspector.Disable(context.TODO(), ac, serviceID) case "fanout": err = fanout.Disable(context.TODO(), ac, serviceID) case "image_optimizer": err = imageoptimizer.Disable(context.TODO(), ac, serviceID) case "log_explorer_insights": err = logexplorerinsights.Disable(context.TODO(), ac, serviceID) case "origin_inspector": err = origininspector.Disable(context.TODO(), ac, serviceID) case "websockets": err = websockets.Disable(context.TODO(), ac, serviceID) default: return errors.New("unrecognised product") } if 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 = apidiscovery.Get(context.TODO(), ac, serviceID); err == nil { ps.APIDiscovery = true } if _, err = botmanagement.Get(context.TODO(), ac, serviceID); err == nil { ps.BotManagement = true } if _, err = brotlicompression.Get(context.TODO(), ac, serviceID); err == nil { ps.BrotliCompression = true } if _, err = domaininspector.Get(context.TODO(), ac, serviceID); err == nil { ps.DomainInspector = true } if _, err = fanout.Get(context.TODO(), ac, serviceID); err == nil { ps.Fanout = true } if _, err = imageoptimizer.Get(context.TODO(), ac, serviceID); err == nil { ps.ImageOptimizer = true } if _, err = logexplorerinsights.Get(context.TODO(), ac, serviceID); err == nil { ps.LogExplorerInsights = true } if _, err = origininspector.Get(context.TODO(), ac, serviceID); err == nil { ps.OriginInspector = true } if _, err = websockets.Get(context.TODO(), ac, 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("API Discovery", ps.APIDiscovery) t.AddLine("Bot Management", ps.BotManagement) t.AddLine("Brotli Compression", ps.BrotliCompression) t.AddLine("Domain Inspector", ps.DomainInspector) t.AddLine("Fanout", ps.Fanout) t.AddLine("Image Optimizer", ps.ImageOptimizer) t.AddLine("Log Explorer & Insights", ps.LogExplorerInsights) t.AddLine("Origin Inspector", ps.OriginInspector) t.AddLine("WebSockets", ps.WebSockets) t.Print() return nil } ================================================ FILE: pkg/commands/profile/create.go ================================================ package profile import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "github.com/fastly/cli/pkg/argparser" authcmd "github.com/fastly/cli/pkg/commands/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/text" ) // CreateCommand represents a Kingpin command. type CreateCommand struct { argparser.Base profile string sso bool } // NewCreateCommand returns a new command registered in the parent. func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { var c CreateCommand c.Globals = g c.CmdClause = parent.Command("create", "Create user profile (deprecated: use 'fastly auth login' or 'fastly auth add' instead)") c.CmdClause.Arg("profile", "Profile to create (default 'user')").Default("user").Short('p').StringVar(&c.profile) 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.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth login' or 'fastly auth add' instead.\n\n") } if c.Globals.Verbose() { text.Break(out) } text.Output(out, "Creating profile '%s'", c.profile) if c.Globals.Config.GetAuthToken(c.profile) != nil { 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.", } } makeDefault := true if name, _ := c.Globals.Config.GetDefaultAuthToken(); name != "" && !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 { if err := authcmd.RunSSOWithTokenName(in, out, c.Globals, false, false, c.profile); err != nil { return fmt.Errorf("failed to authenticate: %w", err) } if makeDefault { c.Globals.Config.Auth.Default = c.profile } 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 } 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) spinner, err := text.NewSpinner(out) if err != nil { return err } defer func() { if err != nil { c.Globals.ErrLog.Add(err) } }() var md *authcmd.TokenMetadata err = spinner.Process("Validating token", func(_ *text.SpinnerWrapper) error { md, err = authcmd.FetchTokenMetadata(c.Globals, token) return err }) if err != nil { return err } return spinner.Process("Persisting configuration", func(_ *text.SpinnerWrapper) error { authcmd.BuildAndStoreStaticToken(c.Globals, token, c.profile, md, makeDefault) return nil }) } 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") func (c *CreateCommand) persistCfg() error { 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 } ================================================ FILE: pkg/commands/profile/delete.go ================================================ package profile import ( "fmt" "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "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 (deprecated: use 'fastly auth delete' instead)") 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 !c.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth delete' instead.\n\n") } if !c.Globals.Config.DeleteAuthToken(c.profile) { return fmt.Errorf("the specified profile does not exist") } 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 c.Globals.Config.Auth.Default == "" && len(c.Globals.Config.Auth.Tokens) > 0 { text.Break(out) text.Warning(out, "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.") } return nil } ================================================ FILE: pkg/commands/profile/doc.go ================================================ // Package profile contains commands to manage user profiles. package profile ================================================ FILE: pkg/commands/profile/list.go ================================================ package profile import ( "errors" "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" ) // 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 (deprecated: use 'fastly auth list' instead)") 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.Flags.Quiet && !c.JSONOutput.Enabled { text.Deprecated("This command will be removed in a future release. Use 'fastly auth list' instead.\n\n") } if c.Globals.Verbose() && c.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } if ok, err := c.WriteJSON(out, c.Globals.Config.Auth.Tokens); ok { return err } if len(c.Globals.Config.Auth.Tokens) == 0 { msg := "no profiles available" return fsterr.RemediationError{ Inner: errors.New(msg), Remediation: fsterr.ProfileRemediation(), } } defaultName := c.Globals.Config.Auth.Default if defaultName != "" { if at := c.Globals.Config.Auth.Tokens[defaultName]; at != nil { if c.Globals.Verbose() { text.Break(out) } text.Info(out, "Default profile highlighted in red.\n\n") display(defaultName, at, true, out, text.BoldRed) } } for name, at := range c.Globals.Config.Auth.Tokens { if name != defaultName { text.Break(out) display(name, at, false, out, text.Bold) } } return nil } func display(name string, at *config.AuthToken, isDefault bool, out io.Writer, style func(a ...any) string) { text.Output(out, style(name)) text.Break(out) text.Output(out, "%s: %t", style("Default"), isDefault) text.Output(out, "%s: %s", style("Email"), at.Email) text.Output(out, "%s: %s", style("Token"), at.Token) isSSO := at.Type == config.AuthTokenTypeSSO text.Output(out, "%s: %t", style("SSO"), isSSO) if isSSO { text.Output(out, "%s: %s", style("Account ID"), at.AccountID) text.Output(out, "%s: %s", style("Label"), at.Label) } } ================================================ FILE: pkg/commands/profile/profile_test.go ================================================ package profile_test import ( "context" "fmt" "path/filepath" "testing" "time" "github.com/fastly/go-fastly/v15/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{ GetCurrentUserFn: getCurrentUser, GetTokenSelfFn: getToken, }, 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, }, }, }, 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, }, }, }, 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, 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", }, { 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{ Auth: config.Auth{ Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, }, }, }, 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{ Auth: config.Auth{ Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, WantOutputs: []string{ `"bar"`, `"token": "456"`, `"foo"`, `"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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, WantOutput: "Profile switched to 'bar'", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "switch"}, scenarios) } func TestProfileToken(t *testing.T) { now := time.Now() expiredAt := now.Add(-600 * time.Second) soonExpireAt := now.Add(30 * time.Second) laterExpireAt := now.Add(1200 * time.Second) longTTLExpireAt := now.Add(60 * time.Second) scenarios := []testutil.CLIScenario{ { Name: "validate deprecation warning appears 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, }, }, }, WantOutputs: []string{"123"}, }, { Name: "validate --quiet suppresses deprecation warning", Args: "--quiet", 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, }, }, }, WantOutput: "123", }, { Name: "validate the active profile non-SSO 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, WantOutput: "123", }, { Name: "validate non-SSO token is displayed for the specified profile", 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, WantOutput: "456", }, { Name: "validate non-SSO token is displayed for the specified profile using global --profile", Args: "--profile 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, 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 SSO 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "123", Email: "foo@example.com", RefreshExpiresAt: expiredAt.Format(time.RFC3339), }, }, }, }, WantError: fmt.Sprintf("the token in profile 'foo' expired at '%s'", expiredAt.UTC().Format(fsttime.Format)), }, { Name: "validate that a soon-to-expire SSO 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "123", Email: "foo@example.com", RefreshExpiresAt: soonExpireAt.Format(time.RFC3339), }, }, }, }, WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", soonExpireAt.UTC().Format(fsttime.Format)), }, { Name: "validate that a soon-to-expire SSO 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "123", Email: "foo@example.com", RefreshExpiresAt: longTTLExpireAt.Format(time.RFC3339), }, }, }, }, WantOutput: "123", }, { Name: "validate that an SSO 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "123", Email: "foo@example.com", RefreshExpiresAt: laterExpireAt.Format(time.RFC3339), }, }, }, }, WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", laterExpireAt.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", API: &mock.API{ GetCurrentUserFn: getCurrentUser, GetTokenSelfFn: getToken, }, 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{ Auth: config.Auth{ Default: "foo", Tokens: config.AuthTokens{ "foo": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "123", Email: "foo@example.com", }, "bar": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "456", Email: "bar@example.com", }, }, }, }, Stdin: []string{ "", // 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 getCurrentUser(_ context.Context) (*fastly.User, error) { return &fastly.User{ Login: fastly.ToPointer("foo@example.com"), CustomerID: fastly.ToPointer("abc"), }, nil } func getToken(_ context.Context) (*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 } ================================================ FILE: pkg/commands/profile/root.go ================================================ 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 (deprecated: use 'fastly auth' instead)").Hidden() return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/profile/switch.go ================================================ package profile import ( "fmt" "io" "github.com/fastly/cli/pkg/argparser" authcmd "github.com/fastly/cli/pkg/commands/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/text" ) // SwitchCommand represents a Kingpin command. type SwitchCommand struct { argparser.Base profile string } // NewSwitchCommand returns a usable command registered under the parent. func NewSwitchCommand(parent argparser.Registerer, g *global.Data) *SwitchCommand { var c SwitchCommand c.Globals = g c.CmdClause = parent.Command("switch", "Switch user profile (deprecated: use 'fastly auth use' instead)") 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 { if !c.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth use' instead.\n\n") } at := c.Globals.Config.GetAuthToken(c.profile) if at == nil { err := fmt.Errorf("the profile '%s' does not exist", c.profile) c.Globals.ErrLog.Add(err) return fsterr.RemediationError{ Inner: err, Remediation: fsterr.ProfileRemediation(), } } if at.Type == config.AuthTokenTypeSSO { if err := authcmd.RunSSOWithTokenName(in, out, c.Globals, false, false, c.profile); err != nil { return fmt.Errorf("failed to authenticate: %w", err) } if err := c.Globals.Config.SetDefaultAuthToken(c.profile); err != nil { return err } if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config file: %w", err) } text.Success(out, "\nProfile switched to '%s'", c.profile) return nil } if err := c.Globals.Config.SetDefaultAuthToken(c.profile); err != nil { c.Globals.ErrLog.Add(err) return fsterr.RemediationError{ Inner: err, Remediation: fsterr.ProfileRemediation(), } } 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 } ================================================ FILE: pkg/commands/profile/testdata/config.toml ================================================ config_version = 2 [fastly] api_endpoint = "https://api.fastly.com" ================================================ FILE: pkg/commands/profile/token.go ================================================ 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/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 (deprecated: use 'fastly auth show' instead)") 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 } const defaultTokenTTL time.Duration = 5 * time.Minute // Exec implements the command interface. func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) (err error) { if !c.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth show' instead.\n\n") } var name string if c.profile != "" { name = c.profile } if c.Globals.Flags.Profile != "" { name = c.Globals.Flags.Profile } if name != "" { at := c.Globals.Config.GetAuthToken(name) if at != nil { if err = checkTokenValidity(name, at, c.tokenTTL); err != nil { return err } text.Output(out, at.Token) return nil } msg := fmt.Sprintf("the profile '%s' does not exist", name) return fsterr.RemediationError{ Inner: errors.New(msg), Remediation: fsterr.ProfileRemediation(), } } if name, at := c.Globals.Config.GetDefaultAuthToken(); at != nil { if err = checkTokenValidity(name, at, c.tokenTTL); err != nil { return err } text.Output(out, at.Token) return nil } return fsterr.RemediationError{ Inner: errors.New("no profiles available"), Remediation: fsterr.ProfileRemediation(), } } func checkTokenValidity(name string, at *config.AuthToken, ttl time.Duration) error { var expiryStr string if at.Type == config.AuthTokenTypeSSO { expiryStr = at.RefreshExpiresAt } else { expiryStr = at.APITokenExpiresAt } if expiryStr == "" { return nil } expiry, err := time.Parse(time.RFC3339, expiryStr) if err != nil { return err } if expiry.After(time.Now().Add(ttl)) { return nil } var msg string if expiry.Before(time.Now()) { msg = fmt.Sprintf("the token in profile '%s' expired at '%s'", name, expiry.UTC().Format(fsttime.Format)) } else { msg = fmt.Sprintf("the token in profile '%s' will expire at '%s'", name, expiry.UTC().Format(fsttime.Format)) } return fsterr.RemediationError{ Inner: errors.New(msg), Remediation: fsterr.TokenExpirationRemediation(), } } ================================================ FILE: pkg/commands/profile/update.go ================================================ package profile import ( "errors" "fmt" "io" "github.com/fastly/cli/pkg/argparser" authcmd "github.com/fastly/cli/pkg/commands/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/text" ) // UpdateCommand represents a Kingpin command. type UpdateCommand struct { argparser.Base profile string sso bool } // NewUpdateCommand returns a usable command registered under the parent. func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { var c UpdateCommand c.Globals = g c.CmdClause = parent.Command("update", "Update user profile (deprecated: use 'fastly auth login' or 'fastly auth add' instead)") c.CmdClause.Arg("profile", "Profile to update (defaults to the currently active profile)").Short('p').StringVar(&c.profile) 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 { if !c.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth login' or 'fastly auth add' instead.\n\n") } profileName, at, 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) if err := c.updateToken(profileName, at, in, out); err != nil { return fmt.Errorf("failed to update token: %w", err) } 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 { if err := c.Globals.Config.SetDefaultAuthToken(profileName); err != nil { return fmt.Errorf("failed to update token: %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) } } text.Success(out, "\nProfile '%s' updated", profileName) return nil } func (c *UpdateCommand) identifyProfile() (string, *config.AuthToken, error) { if c.profile == "" && c.Globals.Flags.Profile == "" { name, at := c.Globals.Config.GetDefaultAuthToken() if at == nil { return "", nil, fsterr.RemediationError{ Inner: fmt.Errorf("no active profile"), Remediation: "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.", } } return name, at, nil } profileName := c.profile if c.Globals.Flags.Profile != "" { profileName = c.Globals.Flags.Profile } at := c.Globals.Config.GetAuthToken(profileName) if at == nil { msg := fmt.Sprintf("the profile '%s' does not exist", profileName) return "", nil, fsterr.RemediationError{ Inner: errors.New(msg), Remediation: fsterr.ProfileRemediation(), } } return profileName, at, nil } func (c *UpdateCommand) updateToken(profileName string, at *config.AuthToken, in io.Reader, out io.Writer) error { if c.sso || at.Type == config.AuthTokenTypeSSO { if err := authcmd.RunSSOWithTokenName(in, out, c.Globals, false, false, profileName); err != nil { return fmt.Errorf("failed to authenticate: %w", err) } text.Break(out) return nil } token, err := text.InputSecure(out, text.BoldYellow("Profile token: (leave blank to skip): "), in) if err != nil { c.Globals.ErrLog.Add(err) return err } if token == "" { token = at.Token } text.Break(out) spinner, err := text.NewSpinner(out) if err != nil { return err } defer func() { if err != nil { c.Globals.ErrLog.Add(err) } }() var md *authcmd.TokenMetadata err = spinner.Process("Validating token", func(_ *text.SpinnerWrapper) error { md, err = authcmd.FetchTokenMetadata(c.Globals, token) return err }) if err != nil { return err } authcmd.BuildAndStoreStaticToken(c.Globals, token, profileName, md, false) 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 } ================================================ FILE: pkg/commands/secretstore/create.go ================================================ package secretstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstore/delete.go ================================================ package secretstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstore/describe.go ================================================ package secretstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstore/doc.go ================================================ // 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 ================================================ FILE: pkg/commands/secretstore/flags.go ================================================ 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, } } ================================================ FILE: pkg/commands/secretstore/helper_test.go ================================================ package secretstore_test import ( "bytes" "github.com/fastly/go-fastly/v15/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() } ================================================ FILE: pkg/commands/secretstore/list.go ================================================ package secretstore import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstore/root.go ================================================ 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") } ================================================ FILE: pkg/commands/secretstore/secretstore_test.go ================================================ package secretstore_test import ( "bytes" "context" "errors" "fmt" "io" "testing" "time" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { apiInvoked = true return f(ctx, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.DeleteSecretStoreInput) error { apiInvoked = true return f(ctx, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { apiInvoked = true return f(ctx, 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(_ context.Context, _ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return nil, nil }, }, wantAPIInvoked: true, }, { args: "list", api: mock.API{ ListSecretStoresFn: func(_ context.Context, _ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return nil, errors.New("unknown error") }, }, wantAPIInvoked: true, wantError: "unknown error", }, { args: "list", api: mock.API{ ListSecretStoresFn: func(_ context.Context, _ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return stores, nil }, }, wantAPIInvoked: true, wantOutput: fmtStores(stores.Data), }, { args: "list --json", api: mock.API{ ListSecretStoresFn: func(_ context.Context, _ *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(ctx context.Context, i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { apiInvoked = true return f(ctx, 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) } }) } } ================================================ FILE: pkg/commands/secretstoreentry/create.go ================================================ package secretstoreentry import ( "bytes" "context" "encoding/base64" "encoding/hex" "fmt" "io" "net/http" "os" "github.com/fastly/go-fastly/v15/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 ) // verificationKey is Fastly's Ed25519 public key, used to verify signatures // on client keys returned by the API. Fastly holds the corresponding private // key and signs client keys on their side. // // This key is meant to be long-lived and infrequently (if ever) rotated. // Hardcoding it in the CLI provides a trust anchor that prevents MITM attacks // where an attacker could substitute a different key. // // When Fastly rotates it, we will need to update this value and release a // new version of the CLI. Users can also override this check with // the FASTLY_USE_API_SIGNING_KEY environment variable. var verificationKey = 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(context.TODO()) if err != nil { c.Globals.ErrLog.Add(err) return err } apiPublicKey, err := c.Globals.APIClient.GetSigningKey(context.TODO()) if err != nil { c.Globals.ErrLog.Add(err) return err } if !bytes.Equal(apiPublicKey, verificationKey) && os.Getenv("FASTLY_USE_API_SIGNING_KEY") == "" { err := fmt.Errorf("API public key does not match expected verification key") c.Globals.ErrLog.Add(err) return err } if !ck.VerifySignature(apiPublicKey) { err := fmt.Errorf("unable to verify 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstoreentry/delete.go ================================================ package secretstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstoreentry/describe.go ================================================ package secretstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/secretstoreentry/doc.go ================================================ // 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 ================================================ FILE: pkg/commands/secretstoreentry/flags.go ================================================ 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, } } ================================================ FILE: pkg/commands/secretstoreentry/helper_test.go ================================================ package secretstoreentry_test import ( "bytes" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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() } ================================================ FILE: pkg/commands/secretstoreentry/list.go ================================================ package secretstoreentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } } ================================================ FILE: pkg/commands/secretstoreentry/root.go ================================================ 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") } ================================================ FILE: pkg/commands/secretstoreentry/secretstoreentry_test.go ================================================ package secretstoreentry_test import ( "bytes" "context" "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/v15/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(_ context.Context) (*fastly.ClientKey, error) { return ck, nil } mockGetSigningKey := func(_ context.Context) (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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.CreateSecretInput) (*fastly.Secret, error) { apiInvoked = true return f(ctx, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.DeleteSecretInput) error { apiInvoked = true return f(ctx, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(ctx context.Context, i *fastly.GetSecretInput) (*fastly.Secret, error) { apiInvoked = true return f(ctx, 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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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(ctx context.Context, i *fastly.ListSecretsInput) (*fastly.Secrets, error) { apiInvoked = true return f(ctx, 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) } }) } } ================================================ FILE: pkg/commands/service/acl/acl_test.go ================================================ package acl_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate CreateACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateACLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateACLFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateACLFn: func(_ context.Context, 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, sub.CommandName, "create"}, scenarios) } func TestACLDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --name flag", Args: "--version 3", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteACLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteACLFn: func(_ context.Context, _ *fastly.DeleteACLInput) error { return nil }, }, Args: "--name foobar --service-id 123 --version 3", WantOutput: "Deleted ACL 'foobar' (service: 123, version: 3)", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteACLFn: func(_ context.Context, i *fastly.DeleteACLInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteACLFn: func(_ context.Context, i *fastly.DeleteACLInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteACLFn: func(_ context.Context, _ *fastly.DeleteACLInput) error { return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 1", WantOutput: "Deleted ACL 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteACLFn: func(_ context.Context, i *fastly.DeleteACLInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 2", WantOutput: "Deleted ACL 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteACLFn: func(_ context.Context, i *fastly.DeleteACLInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 3", WantOutput: "Deleted ACL 'foo' (service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate GetACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetACLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate ListACLs API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListACLsFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, ListACLsFn: listACLs, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: 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, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate UpdateACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateACLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateACLFn: func(_ context.Context, 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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateACLFn: func(_ context.Context, i *fastly.UpdateACLInput) (*fastly.ACL, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateACLFn: func(_ context.Context, i *fastly.UpdateACLInput) (*fastly.ACL, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateACLFn: func(_ context.Context, 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)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateACLFn: func(_ context.Context, i *fastly.UpdateACLInput) (*fastly.ACL, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 2", WantOutput: "Updated ACL 'beepboop' (previously: 'foobar', service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateACLFn: func(_ context.Context, i *fastly.UpdateACLInput) (*fastly.ACL, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 3", WantOutput: "Updated ACL 'beepboop' (previously: 'foobar', service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } func getACL(_ context.Context, 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(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/acl/create.go ================================================ package acl import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/acl/delete.go ================================================ package acl import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/acl/describe.go ================================================ package acl import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/acl/doc.go ================================================ // Package acl contains commands to inspect and manipulate Fastly ACLs. package acl ================================================ FILE: pkg/commands/service/acl/list.go ================================================ package acl import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/acl/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/acl/update.go ================================================ package acl import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/aclentry/aclentry_test.go ================================================ package aclentry_test import ( "context" "io" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --service-id flag", Args: "--acl-id 123 --ip 127.0.0.1", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate CreateACLEntry API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateACLEntryFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateACLEntryFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateACLEntryFn: func(_ context.Context, 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, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteACLEntryFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteACLEntryFn: func(_ context.Context, _ *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, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate GetACL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetACLEntryFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate ListACLEntries API error (via GetNext() call)", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetACLEntriesFn: func(ctx context.Context, _ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { return fastly.NewPaginator[fastly.ACLEntry](ctx, &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{ GetVersionFn: testutil.GetVersion, GetACLEntriesFn: func(ctx context.Context, _ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { return fastly.NewPaginator[fastly.ACLEntry](ctx, &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{ GetVersionFn: testutil.GetVersion, GetACLEntriesFn: func(ctx context.Context, _ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { return fastly.NewPaginator[fastly.ACLEntry](ctx, &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, sub.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 (auth: 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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{ GetVersionFn: testutil.GetVersion, UpdateACLEntryFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, BatchModifyACLEntriesFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, BatchModifyACLEntriesFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, BatchModifyACLEntriesFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, BatchModifyACLEntriesFn: func(_ context.Context, _ *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, sub.CommandName, "update"}, scenarios) } func getACLEntry(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/aclentry/create.go ================================================ package aclentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/aclentry/delete.go ================================================ package aclentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/aclentry/describe.go ================================================ package aclentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/aclentry/doc.go ================================================ // Package aclentry contains commands to inspect and manipulate Fastly ACL // entries. package aclentry ================================================ FILE: pkg/commands/service/aclentry/list.go ================================================ package aclentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/aclentry/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/aclentry/testdata/batch.json ================================================ { "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" } ] } ================================================ FILE: pkg/commands/service/aclentry/update.go ================================================ package aclentry import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/alert/alert_test.go ================================================ package alerts_test import ( "context" "strings" "testing" "time" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/alert" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/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, sub.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, sub.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{ GetVersionFn: testutil.GetVersion, DeleteAlertDefinitionFn: func(_ context.Context, _ *fastly.DeleteAlertDefinitionInput) error { return nil }, }, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, GetAlertDefinitionFn: func(_ context.Context, _ *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) { response := &mockDefinition return response, nil }, }, WantOutput: listAlertsOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, ListAlertDefinitionsFn: func(_ context.Context, _ *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, sub.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{ GetVersionFn: testutil.GetVersion, ListAlertHistoryFn: func(_ context.Context, _ *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, sub.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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) { response := &mockDefinition return response, nil } var UpdateAlertDefinitionResponse = func(_ context.Context, _ *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 ` ================================================ FILE: pkg/commands/service/alert/common.go ================================================ package alerts import ( "fmt" "io" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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) } } ================================================ FILE: pkg/commands/service/alert/create.go ================================================ package alerts import ( "context" "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/v15/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 Alert") // 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/alert/delete.go ================================================ package alerts import ( "context" "io" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/alert/describe.go ================================================ package alerts import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/alert/doc.go ================================================ // Package alerts contains commands to inspect and manipulate Fastly Service Alerts. package alerts ================================================ FILE: pkg/commands/service/alert/list.go ================================================ package alerts import ( "context" "io" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 var err error if c.order.WasSet { sign, err = argparser.ConvertOrderFromStringFlag(c.order.Value, "order") if err != nil { c.Globals.ErrLog.Add(err) return nil, err } } if c.sort.WasSet { str := sign + c.sort.Value input.Sort = &str } return &input, nil } ================================================ FILE: pkg/commands/service/alert/list_history.go ================================================ package alerts import ( "context" "io" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 var err error if c.order.WasSet { sign, err = argparser.ConvertOrderFromStringFlag(c.order.Value, "order") if err != nil { 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 } ================================================ FILE: pkg/commands/service/alert/root.go ================================================ 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 = "alert" // 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 Alerts") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/service/alert/update.go ================================================ package alerts import ( "context" "io" "github.com/fastly/go-fastly/v15/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 Alert") // 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/auth/create.go ================================================ package auth import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/auth/delete.go ================================================ package auth import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/auth/describe.go ================================================ package auth import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 for a service. 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/auth/doc.go ================================================ // Package auth contains commands to inspect and manipulate authorization // to Fastly services. package auth ================================================ FILE: pkg/commands/service/auth/list.go ================================================ package auth import ( "context" "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/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/auth/root.go ================================================ package auth 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" // 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") } ================================================ FILE: pkg/commands/service/auth/service_test.go ================================================ package auth_test import ( "context" "errors" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/auth" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestServiceAuthCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "missing required flag", Args: "", WantError: "error parsing arguments: required flag --user-id not provided", }, { Name: "api failure", Args: "--user-id 123 --service-id 123", API: &mock.API{CreateServiceAuthorizationFn: createServiceAuthError}, WantError: errTest.Error(), }, { Name: "success", Args: "--user-id 123 --service-id 123", API: &mock.API{CreateServiceAuthorizationFn: createServiceAuthOK}, WantOutput: "Created service authorization 12345", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestServiceAuthList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "invalid flag combination", Args: "--verbose --json", WantError: "invalid flag combination, --verbose and --json", }, { Name: "api failure", Args: "", API: &mock.API{ListServiceAuthorizationsFn: listServiceAuthError}, WantError: errTest.Error(), }, { Name: "success", Args: "", API: &mock.API{ListServiceAuthorizationsFn: listServiceAuthOK}, WantOutput: "AUTH ID USER ID SERVICE ID PERMISSION\n123 456 789 read_only\n", }, { Name: "success with json", Args: "--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" } } ] }`, }, { Name: "success with verbose", Args: "--verbose", API: &mock.API{ListServiceAuthorizationsFn: listServiceAuthOK}, WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: user)\n\nAuth ID: 123\nUser ID: 456\nService ID: 789\nPermission: read_only\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestServiceAuthDescribe(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "missing required flag", Args: "", WantError: "error parsing arguments: required flag --id not provided", }, { Name: "invalid flag combination", Args: "--id 123 --verbose --json", WantError: "invalid flag combination, --verbose and --json", }, { Name: "api failure", Args: "--id 123", API: &mock.API{GetServiceAuthorizationFn: describeServiceAuthError}, WantError: errTest.Error(), }, { Name: "success", Args: "--id 123", API: &mock.API{GetServiceAuthorizationFn: describeServiceAuthOK}, WantOutput: "Auth ID: 12345\nUser ID: 456\nService ID: 789\nPermission: read_only\n", }, { Name: "success with json", Args: "--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" } }`, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestServiceAuthUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "missing id flag", Args: "--permission full", WantError: "error parsing arguments: required flag --id not provided", }, { Name: "missing permission flag", Args: "--id 123", WantError: "error parsing arguments: required flag --permission not provided", }, { Name: "api failure", Args: "--id 123 --permission full", API: &mock.API{UpdateServiceAuthorizationFn: updateServiceAuthError}, WantError: errTest.Error(), }, { Name: "success", Args: "--id 123 --permission full", API: &mock.API{UpdateServiceAuthorizationFn: updateServiceAuthOK}, WantOutput: "Updated service authorization 123", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } func TestServiceAuthDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "missing required flag", Args: "", WantError: "error parsing arguments: required flag --id not provided", }, { Name: "api failure", Args: "--id 123", API: &mock.API{DeleteServiceAuthorizationFn: deleteServiceAuthError}, WantError: errTest.Error(), }, { Name: "success", Args: "--id 123", API: &mock.API{DeleteServiceAuthorizationFn: deleteServiceAuthOK}, WantOutput: "Deleted service authorization 123", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createServiceAuthError(_ context.Context, _ *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return nil, errTest } func createServiceAuthOK(_ context.Context, _ *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return &fastly.ServiceAuthorization{ ID: "12345", }, nil } func listServiceAuthError(_ context.Context, _ *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) { return nil, errTest } func listServiceAuthOK(_ context.Context, _ *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(_ context.Context, _ *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return nil, errTest } func describeServiceAuthOK(_ context.Context, _ *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(_ context.Context, _ *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return nil, errTest } func updateServiceAuthOK(_ context.Context, _ *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return &fastly.ServiceAuthorization{ ID: "12345", }, nil } func deleteServiceAuthError(_ context.Context, _ *fastly.DeleteServiceAuthorizationInput) error { return errTest } func deleteServiceAuthOK(_ context.Context, _ *fastly.DeleteServiceAuthorizationInput) error { return nil } ================================================ FILE: pkg/commands/service/auth/testdata/fastly-no-serviceid.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/service/auth/testdata/fastly-valid.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" service_id = "123" ================================================ FILE: pkg/commands/service/auth/update.go ================================================ package auth import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/backend/backend_test.go ================================================ package backend_test import ( "context" "errors" "fmt" "io" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, // The following test 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, GetServicesFn: func(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CreateBackendFn: createBackendOK, }, WantError: "'tcp-ka-enabled' flag must be one of the following [true, false]", }, // The following tests verify parsing of the --prefer-ipv6 flag. { Args: "--service-id 123 --version 3 --address 127.0.0.1 --name www.test.com --prefer-ipv6=true", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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 --prefer-ipv6=false", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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 --prefer-ipv6=invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateBackendFn: createBackendOK, }, WantError: "'prefer-ipv6' flag must be one of the following [true, false]", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestBackendList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --json", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsJSONOutput, }, { Args: "--service-id 123 --version 1 --json --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantError: fsterr.ErrInvalidVerboseJSONCombo.Error(), }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsVerboseOutput, }, { Args: "--verbose --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsVerboseOutput, }, { Args: "-v --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsOK, }, WantOutput: listBackendsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBackendsFn: listBackendsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, GetBackendFn: getBackendError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBackendFn: getBackendOK, }, WantOutput: describeBackendOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: updateBackendOK, }, WantOutput: "Updated backend www.example.com (service 123 version 4)", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBackendFn: getBackendOK, UpdateBackendFn: func(_ context.Context, i *fastly.UpdateBackendInput) (*fastly.Backend, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name www.test.com --new-name www.example.com --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBackendFn: getBackendOK, UpdateBackendFn: func(_ context.Context, i *fastly.UpdateBackendInput) (*fastly.Backend, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name www.test.com --new-name www.example.com --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, // The following tests verify parsing of the --tcp-ka-enable flag. { Args: "--service-id 123 --version 3 --name www.test.com --tcp-ka-enabled=true --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: updateBackendOK, }, WantError: "'tcp-ka-enabled' flag must be one of the following [true, false]", }, // The following tests verify parsing of the --prefer-ipv6 flag. { Args: "--service-id 123 --version 1 --name www.test.com --prefer-ipv6=true --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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 --prefer-ipv6=false --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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 --prefer-ipv6=invalid --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: updateBackendOK, }, WantError: "'prefer-ipv6' flag must be one of the following [true, false]", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: updateBackendOK, }, Args: "--autoclone --name www.test.com --new-name www.example.com --service-id 123 --version 1", WantOutput: "Updated backend www.example.com (service 123 version 4)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: func(_ context.Context, i *fastly.UpdateBackendInput) (*fastly.Backend, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return &fastly.Backend{ ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion), Name: i.NewName, Comment: i.Comment, }, nil }, }, Args: "--autoclone --name www.test.com --new-name www.example.com --service-id 123 --version 2", WantOutput: "Updated backend www.example.com (service 123 version 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), GetBackendFn: getBackendOK, UpdateBackendFn: func(_ context.Context, i *fastly.UpdateBackendInput) (*fastly.Backend, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return &fastly.Backend{ ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion), Name: i.NewName, Comment: i.Comment, }, nil }, }, Args: "--autoclone --name www.test.com --new-name www.example.com --service-id 123 --version 3", WantOutput: "Updated backend www.example.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBackendFn: deleteBackendError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBackendFn: deleteBackendOK, }, WantOutput: "Deleted backend www.test.com (service 123 version 4)", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteBackendFn: func(_ context.Context, i *fastly.DeleteBackendInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name www.test.com --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteBackendFn: func(_ context.Context, i *fastly.DeleteBackendInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name www.test.com --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBackendFn: deleteBackendOK, }, Args: "--autoclone --name www.test.com --service-id 123 --version 1", WantOutput: "Deleted backend www.test.com (service 123 version 4)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBackendFn: func(_ context.Context, i *fastly.DeleteBackendInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name www.test.com --service-id 123 --version 2", WantOutput: "Deleted backend www.test.com (service 123 version 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBackendFn: func(_ context.Context, i *fastly.DeleteBackendInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name www.test.com --service-id 123 --version 3", WantOutput: "Deleted backend www.test.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createBackendOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateBackendInput) (*fastly.Backend, error) { return nil, errTest } func createBackendWithPort(wantPort int) func(_ context.Context, _ *fastly.CreateBackendInput) (*fastly.Backend, error) { return func(ctx context.Context, 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(ctx, i) default: return createBackendError(ctx, i) } } } func listBackendsOK(_ context.Context, 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(_ context.Context, _ *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, "MaxUse": null, "MaxLifetime": 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, "MaxUse": null, "MaxLifetime": 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 (auth: 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", " Max connection use: 0", " Max connection lifetime: 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", " Max connection use: 0", " Max connection lifetime: 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(_ context.Context, 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(_ context.Context, _ *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", "Prefer IPv6: false", "Override host: ", "Connect timeout: 0", "Max connections: 0", "Max connection use: 0", "Max connection lifetime: 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateBackendInput) (*fastly.Backend, error) { return nil, errTest } func deleteBackendOK(_ context.Context, _ *fastly.DeleteBackendInput) error { return nil } func deleteBackendError(_ context.Context, _ *fastly.DeleteBackendInput) error { return errTest } ================================================ FILE: pkg/commands/service/backend/create.go ================================================ package backend import ( "context" "io" "net" "github.com/fastly/go-fastly/v15/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 maxUse argparser.OptionalInt maxLifetime argparser.OptionalInt maxTLSVersion argparser.OptionalString minTLSVersion argparser.OptionalString name argparser.OptionalString noSSLCheckCert argparser.OptionalBool overrideHost argparser.OptionalString port argparser.OptionalInt preferIPv6 argparser.OptionalString 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-use", "Maximum number of requests allowed over a single, pooled HTTP keepalive connection to this backend; 0 is treated as unlimited.").Action(c.maxUse.Set).IntVar(&c.maxUse.Value) c.CmdClause.Flag("max-lifetime", "Maximum time from creation (in milliseconds) that a pooled HTTP keepalive connection will be eligible for reuse; 0 is treated as unlimited.").Action(c.maxLifetime.Set).IntVar(&c.maxLifetime.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("prefer-ipv6", "Prefer IPv6 connections [true, false]").Action(c.preferIPv6.Set).StringVar(&c.preferIPv6.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.maxUse.WasSet { input.MaxUse = &c.maxUse.Value } if c.maxLifetime.WasSet { input.MaxLifetime = &c.maxLifetime.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.preferIPv6.WasSet { preferIPv6, err := argparser.ConvertBoolFromStringFlag(c.preferIPv6.Value, "prefer-ipv6") if err != nil { c.Globals.ErrLog.Add(err) return err } input.PreferIPv6 = fastly.ToPointer(fastly.Compatibool(*preferIPv6)) } 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("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 { tcpKaEnable, err := argparser.ConvertBoolFromStringFlag(c.tcpKaEnable.Value, "tcp-ka-enabled") if err != nil { 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/backend/delete.go ================================================ package backend import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/backend/describe.go ================================================ package backend import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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, "Prefer IPv6: %v\n", fastly.ToValue(b.PreferIPv6)) 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, "Max connection use: %v\n", fastly.ToValue(b.MaxUse)) fmt.Fprintf(out, "Max connection lifetime: %v\n", fastly.ToValue(b.MaxLifetime)) 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 } ================================================ FILE: pkg/commands/service/backend/doc.go ================================================ // Package backend contains commands to inspect and manipulate Fastly service backends. package backend ================================================ FILE: pkg/commands/service/backend/list.go ================================================ package backend import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/backend/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/backend/update.go ================================================ package backend import ( "context" "io" "github.com/fastly/go-fastly/v15/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 MaxUse argparser.OptionalInt MaxLifetime argparser.OptionalInt MaxTLSVersion argparser.OptionalString MinTLSVersion argparser.OptionalString NewName argparser.OptionalString NoSSLCheckCert argparser.OptionalBool OverrideHost argparser.OptionalString Port argparser.OptionalInt preferIPv6 argparser.OptionalString 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-use", "Maximum number of requests allowed over a single, pooled HTTP keepalive connection to this backend; 0 is treated as unlimited.").Action(c.MaxUse.Set).IntVar(&c.MaxUse.Value) c.CmdClause.Flag("max-lifetime", "Maximum time from creation (in milliseconds) that a pooled HTTP keepalive connection will be eligible for reuse; 0 is treated as unlimited.").Action(c.MaxLifetime.Set).IntVar(&c.MaxLifetime.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("prefer-ipv6", "Prefer IPv6 connections").Action(c.preferIPv6.Set).StringVar(&c.preferIPv6.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.preferIPv6.WasSet { preferIPv6, err := argparser.ConvertBoolFromStringFlag(c.preferIPv6.Value, "prefer-ipv6") if err != nil { c.Globals.ErrLog.Add(err) return err } input.PreferIPv6 = fastly.ToPointer(fastly.Compatibool(*preferIPv6)) } if c.ConnectTimeout.WasSet { input.ConnectTimeout = &c.ConnectTimeout.Value } if c.MaxConn.WasSet { input.MaxConn = &c.MaxConn.Value } if c.MaxUse.WasSet { input.MaxUse = &c.MaxUse.Value } if c.MaxLifetime.WasSet { input.MaxLifetime = &c.MaxLifetime.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("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 { tcpKaEnable, err := argparser.ConvertBoolFromStringFlag(c.TCPKaEnable.Value, "tcp-ka-enabled") if err != nil { 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/create.go ================================================ package service import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/delete.go ================================================ package service import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &fastly.GetServiceDetailsInput{ 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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/describe.go ================================================ package service import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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.GetServiceDetailsInput 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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionary/create.go ================================================ package dictionary import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionary/delete.go ================================================ package dictionary import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionary/describe.go ================================================ package dictionary import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionary/dictionary_test.go ================================================ package dictionary_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/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{ GetVersionFn: testutil.GetVersion, GetDictionaryFn: describeDictionaryOK, }, WantOutput: describeDictionaryOutput, }, { Args: "--version 1 --service-id 123 --name dict-1", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDictionaryFn: describeDictionaryOKDeleted, }, WantOutput: describeDictionaryOutputDeleted, }, { Args: "--version 1 --service-id 123 --name dict-1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDictionaryFn: describeDictionaryOK, GetDictionaryInfoFn: getDictionaryInfoOK, ListDictionaryItemsFn: listDictionaryItemsOK, }, WantOutput: describeDictionaryOutputVerbose, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestDictionaryCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--version 1", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--version 1 --service-id 123 --name denylist --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDictionaryFn: createDictionaryOK, }, WantOutput: createDictionaryOutput, }, { Args: "--version 1 --service-id 123 --name denylist --write-only --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDictionaryFn: createDictionaryOK, }, WantOutput: createDictionaryOutputWriteOnly, }, { Args: "--version 1 --service-id 123 --name denylist --write-only fish --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: unexpected 'fish'", }, { Args: "--version 1 --service-id 123 --name denylist --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDictionaryFn: createDictionaryDuplicate, }, WantError: "Duplicate record", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDictionaryFn: deleteDictionaryOK, }, WantOutput: deleteDictionaryOutput, }, { Args: "--service-id 123 --version 1 --name allowlist --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDictionaryFn: deleteDictionaryError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestListDictionary(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDictionariesFn: listDictionariesOk, }, EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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{ GetVersionFn: testutil.GetVersion, ListDictionariesFn: listDictionariesOk, }, WantOutput: listDictionariesOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestUpdateDictionary(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--version 1 --name oldname --new-name newname", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDictionaryFn: updateDictionaryNameOK, }, WantOutput: updateDictionaryNameOutput, }, { Args: "--service-id 123 --version 1 --name oldname --write-only true --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDictionaryFn: updateDictionaryNameOK, }, WantOutput: updateDictionaryOutputVerbose, }, { Args: "--service-id 123 --version 1 --name oldname --new-name dict-1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDictionaryFn: updateDictionaryError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } func describeDictionaryOK(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { return nil, errors.New("Duplicate record") } func deleteDictionaryOK(_ context.Context, _ *fastly.DeleteDictionaryInput) error { return nil } func deleteDictionaryError(_ context.Context, _ *fastly.DeleteDictionaryInput) error { return errTest } func listDictionariesOk(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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 (auth: 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 (auth: 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" ================================================ FILE: pkg/commands/service/dictionary/doc.go ================================================ // Package dictionary contains commands to inspect and manipulate Fastly edge // dictionaries. package dictionary ================================================ FILE: pkg/commands/service/dictionary/list.go ================================================ package dictionary import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionary/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/dictionary/update.go ================================================ package dictionary import ( "context" "fmt" "io" "strconv" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionaryentry/create.go ================================================ package dictionaryentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionaryentry/delete.go ================================================ package dictionaryentry import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionaryentry/describe.go ================================================ package dictionaryentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionaryentry/dictionaryitem_test.go ================================================ package dictionaryentry_test import ( "context" "errors" "io" "net/http" "os" "strings" "testing" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/dictionaryentry" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly" ) func TestDictionaryItemDescribe(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --key foo", API: &mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, WantError: "error parsing arguments: required flag --dictionary-id not provided", }, { Args: "--service-id 123 --dictionary-id 456", API: &mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, WantError: "error parsing arguments: required flag --key not provided", }, { Args: "--service-id 123 --dictionary-id 456 --key foo", API: &mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, WantOutput: describeDictionaryItemOutput, }, { Args: "--service-id 123 --dictionary-id 456 --key foo-deleted", API: &mock.API{GetDictionaryItemFn: describeDictionaryItemOKDeleted}, WantOutput: describeDictionaryItemOutputDeleted, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestDictionaryItemsList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", WantError: "error parsing arguments: required flag --dictionary-id not provided", }, { Args: "--dictionary-id 456", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDictionaryItemsFn: func(ctx context.Context, _ *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { return fastly.NewPaginator[fastly.DictionaryItem](ctx, &mock.HTTPClient{ Errors: []error{ testutil.Err, }, Responses: []*http.Response{nil}, }, fastly.ListOpts{}, "/example") }, }, Args: "--service-id 123 --dictionary-id 456", WantError: testutil.Err.Error(), }, { API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDictionaryItemsFn: func(ctx context.Context, _ *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { return fastly.NewPaginator[fastly.DictionaryItem](ctx, &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: "--service-id 123 --dictionary-id 456 --per-page 1", WantOutput: listDictionaryItemsOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestDictionaryItemCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", API: &mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, WantError: "error parsing arguments: required flag ", }, { Args: "--service-id 123 --dictionary-id 456", API: &mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, WantError: "error parsing arguments: required flag ", }, { Args: "--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", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestDictionaryItemUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", API: &mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, WantError: "error parsing arguments: required flag --dictionary-id not provided", }, { Args: "--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: "--service-id 123 --dictionary-id 456 --key foo --value bar", API: &mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, WantOutput: updateDictionaryItemOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) // File-based test: invalid json t.Run("invalid json file", func(t *testing.T) { filePath := testutil.MakeTempFile(t, `{invalid": "json"}`) defer os.RemoveAll(filePath) scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --dictionary-id 456 --file " + filePath, WantError: "invalid character 'i' looking for beginning of object key string", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) }) // 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'. t.Run("missing file", func(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --dictionary-id 456 --file missingPath", WantError: "open missingPath:", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) }) // File-based test: batch modify error t.Run("batch modify error", func(t *testing.T) { filePath := testutil.MakeTempFile(t, dictionaryItemBatchModifyInputOK) defer os.RemoveAll(filePath) scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --dictionary-id 456 --file " + filePath, API: &mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsError}, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) }) // File-based test: batch modify success t.Run("batch modify success", func(t *testing.T) { filePath := testutil.MakeTempFile(t, dictionaryItemBatchModifyInputOK) defer os.RemoveAll(filePath) scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --dictionary-id 456 --file " + filePath, API: &mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsOK}, WantOutput: "SUCCESS: Made 4 modifications of Dictionary 456 on service 123\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) }) } func TestDictionaryItemDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", API: &mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, WantError: "error parsing arguments: required flag ", }, { Args: "--service-id 123 --dictionary-id 456", API: &mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, WantError: "error parsing arguments: required flag ", }, { Args: "--service-id 123 --dictionary-id 456 --key foo", API: &mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, WantOutput: "SUCCESS: Deleted dictionary item foo (service 123, dictionary 456)\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func describeDictionaryItemOK(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, _ *fastly.BatchModifyDictionaryItemsInput) error { return nil } func batchModifyDictionaryItemsError(_ context.Context, _ *fastly.BatchModifyDictionaryItemsInput) error { return errTest } var errTest = errors.New("an expected error occurred") ================================================ FILE: pkg/commands/service/dictionaryentry/doc.go ================================================ // Package dictionaryentry contains commands to inspect and manipulate Fastly edge // dictionary items. package dictionaryentry ================================================ FILE: pkg/commands/service/dictionaryentry/list.go ================================================ package dictionaryentry import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/dictionaryentry/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/dictionaryentry/update.go ================================================ package dictionaryentry import ( "context" "encoding/json" "fmt" "io" "os" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/doc.go ================================================ // Package service contains commands to inspect and manipulate Fastly services. package service ================================================ FILE: pkg/commands/service/domain/create.go ================================================ package domain import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/domain/delete.go ================================================ package domain import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/domain/describe.go ================================================ package domain import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/domain/doc.go ================================================ // Package domain contains commands to inspect and manipulate Fastly service domains. package domain ================================================ FILE: pkg/commands/service/domain/domain_test.go ================================================ package domain_test import ( "context" "errors" "fmt" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/domain" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestDomainCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--version 1", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--service-id 123 --version 1 --name www.test.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDomainFn: createDomainError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestDomainList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOK, }, WantOutput: listDomainsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOK, }, WantOutput: listDomainsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOK, }, WantOutput: listDomainsVerboseOutput, }, { Args: "--verbose --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOK, }, WantOutput: listDomainsVerboseOutput, }, { Args: "-v --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsOK, }, WantOutput: listDomainsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDomainsFn: listDomainsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, GetDomainFn: getDomainError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDomainFn: getDomainOK, }, WantOutput: describeDomainOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDomainFn: updateDomainOK, }, WantOutput: "Updated domain www.example.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDomainFn: deleteDomainError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDomainFn: deleteDomainOK, }, WantOutput: "Deleted domain www.test.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate missing --name flag", API: &mock.API{ GetVersionFn: testutil.GetVersion, }, Args: "--service-id 123 --version 3", WantError: "error parsing arguments: must provide --name flag", }, { Name: "validate ValidateDomain API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateDomainFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, ValidateAllDomainsFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, ValidateDomainFn: validateDomain, }, Args: "--name foo.example.com --service-id 123 --version 3", WantOutput: validateAPISuccess(3), }, { Name: "validate ValidateAllDomains API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateAllDomainsFn: validateAllDomains, }, Args: "--all --service-id 123 --version 3", WantOutput: validateAllAPISuccess(), }, { Name: "validate missing --autoclone flag is OK", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateDomainFn: validateDomain, }, Args: "--name foo.example.com --service-id 123 --version 1", WantOutput: validateAPISuccess(1), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "validate"}, scenarios) } var errTest = errors.New("fixture error") func createDomainOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateDomainInput) (*fastly.Domain, error) { return nil, errTest } func listDomainsOK(_ context.Context, 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(_ context.Context, _ *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 (auth: 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateDomainInput) (*fastly.Domain, error) { return nil, errTest } func deleteDomainOK(_ context.Context, _ *fastly.DeleteDomainInput) error { return nil } func deleteDomainError(_ context.Context, _ *fastly.DeleteDomainInput) error { return errTest } func validateDomain(_ context.Context, 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(_ context.Context, i *fastly.ValidateAllDomainsInput) ([]*fastly.DomainValidationResult, 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` } ================================================ FILE: pkg/commands/service/domain/list.go ================================================ package domain import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/domain/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/domain/update.go ================================================ package domain import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/domain/validate.go ================================================ package domain import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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(context.TODO(), 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") } } ================================================ FILE: pkg/commands/service/healthcheck/create.go ================================================ package healthcheck import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/healthcheck/delete.go ================================================ package healthcheck import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/healthcheck/describe.go ================================================ package healthcheck import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/healthcheck/doc.go ================================================ // Package healthcheck contains commands to inspect and manipulate Fastly service healthchecks. package healthcheck ================================================ FILE: pkg/commands/service/healthcheck/healthcheck_test.go ================================================ package healthcheck_test import ( "context" "errors" "net/http" "strings" "testing" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/healthcheck" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestHealthCheckCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--version 1", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--service-id 123 --version 1 --name www.test.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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: "--service-id 123 --version 1 --name www.test.com --autoclone --timeout 10", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHealthCheckFn: createHealthCheckOK, }, WantOutput: "Created healthcheck www.test.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestHealthCheckList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksOK, }, WantOutput: listHealthChecksShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksOK, }, WantOutput: listHealthChecksVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksOK, }, WantOutput: listHealthChecksVerboseOutput, }, { Args: "--verbose --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksOK, }, WantOutput: listHealthChecksVerboseOutput, }, { Args: "-v --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksOK, }, WantOutput: listHealthChecksVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHealthChecksFn: listHealthChecksError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestHealthCheckDescribe(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{ GetVersionFn: testutil.GetVersion, GetHealthCheckFn: getHealthCheckError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHealthCheckFn: getHealthCheckOK, }, WantOutput: describeHealthCheckOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestHealthCheckUpdate(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 --new-name www.example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHealthCheckFn: updateHealthCheckOK, }, }, { Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHealthCheckFn: updateHealthCheckError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHealthCheckFn: updateHealthCheckOK, }, WantOutput: "Updated healthcheck www.example.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } func TestHealthCheckDelete(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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHealthCheckFn: deleteHealthCheckError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name www.test.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHealthCheckFn: deleteHealthCheckOK, }, WantOutput: "Deleted healthcheck www.test.com (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createHealthCheckOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { return nil, errTest } func listHealthChecksOK(_ context.Context, 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(_ context.Context, _ *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 (auth: 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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { return nil, errTest } func deleteHealthCheckOK(_ context.Context, _ *fastly.DeleteHealthCheckInput) error { return nil } func deleteHealthCheckError(_ context.Context, _ *fastly.DeleteHealthCheckInput) error { return errTest } ================================================ FILE: pkg/commands/service/healthcheck/list.go ================================================ package healthcheck import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/healthcheck/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/healthcheck/update.go ================================================ package healthcheck import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/imageoptimizerdefaults/doc.go ================================================ // Package imageoptimizerdefaults contains commands to configure default settings for Fastly Image Optimizer requests. package imageoptimizerdefaults ================================================ FILE: pkg/commands/service/imageoptimizerdefaults/get.go ================================================ package imageoptimizerdefaults import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" ) // GetCommand calls the Fastly API to describe the Image Optimizer default settings for a service. type GetCommand struct { argparser.Base argparser.JSONOutput Input fastly.GetImageOptimizerDefaultSettingsInput serviceName argparser.OptionalServiceNameID serviceVersion argparser.OptionalServiceVersion } // 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", "Retrieve the current Image Optimizer default settings") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) 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{ 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 *GetCommand) 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.GetImageOptimizerDefaultSettings(context.TODO(), &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 } fmt.Fprintf(out, "Allow Video: %t\n", o.AllowVideo) fmt.Fprintf(out, "JPEG Quality: %d\n", o.JpegQuality) fmt.Fprintf(out, "JPEG Type: %s\n", o.JpegType) fmt.Fprintf(out, "Resize Filter: %s\n", o.ResizeFilter) fmt.Fprintf(out, "Upscale: %t\n", o.Upscale) fmt.Fprintf(out, "WebP: %t\n", o.Webp) fmt.Fprintf(out, "WebP Quality: %d\n", o.WebpQuality) return nil } ================================================ FILE: pkg/commands/service/imageoptimizerdefaults/imageoptimizer_test.go ================================================ package imageoptimizerdefaults_test import ( "context" "errors" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/imageoptimizerdefaults" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestImageOptimizerDefaultsUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--version 1", WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate missing --version flag", Args: "--service-id 123", WantError: "error parsing arguments: required flag --version not provided", }, { Name: "validate missing optional flags", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsValidationError, }, // For future clarity, this order is coming from Go-Fastly. We should fix that at some point. WantError: "problem with field 'ResizeFilter, Webp, WebpQuality, JpegType, JpegQuality, Upscale, AllowVideo': at least one of the available optional fields is required", }, { Name: "validate successful boolean updates of webp, upscale and allow-video", Args: "--service-id 123 --version 1 --webp=true --upscale=false --allow-video=true", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsWithBoolsOK, }, WantOutput: "Updated Image Optimizer default settings for service 123 (version 1)\n\nAllow Video: true\nJPEG Quality: 85\nJPEG Type: auto\nResize Filter: lanczos3\nUpscale: false\nWebP: true\nWebP Quality: 85\n", }, { Name: "validate successful update of the --resize, --webp-quality and --jpeg-quality flags", Args: "--service-id 123 --version 1 --resize-filter bicubic --webp-quality 90 --jpeg-quality 80", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsWithOptionsOK, }, WantOutput: "Updated Image Optimizer default settings for service 123 (version 1)\n\nAllow Video: false\nJPEG Quality: 80\nJPEG Type: auto\nResize Filter: bicubic\nUpscale: false\nWebP: false\nWebP Quality: 90\n", }, { Name: "validate incorrect input for the --webp flag", Args: "--service-id 123 --version 1 --webp invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsOK, }, WantError: "'webp' flag must be one of the following [true, false]", }, { Name: "validate incorrect input for the --upscale flag", Args: "--service-id 123 --version 1 --upscale invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsOK, }, WantError: "'upscale' flag must be one of the following [true, false]", }, { Name: "validate incorrect input for the --allow-video flag", Args: "--service-id 123 --version 1 --allow-video invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsOK, }, WantError: "'allow-video' flag must be one of the following [true, false]", }, { Name: "validate incorrect input for the --resize-filter flag", Args: "--service-id 123 --version 1 --resize-filter invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsOK, }, WantError: "invalid resize filter: invalid. Valid options: lanczos3, lanczos2, bicubic, bilinear, nearest", }, { Name: "validate incorrect input for the --jpeg-type flag", Args: "--service-id 123 --version 1 --jpeg-type invalid", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsOK, }, WantError: "invalid jpeg type: invalid. Valid options: auto, baseline, progressive", }, { Name: "validate API error handling", Args: "--service-id 123 --version 1 --webp true", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateImageOptimizerDefaultSettingsFn: updateImageOptimizerDefaultsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } func updateImageOptimizerDefaultsOK(_ context.Context, _ *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return &fastly.ImageOptimizerDefaultSettings{ ResizeFilter: "lanczos3", Webp: false, WebpQuality: 85, JpegType: "auto", JpegQuality: 85, Upscale: false, AllowVideo: false, }, nil } func updateImageOptimizerDefaultsWithBoolsOK(_ context.Context, i *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return &fastly.ImageOptimizerDefaultSettings{ ResizeFilter: "lanczos3", Webp: fastly.ToValue(i.Webp), WebpQuality: 85, JpegType: "auto", JpegQuality: 85, Upscale: fastly.ToValue(i.Upscale), AllowVideo: fastly.ToValue(i.AllowVideo), }, nil } func updateImageOptimizerDefaultsWithOptionsOK(_ context.Context, i *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { resizeFilter := "bicubic" if i.ResizeFilter != nil { switch *i.ResizeFilter { case fastly.ImageOptimizerLanczos3: resizeFilter = "lanczos3" case fastly.ImageOptimizerLanczos2: resizeFilter = "lanczos2" case fastly.ImageOptimizerBicubic: resizeFilter = "bicubic" case fastly.ImageOptimizerBilinear: resizeFilter = "bilinear" case fastly.ImageOptimizerNearest: resizeFilter = "nearest" } } jpegType := "auto" if i.JpegType != nil { switch *i.JpegType { case fastly.ImageOptimizerAuto: jpegType = "auto" case fastly.ImageOptimizerBaseline: jpegType = "baseline" case fastly.ImageOptimizerProgressive: jpegType = "progressive" } } return &fastly.ImageOptimizerDefaultSettings{ ResizeFilter: resizeFilter, Webp: false, WebpQuality: fastly.ToValue(i.WebpQuality), JpegType: jpegType, JpegQuality: fastly.ToValue(i.JpegQuality), Upscale: false, AllowVideo: false, }, nil } func updateImageOptimizerDefaultsValidationError(_ context.Context, _ *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return nil, errors.New("problem with field 'ResizeFilter, Webp, WebpQuality, JpegType, JpegQuality, Upscale, AllowVideo': at least one of the available optional fields is required") } func updateImageOptimizerDefaultsError(_ context.Context, _ *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return nil, errTest } func TestImageOptimizerDefaultsGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--version 1", WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate missing --version flag", Args: "--service-id 123", WantError: "error parsing arguments: required flag --version not provided", }, { Name: "validate successful get with no flags", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetImageOptimizerDefaultSettingsFn: getImageOptimizerDefaultsOK, }, WantOutput: "Allow Video: false\nJPEG Quality: 85\nJPEG Type: auto\nResize Filter: lanczos3\nUpscale: false\nWebP: false\nWebP Quality: 85\n", }, { Name: "validate successful get with --json flag", Args: "--service-id 123 --version 1 --json", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetImageOptimizerDefaultSettingsFn: getImageOptimizerDefaultsOK, }, WantOutput: "{\n \"resize_filter\": \"lanczos3\",\n \"webp\": false,\n \"webp_quality\": 85,\n \"jpeg_type\": \"auto\",\n \"jpeg_quality\": 85,\n \"upscale\": false,\n \"allow_video\": false\n}\n", }, { Name: "validate API error handling", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetImageOptimizerDefaultSettingsFn: getImageOptimizerDefaultsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) } func getImageOptimizerDefaultsOK(_ context.Context, _ *fastly.GetImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return &fastly.ImageOptimizerDefaultSettings{ ResizeFilter: "lanczos3", Webp: false, WebpQuality: 85, JpegType: "auto", JpegQuality: 85, Upscale: false, AllowVideo: false, }, nil } func getImageOptimizerDefaultsError(_ context.Context, _ *fastly.GetImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return nil, errTest } var errTest = errors.New("fixture error") ================================================ FILE: pkg/commands/service/imageoptimizerdefaults/root.go ================================================ package imageoptimizerdefaults 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 = "imageoptimizer" // 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 Image Optimizer default settings") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/service/imageoptimizerdefaults/update.go ================================================ package imageoptimizerdefaults import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" ) // UpdateCommand calls the Fastly API to update Image Optimizer default settings for a service. type UpdateCommand struct { argparser.Base argparser.JSONOutput Input fastly.UpdateImageOptimizerDefaultSettingsInput serviceName argparser.OptionalServiceNameID serviceVersion argparser.OptionalServiceVersion // Image Optimizer setting flags allowVideo argparser.OptionalString jpegQuality argparser.OptionalInt jpegType argparser.OptionalString resizeFilter argparser.OptionalString upscale argparser.OptionalString webp argparser.OptionalString webpQuality 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 Image Optimizer default settings for a service") // Required. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagVersionName, Description: argparser.FlagVersionDesc, Dst: &c.serviceVersion.Value, Required: true, }) // Optional flags for Image Optimizer settings c.RegisterFlag(argparser.StringFlagOpts{ Name: "allow-video", Description: "Enables GIF to MP4 transformations on this service [true, false]", Action: c.allowVideo.Set, Dst: &c.allowVideo.Value, }) c.RegisterFlagInt(argparser.IntFlagOpts{ Name: "jpeg-quality", Description: "The default quality to use with JPEG output (1-100)", Dst: &c.jpegQuality.Value, Action: c.jpegQuality.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: "jpeg-type", Description: "The default type of JPEG output to use (auto, baseline, progressive)", Dst: &c.jpegType.Value, Action: c.jpegType.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: "resize-filter", Description: "The type of filter to use while resizing an image (lanczos3, lanczos2, bicubic, bilinear, nearest)", Dst: &c.resizeFilter.Value, Action: c.resizeFilter.Set, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: "upscale", Description: "Whether or not we should allow output images to render at sizes larger than input [true, false]", Action: c.upscale.Set, Dst: &c.upscale.Value, }) c.RegisterFlag(argparser.StringFlagOpts{ Name: "webp", Description: "Controls whether or not to default to WebP output when the client supports it [true, false]", Action: c.webp.Set, Dst: &c.webp.Value, }) c.RegisterFlagInt(argparser.IntFlagOpts{ Name: "webp-quality", Description: "The default quality to use with WebP output (1-100)", Dst: &c.webpQuality.Value, Action: c.webpQuality.Set, }) // Optional. c.RegisterFlagBool(c.JSONFlag()) // --json 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 { 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) // Set optional fields only if they were provided if c.resizeFilter.WasSet { // Convert string to ImageOptimizerResizeFilter constant switch c.resizeFilter.Value { case "lanczos3": filter := fastly.ImageOptimizerLanczos3 c.Input.ResizeFilter = &filter case "lanczos2": filter := fastly.ImageOptimizerLanczos2 c.Input.ResizeFilter = &filter case "bicubic": filter := fastly.ImageOptimizerBicubic c.Input.ResizeFilter = &filter case "bilinear": filter := fastly.ImageOptimizerBilinear c.Input.ResizeFilter = &filter case "nearest": filter := fastly.ImageOptimizerNearest c.Input.ResizeFilter = &filter default: return fmt.Errorf("invalid resize filter: %s. Valid options: lanczos3, lanczos2, bicubic, bilinear, nearest", c.resizeFilter.Value) } } if c.webp.WasSet { webp, err := argparser.ConvertBoolFromStringFlag(c.webp.Value, "webp") if err != nil { c.Globals.ErrLog.Add(err) return err } c.Input.Webp = webp } if c.webpQuality.WasSet { c.Input.WebpQuality = &c.webpQuality.Value } if c.jpegType.WasSet { // Convert string to JPEG type constant switch c.jpegType.Value { case "auto": jpegType := fastly.ImageOptimizerAuto c.Input.JpegType = &jpegType case "baseline": jpegType := fastly.ImageOptimizerBaseline c.Input.JpegType = &jpegType case "progressive": jpegType := fastly.ImageOptimizerProgressive c.Input.JpegType = &jpegType default: return fmt.Errorf("invalid jpeg type: %s. Valid options: auto, baseline, progressive", c.jpegType.Value) } } if c.jpegQuality.WasSet { c.Input.JpegQuality = &c.jpegQuality.Value } if c.upscale.WasSet { upscale, err := argparser.ConvertBoolFromStringFlag(c.upscale.Value, "upscale") if err != nil { c.Globals.ErrLog.Add(err) return err } c.Input.Upscale = upscale } if c.allowVideo.WasSet { allowVideo, err := argparser.ConvertBoolFromStringFlag(c.allowVideo.Value, "allow-video") if err != nil { c.Globals.ErrLog.Add(err) return err } c.Input.AllowVideo = allowVideo } o, err := c.Globals.APIClient.UpdateImageOptimizerDefaultSettings(context.TODO(), &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 } fmt.Fprintf(out, "Updated Image Optimizer default settings for service %s (version %d)\n", serviceID, fastly.ToValue(serviceVersion.Number)) fmt.Fprintf(out, "\nAllow Video: %t\n", o.AllowVideo) fmt.Fprintf(out, "JPEG Quality: %d\n", o.JpegQuality) fmt.Fprintf(out, "JPEG Type: %s\n", o.JpegType) fmt.Fprintf(out, "Resize Filter: %s\n", o.ResizeFilter) fmt.Fprintf(out, "Upscale: %t\n", o.Upscale) fmt.Fprintf(out, "WebP: %t\n", o.Webp) fmt.Fprintf(out, "WebP Quality: %d\n", o.WebpQuality) return nil } ================================================ FILE: pkg/commands/service/list.go ================================================ package service import ( "context" "fmt" "io" "strconv" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/azureblob/azureblob_integration_test.go ================================================ package azureblob_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/azureblob" ) func TestBlobStorageCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --account-name account --container log --sas-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateBlobStorageFn: createBlobStorageOK, }, WantOutput: "Created Azure Blob Storage logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --account-name account --container log --sas-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateBlobStorageFn: createBlobStorageError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateBlobStorageFn: createBlobStorageError, }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestBlobStorageList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBlobStoragesFn: listBlobStoragesOK, }, WantOutput: listBlobStoragesShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBlobStoragesFn: listBlobStoragesOK, }, WantOutput: listBlobStoragesVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBlobStoragesFn: listBlobStoragesOK, }, WantOutput: listBlobStoragesVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBlobStoragesFn: listBlobStoragesError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestBlobStorageDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBlobStorageFn: getBlobStorageError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBlobStorageFn: getBlobStorageOK, }, WantOutput: describeBlobStorageOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestBlobStorageUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateBlobStorageFn: updateBlobStorageError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateBlobStorageFn: updateBlobStorageOK, }, WantOutput: "Updated Azure Blob Storage logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestBlobStorageDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBlobStorageFn: deleteBlobStorageError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBlobStorageFn: deleteBlobStorageOK, }, WantOutput: "Deleted Azure Blob Storage logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createBlobStorageOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { return nil, errTest } func listBlobStoragesOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listBlobStoragesError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getBlobStorageOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), PublicKey: fastly.ToPointer(pgpPublicKey()), CompressionCodec: fastly.ToPointer("zstd"), }, nil } func getBlobStorageError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { return nil, errTest } func deleteBlobStorageOK(_ context.Context, _ *fastly.DeleteBlobStorageInput) error { return nil } func deleteBlobStorageError(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/azureblob/azureblob_test.go ================================================ package azureblob_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), PublicKey: fastly.ToPointer("new11"), CompressionCodec: fastly.ToPointer("new12"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/azureblob/create.go ================================================ package azureblob import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Azure Blob Storage") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/azureblob/delete.go ================================================ package azureblob import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/azureblob/describe.go ================================================ package azureblob import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/azureblob/doc.go ================================================ // Package azureblob contains commands to inspect and manipulate Fastly service Azure Blob Storage // logging endpoints. package azureblob ================================================ FILE: pkg/commands/service/logging/azureblob/list.go ================================================ package azureblob import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(azureblob.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/azureblob/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/azureblob/update.go ================================================ package azureblob import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Azure Blob Storage") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/bigquery/bigquery_integration_test.go ================================================ package bigquery_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/bigquery" ) func TestBigQueryCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateBigQueryFn: createBigQueryOK, }, WantOutput: "Created BigQuery logging endpoint log (service 123 version 4)", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateBigQueryFn: createBigQueryError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestBigQueryList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBigQueriesFn: listBigQueriesOK, }, WantOutput: listBigQueriesShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBigQueriesFn: listBigQueriesOK, }, WantOutput: listBigQueriesVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBigQueriesFn: listBigQueriesOK, }, WantOutput: listBigQueriesVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListBigQueriesFn: listBigQueriesError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestBigQueryDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBigQueryFn: getBigQueryError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetBigQueryFn: getBigQueryOK, }, WantOutput: describeBigQueryOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestBigQueryUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateBigQueryFn: updateBigQueryError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateBigQueryFn: updateBigQueryOK, }, WantOutput: "Updated BigQuery logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestBigQueryDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBigQueryFn: deleteBigQueryError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteBigQueryFn: deleteBigQueryOK, }, WantOutput: "Deleted BigQuery logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createBigQueryOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { return nil, errTest } func listBigQueriesOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listBigQueriesError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getBigQueryOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getBigQueryError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { return nil, errTest } func deleteBigQueryOK(_ context.Context, _ *fastly.DeleteBigQueryInput) error { return nil } func deleteBigQueryError(_ context.Context, _ *fastly.DeleteBigQueryInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/bigquery/bigquery_test.go ================================================ package bigquery_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), FormatVersion: fastly.ToPointer(2), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/bigquery/create.go ================================================ package bigquery import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" 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 ProcessingRegion 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. logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "BigQuery") c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/bigquery/delete.go ================================================ package bigquery import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/bigquery/describe.go ================================================ package bigquery import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/bigquery/doc.go ================================================ // Package bigquery contains commands to inspect and manipulate Fastly service // BigQuery logging endpoints. package bigquery ================================================ FILE: pkg/commands/service/logging/bigquery/list.go ================================================ package bigquery import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(bq.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/bigquery/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/bigquery/update.go ================================================ package bigquery import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" 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 ProcessingRegion 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. logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "BigQuery") c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/cloudfiles/cloudfiles_integration_test.go ================================================ package cloudfiles_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles" ) func TestCloudfilesCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --user username --bucket log --access-key foo --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateCloudfilesFn: createCloudfilesOK, }, WantOutput: "Created Cloudfiles logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --user username --bucket log --access-key foo --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateCloudfilesFn: createCloudfilesError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name log --user username --bucket log --access-key foo --compression-codec zstd --gzip-level 9 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestCloudfilesList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListCloudfilesFn: listCloudfilesOK, }, WantOutput: listCloudfilesShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListCloudfilesFn: listCloudfilesOK, }, WantOutput: listCloudfilesVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListCloudfilesFn: listCloudfilesOK, }, WantOutput: listCloudfilesVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListCloudfilesFn: listCloudfilesError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestCloudfilesDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetCloudfilesFn: getCloudfilesError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetCloudfilesFn: getCloudfilesOK, }, WantOutput: describeCloudfilesOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestCloudfilesUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateCloudfilesFn: updateCloudfilesError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateCloudfilesFn: updateCloudfilesOK, }, WantOutput: "Updated Cloudfiles logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestCloudfilesDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteCloudfilesFn: deleteCloudfilesError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteCloudfilesFn: deleteCloudfilesOK, }, WantOutput: "Deleted Cloudfiles logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createCloudfilesOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { return nil, errTest } func listCloudfilesOK(_ context.Context, 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()), ProcessingRegion: fastly.ToPointer("us"), }, { 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()), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listCloudfilesError(_ context.Context, _ *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 (auth: 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()+` Processing region: us 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()+` Processing region: us `) + "\n\n" func getCloudfilesOK(_ context.Context, 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), ProcessingRegion: fastly.ToPointer("us"), 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(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { return nil, errTest } func deleteCloudfilesOK(_ context.Context, _ *fastly.DeleteCloudfilesInput) error { return nil } func deleteCloudfilesError(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/cloudfiles/cloudfiles_test.go ================================================ package cloudfiles_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *cloudfiles.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *cloudfiles.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/cloudfiles/create.go ================================================ package cloudfiles import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" 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 ProcessingRegion 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Cloud Files") logflags.PublicKey(c.CmdClause, &c.PublicKey) c.CmdClause.Flag("region", "The region where logs are received and stored by Cloud Files. One of: DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, LON-London, SYD-Sydney, HKG-Hong Kong").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/cloudfiles/delete.go ================================================ package cloudfiles import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/cloudfiles/describe.go ================================================ package cloudfiles import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/cloudfiles/doc.go ================================================ // Package cloudfiles contains commands to inspect and manipulate Fastly service Cloudfiles // logging endpoints. package cloudfiles ================================================ FILE: pkg/commands/service/logging/cloudfiles/list.go ================================================ package cloudfiles import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(cloudfile.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/cloudfiles/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/cloudfiles/update.go ================================================ package cloudfiles import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" 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. AccessKey argparser.OptionalString AutoClone argparser.OptionalAutoClone BucketName 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 ProcessingRegion argparser.OptionalString PublicKey argparser.OptionalString Region argparser.OptionalString ResponseCondition 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 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Cloud Files") logflags.PublicKey(c.CmdClause, &c.PublicKey) c.CmdClause.Flag("region", "The region where logs are received and stored by Cloud Files. 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) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/datadog/create.go ================================================ package datadog import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Datadog") c.CmdClause.Flag("region", "The region where logs are received and stored by Datadog. One of US, US3, US5, or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/datadog/datadog_integration_test.go ================================================ package datadog_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/datadog" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestDatadogCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDatadogFn: createDatadogOK, }, WantOutput: "Created Datadog logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDatadogFn: createDatadogError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestDatadogList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDatadogFn: listDatadogsOK, }, WantOutput: listDatadogsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDatadogFn: listDatadogsOK, }, WantOutput: listDatadogsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDatadogFn: listDatadogsOK, }, WantOutput: listDatadogsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDatadogFn: listDatadogsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestDatadogDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDatadogFn: getDatadogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDatadogFn: getDatadogOK, }, WantOutput: describeDatadogOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestDatadogUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDatadogFn: updateDatadogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDatadogFn: updateDatadogOK, }, WantOutput: "Updated Datadog logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestDatadogDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDatadogFn: deleteDatadogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDatadogFn: deleteDatadogOK, }, WantOutput: "Deleted Datadog logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createDatadogOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateDatadogInput) (*fastly.Datadog, error) { return nil, errTest } func listDatadogsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listDatadogsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getDatadogOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getDatadogError(_ context.Context, _ *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 Processing region: us Region: US Response condition: Prevent default logging Service ID: 123 Token: abc Version: 1 `) + "\n" func updateDatadogOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { return nil, errTest } func deleteDatadogOK(_ context.Context, _ *fastly.DeleteDatadogInput) error { return nil } func deleteDatadogError(_ context.Context, _ *fastly.DeleteDatadogInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/datadog/datadog_test.go ================================================ package datadog_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } 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{ GetVersionFn: testutil.GetVersion, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *datadog.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/datadog/delete.go ================================================ package datadog import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/datadog/describe.go ================================================ package datadog import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/datadog/doc.go ================================================ // Package datadog contains commands to inspect and manipulate Fastly service Datadog // logging endpoints. package datadog ================================================ FILE: pkg/commands/service/logging/datadog/list.go ================================================ package datadog import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(datadog.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/datadog/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/datadog/update.go ================================================ package datadog import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString Region argparser.OptionalString ResponseCondition argparser.OptionalString Token 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Datadog") c.CmdClause.Flag("region", "The region where logs are received and stored by Datadog. One of US, US3, US5, or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/debug/debug_test.go ================================================ package debug import ( "encoding/json" "testing" "github.com/google/go-cmp/cmp" "github.com/fastly/go-fastly/v15/fastly" ) // TestParseLoggingError validates we're correctly decoding individual logging error JSON. func TestParseLoggingError(t *testing.T) { data := []byte(`{"sequence_number":1,"error_time_us":1601645172164,"stream":"logging_error","message":"Failed to send log","endpoint":"my-s3-endpoint","details":"connection refused"}`) var got fastly.LoggingEndpointError err := json.Unmarshal(data, &got) if err != nil { t.Fatalf("error parsing response data: %v", err) } want := fastly.LoggingEndpointError{ SequenceNumber: 1, Timestamp: 1601645172164, Stream: "logging_error", Message: "Failed to send log", Endpoint: "my-s3-endpoint", Details: "connection refused", } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("JSON unmarshal mismatch (-want +got):\n%s", diff) } } ================================================ FILE: pkg/commands/service/logging/debug/doc.go ================================================ // Package debug contains the command to stream live logging endpoint errors, // providing visibility into logging pipeline issues for troubleshooting and resolution. package debug ================================================ FILE: pkg/commands/service/logging/debug/root.go ================================================ package debug import ( "context" "encoding/json" "fmt" "io" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // batch wraps errors for sending to the output loop. type batch struct { Errors []fastly.LoggingEndpointError } // Command is the command for streaming logging endpoint errors. type Command struct { argparser.Base serviceName argparser.OptionalServiceNameID serviceID string from uint64 to uint64 filter string printTimestamps bool jsonOutput bool batchCh chan batch dieCh chan struct{} doneCh chan struct{} } // CommandName is the string to be used to invoke this command. const CommandName = "debug" // NewDebugCommand returns a new command registered in the parent. func NewDebugCommand(parent argparser.Registerer, g *global.Data) *Command { var c Command c.Globals = g c.CmdClause = parent.Command(CommandName, "Stream live logging endpoint errors") 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").Uint64Var(&c.from) c.CmdClause.Flag("to", "To time, in Unix seconds").Uint64Var(&c.to) c.CmdClause.Flag("filter", "Filter errors by logging endpoint name").StringVar(&c.filter) c.CmdClause.Flag("timestamps", "Print full timestamps instead of compact time").BoolVar(&c.printTimestamps) c.CmdClause.Flag("json", "Output error stream as JSON").BoolVar(&c.jsonOutput) return &c } // Exec implements the command interface. func (c *Command) 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.serviceID = serviceID c.dieCh = make(chan struct{}) c.batchCh = make(chan batch) c.doneCh = make(chan struct{}) text.Info(out, "Streaming logging endpoint errors for service %s\n\n", c.serviceID) 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 streaming the errors. go func() { failure <- c.stream(out) }() select { case asyncErr := <-failure: close(c.dieCh) return asyncErr case <-c.doneCh: return nil case <-sigs: close(c.dieCh) } return nil } // stream fetches error data from the API and sends it to the output loop. func (c *Command) stream(out io.Writer) error { var curWindow *uint64 if c.from != 0 { curWindow = &c.from } var toWindow *uint64 if c.to != 0 { toWindow = &c.to } // Prepare filter slice var filter []string if c.filter != "" { filter = []string{c.filter} } ctx := context.Background() for { // Check if we've passed the "to" requirement. if toWindow != nil && curWindow != nil && *curWindow > *toWindow { text.Info(out, "Reached window: %v which is newer than the requested 'to': %v", *curWindow, *toWindow) close(c.doneCh) break } // Use go-fastly to fetch logging endpoint errors resp, err := c.Globals.APIClient.GetLoggingEndpointErrors(ctx, &fastly.LoggingEndpointErrorsInput{ ServiceID: c.serviceID, From: curWindow, To: toWindow, Filter: filter, }) if err != nil { c.Globals.ErrLog.Add(err) return fmt.Errorf("unable to fetch logging endpoint errors: %w", err) } // Send errors to the output loop if len(resp.Errors) > 0 { c.batchCh <- batch{Errors: resp.Errors} } // Check for next link to continue streaming if resp.NextFrom != "" { // Parse the next link value (it's already the from parameter value) nextFrom, err := strconv.ParseUint(resp.NextFrom, 10, 64) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "NextFrom": resp.NextFrom, }) text.Error(out, "error parsing next from") continue } curWindow = &nextFrom } else { // No next link, we're done close(c.doneCh) break } } return nil } // outputLoop processes the errors out of band from the request/response loop. func (c *Command) outputLoop(out io.Writer) { for { select { case <-c.dieCh: return case batch := <-c.batchCh: c.printErrors(out, batch.Errors) } } } // printErrors prints error entries. func (c *Command) printErrors(out io.Writer, errors []fastly.LoggingEndpointError) { if len(errors) == 0 { return } if c.jsonOutput { // Output as JSON array encoder := json.NewEncoder(out) for _, e := range errors { if err := encoder.Encode(e); err != nil { c.Globals.ErrLog.Add(err) } } } else { // Find the longest endpoint name in this batch for dynamic width maxEndpointLen := 0 for _, e := range errors { if len(e.Endpoint) > maxEndpointLen { maxEndpointLen = len(e.Endpoint) } } // Human-readable format - match log-tail style for _, e := range errors { // Format timestamp // #nosec G115 -- Timestamp is in microseconds, multiplication by 1000 for nanoseconds is safe for reasonable time values timestamp := time.Unix(0, int64(e.Timestamp)*1000) // Convert microseconds to nanoseconds var timeStr string if c.printTimestamps { // Full timestamp with --timestamps flag timeStr = timestamp.UTC().Format(time.RFC3339) } else { // Compact time by default (HH:MM:SS) timeStr = timestamp.UTC().Format("15:04:05") } // Extract clean error message from details JSON if present errorSummary := e.Message if e.Details != "" { var detailsJSON map[string]interface{} if err := json.Unmarshal([]byte(e.Details), &detailsJSON); err == nil { // Try to extract a cleaner error message if errorMsg, ok := detailsJSON["error"].(string); ok { // Simplify common error patterns errorMsg = strings.TrimPrefix(errorMsg, "non-temporary request err: ") errorMsg = strings.TrimPrefix(errorMsg, "temporary request err: ") errorSummary = errorMsg } } } // Format: time | endpoint | message fmt.Fprintf(out, "%s | %-*s | %s\n", timeStr, maxEndpointLen, e.Endpoint, errorSummary) } } // Flush output immediately if f, ok := out.(*os.File); ok { _ = f.Sync() } } ================================================ FILE: pkg/commands/service/logging/digitalocean/create.go ================================================ package digitalocean import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.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) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "DigitalOcean Spaces") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/digitalocean/delete.go ================================================ package digitalocean import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/digitalocean/describe.go ================================================ package digitalocean import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/digitalocean/digitalocean_integration_test.go ================================================ package digitalocean_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/digitalocean" ) func TestDigitalOceanCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDigitalOceanFn: createDigitalOceanOK, }, WantOutput: "Created DigitalOcean Spaces logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateDigitalOceanFn: createDigitalOceanError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestDigitalOceanList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDigitalOceansFn: listDigitalOceansOK, }, WantOutput: listDigitalOceansShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDigitalOceansFn: listDigitalOceansOK, }, WantOutput: listDigitalOceansVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDigitalOceansFn: listDigitalOceansOK, }, WantOutput: listDigitalOceansVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListDigitalOceansFn: listDigitalOceansError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestDigitalOceanDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDigitalOceanFn: getDigitalOceanError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDigitalOceanFn: getDigitalOceanOK, }, WantOutput: describeDigitalOceanOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestDigitalOceanUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDigitalOceanFn: updateDigitalOceanError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateDigitalOceanFn: updateDigitalOceanOK, }, WantOutput: "Updated DigitalOcean Spaces logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestDigitalOceanDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDigitalOceanFn: deleteDigitalOceanError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteDigitalOceanFn: deleteDigitalOceanOK, }, WantOutput: "Deleted DigitalOcean Spaces logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createDigitalOceanOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { return nil, errTest } func listDigitalOceansOK(_ context.Context, 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(_ context.Context, _ *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 (auth: 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(_ context.Context, 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()), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getDigitalOceanError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { return nil, errTest } func deleteDigitalOceanOK(_ context.Context, _ *fastly.DeleteDigitalOceanInput) error { return nil } func deleteDigitalOceanError(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/digitalocean/digitalocean_test.go ================================================ package digitalocean_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/digitalocean/doc.go ================================================ // Package digitalocean contains commands to inspect and manipulate Fastly service DigitalOcean // logging endpoints. package digitalocean ================================================ FILE: pkg/commands/service/logging/digitalocean/list.go ================================================ package digitalocean import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/digitalocean/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/digitalocean/update.go ================================================ package digitalocean import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. AccessKey argparser.OptionalString AutoClone argparser.OptionalAutoClone BucketName argparser.OptionalString CompressionCodec argparser.OptionalString Domain 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 ProcessingRegion argparser.OptionalString PublicKey argparser.OptionalString ResponseCondition argparser.OptionalString SecretKey argparser.OptionalString TimestampFormat 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "DigitalOcean Spaces") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/doc.go ================================================ // Package logging contains commands to inspect and manipulate Fastly service // logging endpoints. package logging ================================================ FILE: pkg/commands/service/logging/elasticsearch/create.go ================================================ package elasticsearch import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Elasticsearch") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/elasticsearch/delete.go ================================================ package elasticsearch import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/elasticsearch/describe.go ================================================ package elasticsearch import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/elasticsearch/doc.go ================================================ // Package elasticsearch contains commands to inspect and manipulate Fastly service Elasticsearch // logging endpoints. package elasticsearch ================================================ FILE: pkg/commands/service/logging/elasticsearch/elasticsearch_integration_test.go ================================================ package elasticsearch_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch" ) func TestElasticsearchCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --index logs --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateElasticsearchFn: createElasticsearchOK, }, WantOutput: "Created Elasticsearch logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --index logs --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateElasticsearchFn: createElasticsearchError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestElasticsearchList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListElasticsearchFn: listElasticsearchsOK, }, WantOutput: listElasticsearchsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListElasticsearchFn: listElasticsearchsOK, }, WantOutput: listElasticsearchsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListElasticsearchFn: listElasticsearchsOK, }, WantOutput: listElasticsearchsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListElasticsearchFn: listElasticsearchsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestElasticsearchDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetElasticsearchFn: getElasticsearchError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetElasticsearchFn: getElasticsearchOK, }, WantOutput: describeElasticsearchOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestElasticsearchUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateElasticsearchFn: updateElasticsearchError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateElasticsearchFn: updateElasticsearchOK, }, WantOutput: "Updated Elasticsearch logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestElasticsearchDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteElasticsearchFn: deleteElasticsearchError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteElasticsearchFn: deleteElasticsearchOK, }, WantOutput: "Deleted Elasticsearch logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createElasticsearchOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { return nil, errTest } func listElasticsearchsOK(_ context.Context, 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), ProcessingRegion: fastly.ToPointer("us"), }, { 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), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listElasticsearchsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getElasticsearchOK(_ context.Context, 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), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getElasticsearchError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { return nil, errTest } func deleteElasticsearchOK(_ context.Context, _ *fastly.DeleteElasticsearchInput) error { return nil } func deleteElasticsearchError(_ context.Context, _ *fastly.DeleteElasticsearchInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/elasticsearch/elasticsearch_test.go ================================================ package elasticsearch_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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), ProcessingRegion: fastly.ToPointer("eu"), 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{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/elasticsearch/list.go ================================================ package elasticsearch import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(elasticsearch.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/elasticsearch/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/elasticsearch/update.go ================================================ package elasticsearch import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt Index argparser.OptionalString NewName argparser.OptionalString Password argparser.OptionalString Pipeline argparser.OptionalString Placement argparser.OptionalString ProcessingRegion 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 } // 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Elasticsearch") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/ftp/create.go ================================================ package ftp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion argparser.OptionalString 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "FTP") logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/ftp/delete.go ================================================ package ftp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/ftp/describe.go ================================================ package ftp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/ftp/doc.go ================================================ // Package ftp contains commands to inspect and manipulate Fastly service FTP // logging endpoints. package ftp ================================================ FILE: pkg/commands/service/logging/ftp/ftp_integration_test.go ================================================ package ftp_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/ftp" ) func TestFTPCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --address example.com --user anonymous --password foo@example.com --compression-codec zstd --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateFTPFn: createFTPOK, }, WantOutput: "Created FTP logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --address example.com --user anonymous --password foo@example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateFTPFn: createFTPError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestFTPList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListFTPsFn: listFTPsOK, }, WantOutput: listFTPsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListFTPsFn: listFTPsOK, }, WantOutput: listFTPsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListFTPsFn: listFTPsOK, }, WantOutput: listFTPsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListFTPsFn: listFTPsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestFTPDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetFTPFn: getFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetFTPFn: getFTPOK, }, WantOutput: describeFTPOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestFTPUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateFTPFn: updateFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateFTPFn: updateFTPOK, }, WantOutput: "Updated FTP logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestFTPDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteFTPFn: deleteFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteFTPFn: deleteFTPOK, }, WantOutput: "Deleted FTP logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createFTPOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateFTPInput) (*fastly.FTP, error) { return nil, errTest } func listFTPsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listFTPsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getFTPOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getFTPError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateFTPInput) (*fastly.FTP, error) { return nil, errTest } func deleteFTPOK(_ context.Context, _ *fastly.DeleteFTPInput) error { return nil } func deleteFTPError(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/ftp/ftp_test.go ================================================ package ftp_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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), ProcessingRegion: fastly.ToPointer("eu"), 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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), ProcessingRegion: fastly.ToPointer("eu"), 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/ftp/list.go ================================================ package ftp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(ftp.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/ftp/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/ftp/update.go ================================================ package ftp import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. Address argparser.OptionalString AutoClone argparser.OptionalAutoClone CompressionCodec argparser.OptionalString Format argparser.OptionalString FormatVersion argparser.OptionalInt GzipLevel argparser.OptionalInt NewName argparser.OptionalString Password argparser.OptionalString Path argparser.OptionalString Period argparser.OptionalInt Placement argparser.OptionalString Port argparser.OptionalInt ProcessingRegion argparser.OptionalString PublicKey argparser.OptionalString ResponseCondition argparser.OptionalString TimestampFormat argparser.OptionalString Username 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, }) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "FTP") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/gcs/create.go ================================================ package gcs import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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. logflags.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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "GCS") c.CmdClause.Flag("project-id", "The google project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) logflags.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, }) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/gcs/delete.go ================================================ package gcs import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/gcs/describe.go ================================================ package gcs import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/gcs/doc.go ================================================ // Package gcs contains commands to inspect and manipulate Fastly service GCS // logging endpoints. package gcs ================================================ FILE: pkg/commands/service/logging/gcs/gcs_integration_test.go ================================================ package gcs_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/gcs" ) func TestGCSCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --bucket log --user foo@example.com --secret-key foo --period 86400 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateGCSFn: createGCSOK, }, WantOutput: "Created GCS logging endpoint log (service 123 version 4)", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateGCSFn: createGCSOK, }, WantOutput: "Created GCS logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --bucket log --user foo@example.com --secret-key foo --period 86400 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateGCSFn: createGCSError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestGCSList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGCSsFn: listGCSsOK, }, WantOutput: listGCSsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGCSsFn: listGCSsOK, }, WantOutput: listGCSsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGCSsFn: listGCSsOK, }, WantOutput: listGCSsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGCSsFn: listGCSsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestGCSDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGCSFn: getGCSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGCSFn: getGCSOK, }, WantOutput: describeGCSOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestGCSUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateGCSFn: updateGCSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateGCSFn: updateGCSOK, }, WantOutput: "Updated GCS logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestGCSDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteGCSFn: deleteGCSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteGCSFn: deleteGCSOK, }, WantOutput: "Deleted GCS logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createGCSOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateGCSInput) (*fastly.GCS, error) { return nil, errTest } func listGCSsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listGCSsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getGCSOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getGCSError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateGCSInput) (*fastly.GCS, error) { return nil, errTest } func deleteGCSOK(_ context.Context, _ *fastly.DeleteGCSInput) error { return nil } func deleteGCSError(_ context.Context, _ *fastly.DeleteGCSInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/gcs/gcs_test.go ================================================ package gcs_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *gcs.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *gcs.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/gcs/list.go ================================================ package gcs import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(gcs.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/gcs/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/gcs/update.go ================================================ package gcs import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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. logflags.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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "GCS") c.CmdClause.Flag("project-id", "The google project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) logflags.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) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/googlepubsub/create.go ================================================ package googlepubsub import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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. logflags.AccountName(c.CmdClause, &c.AccountName) c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ Action: c.AutoClone.Set, Dst: &c.AutoClone.Value, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Google Cloud Pub/Sub") c.CmdClause.Flag("project-id", "The ID of your Google Cloud Platform project").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/googlepubsub/delete.go ================================================ package googlepubsub import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/googlepubsub/describe.go ================================================ package googlepubsub import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/googlepubsub/doc.go ================================================ // Package googlepubsub contains commands to inspect and manipulate Fastly service Google Cloud Pub/Sub // logging endpoints. package googlepubsub ================================================ FILE: pkg/commands/service/logging/googlepubsub/googlepubsub_integration_test.go ================================================ package googlepubsub_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/googlepubsub" ) func TestGooglePubSubCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreatePubsubFn: createGooglePubSubOK, }, WantOutput: "Created Google Cloud Pub/Sub logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --user user@example.com --secret-key secret --project-id project --topic topic --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreatePubsubFn: createGooglePubSubError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestGooglePubSubList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPubsubsFn: listGooglePubSubsOK, }, WantOutput: listGooglePubSubsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPubsubsFn: listGooglePubSubsOK, }, WantOutput: listGooglePubSubsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPubsubsFn: listGooglePubSubsOK, }, WantOutput: listGooglePubSubsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPubsubsFn: listGooglePubSubsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestGooglePubSubDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetPubsubFn: getGooglePubSubError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetPubsubFn: getGooglePubSubOK, }, WantOutput: describeGooglePubSubOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestGooglePubSubUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdatePubsubFn: updateGooglePubSubError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdatePubsubFn: updateGooglePubSubOK, }, WantOutput: "Updated Google Cloud Pub/Sub logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestGooglePubSubDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeletePubsubFn: deleteGooglePubSubError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeletePubsubFn: deleteGooglePubSubOK, }, WantOutput: "Deleted Google Cloud Pub/Sub logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createGooglePubSubOK(_ context.Context, 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(_ context.Context, _ *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { return nil, errTest } func listGooglePubSubsOK(_ context.Context, 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), ProcessingRegion: fastly.ToPointer("us"), }, { 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), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listGooglePubSubsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getGooglePubSubOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getGooglePubSubError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { return nil, errTest } func deleteGooglePubSubOK(_ context.Context, _ *fastly.DeletePubsubInput) error { return nil } func deleteGooglePubSubError(_ context.Context, _ *fastly.DeletePubsubInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/googlepubsub/googlepubsub_test.go ================================================ package googlepubsub_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *googlepubsub.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *googlepubsub.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/googlepubsub/list.go ================================================ package googlepubsub import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(googlepubsub.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/googlepubsub/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/googlepubsub/update.go ================================================ package googlepubsub import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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. logflags.AccountName(c.CmdClause, &c.AccountName) c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ Action: c.AutoClone.Set, Dst: &c.AutoClone.Value, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Google Cloud Pub/Sub") 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) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/create.go ================================================ package grafanacloudlogs import ( "context" "io" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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/v15/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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Grafana Cloud Logs") logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/delete.go ================================================ package grafanacloudlogs import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/describe.go ================================================ package grafanacloudlogs import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/doc.go ================================================ // Package grafanacloudlogs contains commands to inspect and manipulate Fastly service Grafana Cloud Logs // logging endpoints. package grafanacloudlogs ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/grafanacloud_logs_integration_test.go ================================================ package grafanacloudlogs_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/grafanacloudlogs" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestGrafanaCloudLogsCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --user 123456 --url https://test123.grafana.net --auth-token testtoken --index `{\"label\": \"value\" }` --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateGrafanaCloudLogsFn: createGrafanaCloudLogsOK, }, WantOutput: "Created Grafana Cloud Logs logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --url https://test123.grafana.net --auth-token testtoken --index `{\"label\": \"value\" }` --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateGrafanaCloudLogsFn: createGrafanaCloudLogsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestGrafanaCloudLogsList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, }, WantOutput: listGrafanaCloudLogsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, }, WantOutput: listGrafanaCloudLogsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, }, WantOutput: listGrafanaCloudLogsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListGrafanaCloudLogsFn: listGrafanaCloudLogsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestGrafanaCloudLogsDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGrafanaCloudLogsFn: getGrafanaCloudLogsError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGrafanaCloudLogsFn: getGrafanaCloudLogsOK, }, WantOutput: describeGrafanaCloudLogsOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestGrafanaCloudLogsUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateGrafanaCloudLogsFn: updateGrafanaCloudLogsError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateGrafanaCloudLogsFn: updateGrafanaCloudLogsOK, }, WantOutput: "Updated Grafana Cloud Logs logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestGrafanaCloudLogsDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteGrafanaCloudLogsFn: deleteGrafanaCloudLogsError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteGrafanaCloudLogsFn: deleteGrafanaCloudLogsOK, }, WantOutput: "Deleted Grafana Cloud Logs logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createGrafanaCloudLogsOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { return nil, errTest } func listGrafanaCloudLogsOK(_ context.Context, 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\"}"), ProcessingRegion: fastly.ToPointer("us"), }, { 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\"}"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listGrafanaCloudLogsError(_ context.Context, _ *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 (auth: 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"} Processing region: us 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"} Processing region: us `) + "\n\n" func getGrafanaCloudLogsOK(_ context.Context, 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\"}"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getGrafanaCloudLogsError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Token: testtoken URL: https://test123.grafana.net User: 123456 Version: 1 `) + "\n" func updateGrafanaCloudLogsOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { return nil, errTest } func deleteGrafanaCloudLogsOK(_ context.Context, _ *fastly.DeleteGrafanaCloudLogsInput) error { return nil } func deleteGrafanaCloudLogsError(_ context.Context, _ *fastly.DeleteGrafanaCloudLogsInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/grafanacloudlogs_test.go ================================================ package grafanacloudlogs_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *grafanacloudlogs.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *grafanacloudlogs.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/list.go ================================================ package grafanacloudlogs import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(grafanacloudlogs.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/grafanacloudlogs/update.go ================================================ package grafanacloudlogs import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Grafana Cloud Logs") logflags.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 Grafana 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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/heroku/create.go ================================================ package heroku import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Heroku") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/heroku/delete.go ================================================ package heroku import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/heroku/describe.go ================================================ package heroku import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/heroku/doc.go ================================================ // Package heroku contains commands to inspect and manipulate Fastly service Heroku // logging endpoints. package heroku ================================================ FILE: pkg/commands/service/logging/heroku/heroku_integration_test.go ================================================ package heroku_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/heroku" ) func TestHerokuCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --auth-token abc --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHerokuFn: createHerokuOK, }, WantOutput: "Created Heroku logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHerokuFn: createHerokuError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestHerokuList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHerokusFn: listHerokusOK, }, WantOutput: listHerokusShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHerokusFn: listHerokusOK, }, WantOutput: listHerokusVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHerokusFn: listHerokusOK, }, WantOutput: listHerokusVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHerokusFn: listHerokusError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestHerokuDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHerokuFn: getHerokuError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHerokuFn: getHerokuOK, }, WantOutput: describeHerokuOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestHerokuUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHerokuFn: updateHerokuError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHerokuFn: updateHerokuOK, }, WantOutput: "Updated Heroku logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestHerokuDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHerokuFn: deleteHerokuError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHerokuFn: deleteHerokuOK, }, WantOutput: "Deleted Heroku logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createHerokuOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateHerokuInput) (*fastly.Heroku, error) { return nil, errTest } func listHerokusOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listHerokusError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getHerokuOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getHerokuError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Token: abc URL: example.com Version: 1 `) + "\n" func updateHerokuOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { return nil, errTest } func deleteHerokuOK(_ context.Context, _ *fastly.DeleteHerokuInput) error { return nil } func deleteHerokuError(_ context.Context, _ *fastly.DeleteHerokuInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/heroku/heroku_test.go ================================================ package heroku_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *heroku.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *heroku.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/heroku/list.go ================================================ package heroku import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(heroku.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/heroku/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/heroku/update.go ================================================ package heroku import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString Token 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 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Heroku") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/honeycomb/create.go ================================================ package honeycomb import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Honeycomb") 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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/honeycomb/delete.go ================================================ package honeycomb import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/honeycomb/describe.go ================================================ package honeycomb import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/honeycomb/doc.go ================================================ // Package honeycomb contains commands to inspect and manipulate Fastly service Honeycomb // logging endpoints. package honeycomb ================================================ FILE: pkg/commands/service/logging/honeycomb/honeycomb_integration_test.go ================================================ package honeycomb_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestHoneycombCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --auth-token abc --dataset log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHoneycombFn: createHoneycombOK, }, WantOutput: "Created Honeycomb logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --dataset log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHoneycombFn: createHoneycombError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestHoneycombList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHoneycombsFn: listHoneycombsOK, }, WantOutput: listHoneycombsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHoneycombsFn: listHoneycombsOK, }, WantOutput: listHoneycombsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHoneycombsFn: listHoneycombsOK, }, WantOutput: listHoneycombsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHoneycombsFn: listHoneycombsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestHoneycombDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHoneycombFn: getHoneycombError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHoneycombFn: getHoneycombOK, }, WantOutput: describeHoneycombOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestHoneycombUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHoneycombFn: updateHoneycombError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHoneycombFn: updateHoneycombOK, }, WantOutput: "Updated Honeycomb logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestHoneycombDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHoneycombFn: deleteHoneycombError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHoneycombFn: deleteHoneycombOK, }, WantOutput: "Deleted Honeycomb logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createHoneycombOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { return nil, errTest } func listHoneycombsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listHoneycombsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getHoneycombOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getHoneycombError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Token: tkn Version: 1 `) + "\n" func updateHoneycombOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { return nil, errTest } func deleteHoneycombOK(_ context.Context, _ *fastly.DeleteHoneycombInput) error { return nil } func deleteHoneycombError(_ context.Context, _ *fastly.DeleteHoneycombInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/honeycomb/honeycomb_test.go ================================================ package honeycomb_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *honeycomb.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *honeycomb.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/honeycomb/list.go ================================================ package honeycomb import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(honeycomb.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/honeycomb/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/honeycomb/update.go ================================================ package honeycomb import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Dataset argparser.OptionalString Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString Token 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Honeycomb") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/https/create.go ================================================ package https import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 CompressionCodec argparser.OptionalString ContentType argparser.OptionalString EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). Format argparser.OptionalString FormatVersion argparser.OptionalInt GzipLevel argparser.OptionalInt HeaderName argparser.OptionalString HeaderValue argparser.OptionalString JSONFormat argparser.OptionalString MessageType argparser.OptionalString Method argparser.OptionalString Period argparser.OptionalInt Placement argparser.OptionalString ProcessingRegion 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) 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) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "HTTPS") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 // 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") } 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.CompressionCodec.WasSet { input.CompressionCodec = &c.CompressionCodec.Value } if c.ContentType.WasSet { input.ContentType = &c.ContentType.Value } if c.GzipLevel.WasSet { input.GzipLevel = &c.GzipLevel.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.Period.WasSet { input.Period = &c.Period.Value } if c.Placement.WasSet { input.Placement = &c.Placement.Value } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/https/delete.go ================================================ package https import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/https/describe.go ================================================ package https import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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{ "Compression codec": fastly.ToValue(o.CompressionCodec), "Content type": fastly.ToValue(o.ContentType), "Format version": fastly.ToValue(o.FormatVersion), "Format": fastly.ToValue(o.Format), "GZip level": fastly.ToValue(o.GzipLevel), "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), "Period": fastly.ToValue(o.Period), "Placement": fastly.ToValue(o.Placement), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/https/doc.go ================================================ // Package https contains commands to inspect and manipulate Fastly service HTTPS // logging endpoints. package https ================================================ FILE: pkg/commands/service/logging/https/https_integration_test.go ================================================ package https_test import ( "context" "errors" "net/http" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/https" ) func TestHTTPSCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHTTPSFn: createHTTPSOK, }, WantOutput: "Created HTTPS logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateHTTPSFn: createHTTPSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name log --url example.com --compression-codec zstd --gzip-level 9 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestHTTPSList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHTTPSFn: listHTTPSsOK, }, WantOutput: listHTTPSsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHTTPSFn: listHTTPSsOK, }, WantOutput: listHTTPSsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHTTPSFn: listHTTPSsOK, }, WantOutput: listHTTPSsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListHTTPSFn: listHTTPSsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestHTTPSDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHTTPSFn: getHTTPSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetHTTPSFn: getHTTPSOK, }, WantOutput: describeHTTPSOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestHTTPSUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHTTPSFn: updateHTTPSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateHTTPSFn: updateHTTPSOK, }, WantOutput: "Updated HTTPS logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestHTTPSDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHTTPSFn: deleteHTTPSError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteHTTPSFn: deleteHTTPSOK, }, WantOutput: "Deleted HTTPS logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createHTTPSOK(_ context.Context, 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), CompressionCodec: fastly.ToPointer(""), ContentType: fastly.ToPointer("application/json"), GzipLevel: fastly.ToPointer(0), HeaderName: fastly.ToPointer("name"), HeaderValue: fastly.ToPointer("value"), Method: fastly.ToPointer(http.MethodGet), JSONFormat: fastly.ToPointer("1"), Period: fastly.ToPointer(0), 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(_ context.Context, _ *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { return nil, errTest } func listHTTPSsOK(_ context.Context, 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), CompressionCodec: fastly.ToPointer(""), ContentType: fastly.ToPointer("application/json"), GzipLevel: fastly.ToPointer(0), HeaderName: fastly.ToPointer("name"), HeaderValue: fastly.ToPointer("value"), Method: fastly.ToPointer(http.MethodGet), JSONFormat: fastly.ToPointer("1"), Period: fastly.ToPointer(0), 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), ProcessingRegion: fastly.ToPointer("us"), }, { 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), CompressionCodec: fastly.ToPointer(""), ContentType: fastly.ToPointer("application/json"), GzipLevel: fastly.ToPointer(0), HeaderName: fastly.ToPointer("name"), HeaderValue: fastly.ToPointer("value"), Method: fastly.ToPointer(http.MethodGet), JSONFormat: fastly.ToPointer("1"), Period: fastly.ToPointer(0), 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), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listHTTPSsError(_ context.Context, _ *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 (auth: user) Service ID (via --service-id): 123 Version: 1 HTTPS 1/2 Service ID: 123 Version: 1 Name: logs URL: example.com Compression codec: Content type: application/json GZip level: 0 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 Period: 0 Placement: none Processing region: us HTTPS 2/2 Service ID: 123 Version: 1 Name: analytics URL: analytics.example.com Compression codec: Content type: application/json GZip level: 0 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 Period: 0 Placement: none Processing region: us `) + "\n\n" func getHTTPSOK(_ context.Context, 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), CompressionCodec: fastly.ToPointer(""), ContentType: fastly.ToPointer("application/json"), GzipLevel: fastly.ToPointer(0), HeaderName: fastly.ToPointer("name"), HeaderValue: fastly.ToPointer("value"), Method: fastly.ToPointer(http.MethodGet), JSONFormat: fastly.ToPointer("1"), Period: fastly.ToPointer(0), 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), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getHTTPSError(_ context.Context, _ *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { return nil, errTest } var describeHTTPSOutput = "\n" + strings.TrimSpace(` Compression codec: Content type: application/json Format: %h %l %u %t "%r" %>s %b Format version: 2 GZip level: 0 Header name: name Header value: value JSON format: 1 Message type: classic Method: GET Name: log Period: 0 Placement: none Processing region: us 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(_ context.Context, 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), CompressionCodec: fastly.ToPointer(""), ContentType: fastly.ToPointer("application/json"), GzipLevel: fastly.ToPointer(7), HeaderName: fastly.ToPointer("name"), HeaderValue: fastly.ToPointer("value"), Method: fastly.ToPointer(http.MethodGet), JSONFormat: fastly.ToPointer("1"), Period: fastly.ToPointer(0), 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(_ context.Context, _ *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { return nil, errTest } func deleteHTTPSOK(_ context.Context, _ *fastly.DeleteHTTPSInput) error { return nil } func deleteHTTPSError(_ context.Context, _ *fastly.DeleteHTTPSInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/https/https_test.go ================================================ package https_test import ( "bytes" "net/http" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), Period: fastly.ToPointer(5), 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), ProcessingRegion: fastly.ToPointer("eu"), CompressionCodec: fastly.ToPointer("zstd"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), Period: fastly.ToPointer(5), 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), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 5}, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, } } func createCommandMissingServiceID() *https.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 5}, 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}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, } } func updateCommandMissingServiceID() *https.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/https/list.go ================================================ package https import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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\tCompression codec: %s\n", fastly.ToValue(https.CompressionCodec)) fmt.Fprintf(out, "\t\tContent type: %s\n", fastly.ToValue(https.ContentType)) fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(https.GzipLevel)) 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\tPeriod: %d\n", fastly.ToValue(https.Period)) fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(https.Placement)) fmt.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(https.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/https/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/https/update.go ================================================ package https import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 CompressionCodec argparser.OptionalString ContentType argparser.OptionalString Format argparser.OptionalString FormatVersion argparser.OptionalInt GzipLevel argparser.OptionalInt HeaderName argparser.OptionalString HeaderValue argparser.OptionalString JSONFormat argparser.OptionalString MessageType argparser.OptionalString Method argparser.OptionalString NewName argparser.OptionalString Period argparser.OptionalInt Placement argparser.OptionalString ProcessingRegion 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 } // 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "HTTPS") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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.Period.WasSet { input.Period = &c.Period.Value } if c.Placement.WasSet { input.Placement = &c.Placement.Value } if c.MessageType.WasSet { input.MessageType = &c.MessageType.Value } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/kafka/create.go ================================================ package kafka import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Kafka") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/kafka/delete.go ================================================ package kafka import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/kafka/describe.go ================================================ package kafka import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/kafka/doc.go ================================================ // Package kafka contains commands to inspect and manipulate Fastly service Kafka // logging endpoints. package kafka ================================================ FILE: pkg/commands/service/logging/kafka/kafka_integration_test.go ================================================ package kafka_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/kafka" ) func TestKafkaCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKafkaFn: createKafkaOK, }, WantOutput: "Created Kafka logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --topic logs --brokers 127.0.0.1127.0.0.2 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKafkaFn: createKafkaError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestKafkaList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKafkasFn: listKafkasOK, }, WantOutput: listKafkasShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKafkasFn: listKafkasOK, }, WantOutput: listKafkasVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKafkasFn: listKafkasOK, }, WantOutput: listKafkasVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKafkasFn: listKafkasError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestKafkaDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetKafkaFn: getKafkaError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetKafkaFn: getKafkaOK, }, WantOutput: describeKafkaOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestKafkaUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateKafkaFn: updateKafkaError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateKafkaFn: updateKafkaOK, }, WantOutput: "Updated Kafka logging endpoint log (service 123 version 4)", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateKafkaFn: updateKafkaSASL, }, WantOutput: "Updated Kafka logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestKafkaDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteKafkaFn: deleteKafkaError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteKafkaFn: deleteKafkaOK, }, WantOutput: "Deleted Kafka logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createKafkaOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateKafkaInput) (*fastly.Kafka, error) { return nil, errTest } func listKafkasOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listKafkasError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getKafkaOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), 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(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { return nil, errTest } func deleteKafkaOK(_ context.Context, _ *fastly.DeleteKafkaInput) error { return nil } func deleteKafkaError(_ context.Context, _ *fastly.DeleteKafkaInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/kafka/kafka_test.go ================================================ package kafka_test import ( "bytes" "context" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } 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{ GetVersionFn: testutil.GetVersion, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } func getKafkaSASL(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/logging/kafka/list.go ================================================ package kafka import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(kafka.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/kafka/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/kafka/update.go ================================================ package kafka import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. AuthMethod argparser.OptionalString AutoClone argparser.OptionalAutoClone Brokers argparser.OptionalString CompressionCodec argparser.OptionalString Format argparser.OptionalString FormatVersion argparser.OptionalInt Index argparser.OptionalString NewName argparser.OptionalString ParseLogKeyvals argparser.OptionalBool Password argparser.OptionalString Placement argparser.OptionalString ProcessingRegion 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 UseSASL argparser.OptionalBool UseTLS argparser.OptionalBool 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 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Kafka") 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) logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/kinesis/create.go ================================================ package kinesis import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) c.CmdClause.Flag("region", "The region where logs are received and stored by Kinesis").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Kinesis") 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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/kinesis/delete.go ================================================ package kinesis import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/kinesis/describe.go ================================================ package kinesis import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/kinesis/doc.go ================================================ // Package kinesis contains commands to inspect and manipulate Fastly service Kinesis // logging endpoints. package kinesis ================================================ FILE: pkg/commands/service/logging/kinesis/kinesis_integration_test.go ================================================ package kinesis_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/kinesis" ) func TestKinesisCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--service-id 123 --version 1 --name log --stream-name log --access-key foo --secret-key bar --region us-east-1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKinesisFn: createKinesisOK, }, WantOutput: "Created Kinesis logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --stream-name log --access-key foo --secret-key bar --region us-east-1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKinesisFn: createKinesisError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKinesisFn: createKinesisOK, }, WantOutput: "Created Kinesis logging endpoint log2 (service 123 version 4)", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateKinesisFn: createKinesisError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestKinesisList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKinesisFn: listKinesesOK, }, WantOutput: listKinesesShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKinesisFn: listKinesesOK, }, WantOutput: listKinesesVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKinesisFn: listKinesesOK, }, WantOutput: listKinesesVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListKinesisFn: listKinesesError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestKinesisDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetKinesisFn: getKinesisError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetKinesisFn: getKinesisOK, }, WantOutput: describeKinesisOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestKinesisUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateKinesisFn: updateKinesisError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --region us-west-1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateKinesisFn: updateKinesisOK, }, WantOutput: "Updated Kinesis logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestKinesisDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteKinesisFn: deleteKinesisError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteKinesisFn: deleteKinesisOK, }, WantOutput: "Deleted Kinesis logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createKinesisOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { return nil, errTest } func listKinesesOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listKinesesError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getKinesisOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getKinesisError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { return nil, errTest } func deleteKinesisOK(_ context.Context, _ *fastly.DeleteKinesisInput) error { return nil } func deleteKinesisError(_ context.Context, _ *fastly.DeleteKinesisInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/kinesis/kinesis_test.go ================================================ package kinesis_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *kinesis.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *kinesis.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/kinesis/list.go ================================================ package kinesis import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(kinesis.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/kinesis/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/kinesis/update.go ================================================ package kinesis import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. AccessKey argparser.OptionalString AutoClone argparser.OptionalAutoClone Format argparser.OptionalString FormatVersion argparser.OptionalInt IAMRole argparser.OptionalString NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString Region argparser.OptionalString ResponseCondition argparser.OptionalString SecretKey argparser.OptionalString StreamName 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Kinesis") c.CmdClause.Flag("region", "The region where logs are received and stored by Kinesis").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/logflags/doc.go ================================================ // Package logflags contains common flags used in the logging commands package logflags ================================================ FILE: pkg/commands/service/logging/logflags/flags.go ================================================ package logflags 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) } // ProcessingRegion defines the processing-region flag. func ProcessingRegion(command *kingpin.CmdClause, c *argparser.OptionalString, endpoint string) { command.Flag("processing-region", "The region where logs will be processed before streaming to "+endpoint+". One of 'none', 'eu', or 'us'. Defaults to 'none' for no specific region").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) } ================================================ FILE: pkg/commands/service/logging/loggly/create.go ================================================ package loggly import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Loggly") 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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/loggly/delete.go ================================================ package loggly import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/loggly/describe.go ================================================ package loggly import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/loggly/doc.go ================================================ // Package loggly contains commands to inspect and manipulate Fastly service Loggly // logging endpoints. package loggly ================================================ FILE: pkg/commands/service/logging/loggly/list.go ================================================ package loggly import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(loggly.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/loggly/loggly_integration_test.go ================================================ package loggly_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/loggly" ) func TestLogglyCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateLogglyFn: createLogglyOK, }, WantOutput: "Created Loggly logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateLogglyFn: createLogglyError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestLogglyList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogglyFn: listLogglysOK, }, WantOutput: listLogglysShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogglyFn: listLogglysOK, }, WantOutput: listLogglysVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogglyFn: listLogglysOK, }, WantOutput: listLogglysVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogglyFn: listLogglysError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestLogglyDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetLogglyFn: getLogglyError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetLogglyFn: getLogglyOK, }, WantOutput: describeLogglyOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestLogglyUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateLogglyFn: updateLogglyError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateLogglyFn: updateLogglyOK, }, WantOutput: "Updated Loggly logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestLogglyDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteLogglyFn: deleteLogglyError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteLogglyFn: deleteLogglyOK, }, WantOutput: "Deleted Loggly logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createLogglyOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateLogglyInput) (*fastly.Loggly, error) { return nil, errTest } func listLogglysOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listLogglysError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getLogglyOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getLogglyError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Token: abc Version: 1 `) + "\n" func updateLogglyOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { return nil, errTest } func deleteLogglyOK(_ context.Context, _ *fastly.DeleteLogglyInput) error { return nil } func deleteLogglyError(_ context.Context, _ *fastly.DeleteLogglyInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/loggly/loggly_test.go ================================================ package loggly_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } 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{ GetVersionFn: testutil.GetVersion, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *loggly.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/loggly/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/loggly/update.go ================================================ package loggly import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString Token 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Loggly") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/logshuttle/create.go ================================================ package logshuttle import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Logshuttle") 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 Logshuttle 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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/logshuttle/delete.go ================================================ package logshuttle import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/logshuttle/describe.go ================================================ package logshuttle import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/logshuttle/doc.go ================================================ // Package logshuttle contains commands to inspect and manipulate Fastly service Logshuttle // logging endpoints. package logshuttle ================================================ FILE: pkg/commands/service/logging/logshuttle/list.go ================================================ package logshuttle import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(logshuttle.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/logshuttle/logshuttle_integration_test.go ================================================ package logshuttle_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" ) func TestLogshuttleCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --url example.com --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateLogshuttleFn: createLogshuttleOK, }, WantOutput: "Created Logshuttle logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --url example.com --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateLogshuttleFn: createLogshuttleError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestLogshuttleList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogshuttlesFn: listLogshuttlesOK, }, WantOutput: listLogshuttlesShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogshuttlesFn: listLogshuttlesOK, }, WantOutput: listLogshuttlesVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogshuttlesFn: listLogshuttlesOK, }, WantOutput: listLogshuttlesVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListLogshuttlesFn: listLogshuttlesError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestLogshuttleDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetLogshuttleFn: getLogshuttleError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetLogshuttleFn: getLogshuttleOK, }, WantOutput: describeLogshuttleOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestLogshuttleUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateLogshuttleFn: updateLogshuttleError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateLogshuttleFn: updateLogshuttleOK, }, WantOutput: "Updated Logshuttle logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestLogshuttleDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteLogshuttleFn: deleteLogshuttleError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteLogshuttleFn: deleteLogshuttleOK, }, WantOutput: "Deleted Logshuttle logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createLogshuttleOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { return nil, errTest } func listLogshuttlesOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listLogshuttlesError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getLogshuttleOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getLogshuttleError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Token: abc URL: example.com Version: 1 `) + "\n" func updateLogshuttleOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { return nil, errTest } func deleteLogshuttleOK(_ context.Context, _ *fastly.DeleteLogshuttleInput) error { return nil } func deleteLogshuttleError(_ context.Context, _ *fastly.DeleteLogshuttleInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/logshuttle/logshuttle_test.go ================================================ package logshuttle_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *logshuttle.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *logshuttle.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/logshuttle/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/logshuttle/update.go ================================================ package logshuttle import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString Token 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 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Logshuttle") logflags.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 Logshuttle 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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/newrelic/create.go ================================================ package newrelic import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 processingregion 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, }) logflags.Format(c.CmdClause, &c.format) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.processingregion, "New Relic") c.CmdClause.Flag("region", "The region where logs are received and stored by New Relic").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(context.TODO(), 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.processingregion.WasSet { input.ProcessingRegion = &c.processingregion.Value } if c.region.WasSet { input.Region = &c.region.Value } if c.responseCondition.WasSet { input.ResponseCondition = &c.responseCondition.Value } return &input } ================================================ FILE: pkg/commands/service/logging/newrelic/delete.go ================================================ package newrelic import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/newrelic/describe.go ================================================ package newrelic import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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), "Processing region": fastly.ToValue(nr.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/newrelic/doc.go ================================================ // Package newrelic contains commands to inspect and manipulate NewRelic logging // endpoints. package newrelic ================================================ FILE: pkg/commands/service/logging/newrelic/list.go ================================================ package newrelic import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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, "\nProcessing region: %s\n", fastly.ToValue(l.ProcessingRegion)) 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 } ================================================ FILE: pkg/commands/service/logging/newrelic/newrelic_test.go ================================================ package newrelic_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/fastly" serviceRoot "github.com/fastly/cli/pkg/commands/service" loggingRoot "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate CreateNewRelic API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateNewRelicFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateNewRelicFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateNewRelicFn: func(_ context.Context, 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{serviceRoot.CommandName, loggingRoot.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteNewRelic API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteNewRelicFn: func(_ context.Context, _ *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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicFn: func(_ context.Context, i *fastly.DeleteNewRelicInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicFn: func(_ context.Context, i *fastly.DeleteNewRelicInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicFn: func(_ context.Context, _ *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)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicFn: func(_ context.Context, i *fastly.DeleteNewRelicInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 2", WantOutput: "Deleted New Relic logging endpoint 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicFn: func(_ context.Context, i *fastly.DeleteNewRelicInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 3", WantOutput: "Deleted New Relic logging endpoint 'foo' (service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{serviceRoot.CommandName, loggingRoot.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{ GetVersionFn: testutil.GetVersion, GetNewRelicFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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: \nProcessing region: \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{ GetVersionFn: testutil.GetVersion, 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: \nProcessing region: \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{serviceRoot.CommandName, loggingRoot.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{ GetVersionFn: testutil.GetVersion, ListNewRelicFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, ListNewRelicFn: listNewRelic, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: 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\nProcessing region: \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\nProcessing region: \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{serviceRoot.CommandName, loggingRoot.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate UpdateNewRelic API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateNewRelicFn: func(_ context.Context, 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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicFn: func(_ context.Context, i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicFn: func(_ context.Context, i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicFn: func(_ context.Context, 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)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicFn: func(_ context.Context, i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 2", WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicFn: func(_ context.Context, i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 3", WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{serviceRoot.CommandName, loggingRoot.CommandName, sub.CommandName, "update"}, scenarios) } func getNewRelic(_ context.Context, 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(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/logging/newrelic/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/newrelic/update.go ================================================ package newrelic import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 processingregion 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, }) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.processingregion, "New Relic") c.CmdClause.Flag("region", "The region where logs are received and stored by New Relic").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(context.TODO(), 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.processingregion.WasSet { input.ProcessingRegion = &c.processingregion.Value } if c.region.WasSet { input.Region = &c.region.Value } if c.responseCondition.WasSet { input.ResponseCondition = &c.responseCondition.Value } return &input } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/create.go ================================================ package newrelicotlp import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 processingregion 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, }) logflags.Format(c.CmdClause, &c.format) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.processingregion, "New Relic") 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(context.TODO(), 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.processingregion.WasSet { input.ProcessingRegion = &c.processingregion.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 } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/delete.go ================================================ package newrelicotlp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/describe.go ================================================ package newrelicotlp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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), "Processing region": fastly.ToValue(nr.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/doc.go ================================================ // Package newrelicotlp contains commands to inspect and manipulate NewRelicOTLP logging // endpoints. package newrelicotlp ================================================ FILE: pkg/commands/service/logging/newrelicotlp/list.go ================================================ package newrelicotlp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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, "\nProcessing region: %s\n", fastly.ToValue(l.ProcessingRegion)) 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 } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/newrelicotlp_test.go ================================================ package newrelicotlp_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/fastly" serviceRoot "github.com/fastly/cli/pkg/commands/service" loggingRoot "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate CreateNewRelicOTLP API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateNewRelicOTLPFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateNewRelicOTLPFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateNewRelicOTLPFn: func(_ context.Context, 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{serviceRoot.CommandName, loggingRoot.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteNewRelic API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicOTLPFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteNewRelicOTLPFn: func(_ context.Context, _ *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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicOTLPFn: func(_ context.Context, i *fastly.DeleteNewRelicOTLPInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteNewRelicOTLPFn: func(_ context.Context, i *fastly.DeleteNewRelicOTLPInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicOTLPFn: func(_ context.Context, _ *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)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicOTLPFn: func(_ context.Context, i *fastly.DeleteNewRelicOTLPInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 2", WantOutput: "Deleted New Relic OTLP logging endpoint 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteNewRelicOTLPFn: func(_ context.Context, i *fastly.DeleteNewRelicOTLPInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 3", WantOutput: "Deleted New Relic OTLP logging endpoint 'foo' (service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{serviceRoot.CommandName, loggingRoot.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{ GetVersionFn: testutil.GetVersion, GetNewRelicOTLPFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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: \nProcessing region: \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{ GetVersionFn: testutil.GetVersion, 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: \nProcessing region: \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{serviceRoot.CommandName, loggingRoot.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{ GetVersionFn: testutil.GetVersion, ListNewRelicOTLPFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, ListNewRelicOTLPFn: listNewRelic, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: 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\nProcessing region: \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\nProcessing region: \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{serviceRoot.CommandName, loggingRoot.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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate UpdateNewRelic API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicOTLPFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateNewRelicOTLPFn: func(_ context.Context, 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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicOTLPFn: func(_ context.Context, i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateNewRelicOTLPFn: func(_ context.Context, i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --new-name beepboop --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicOTLPFn: func(_ context.Context, 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)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicOTLPFn: func(_ context.Context, i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 2", WantOutput: "Updated New Relic OTLP logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateNewRelicOTLPFn: func(_ context.Context, i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } 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 3", WantOutput: "Updated New Relic OTLP logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{serviceRoot.CommandName, loggingRoot.CommandName, sub.CommandName, "update"}, scenarios) } func getNewRelic(_ context.Context, 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(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/newrelicotlp/update.go ================================================ package newrelicotlp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 processingregion 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, }) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.processingregion, "New Relic") 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(context.TODO(), 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.processingregion.WasSet { input.ProcessingRegion = &c.processingregion.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 } ================================================ FILE: pkg/commands/service/logging/openstack/create.go ================================================ package openstack import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "OpenStack") logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/openstack/delete.go ================================================ package openstack import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/openstack/describe.go ================================================ package openstack import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/openstack/doc.go ================================================ // Package openstack contains commands to inspect and manipulate Fastly service OpenStack // logging endpoints. package openstack ================================================ FILE: pkg/commands/service/logging/openstack/list.go ================================================ package openstack import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(openstack.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/openstack/openstack_integration_test.go ================================================ package openstack_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/openstack" ) func TestOpenstackCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --user user --url https://example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateOpenstackFn: createOpenstackOK, }, WantOutput: "Created OpenStack logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --user user --url https://example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateOpenstackFn: createOpenstackError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestOpenstackList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListOpenstacksFn: listOpenstacksOK, }, WantOutput: listOpenstacksShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListOpenstacksFn: listOpenstacksOK, }, WantOutput: listOpenstacksVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListOpenstacksFn: listOpenstacksOK, }, WantOutput: listOpenstacksVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListOpenstacksFn: listOpenstacksError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestOpenstackDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetOpenstackFn: getOpenstackError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetOpenstackFn: getOpenstackOK, }, WantOutput: describeOpenstackOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestOpenstackUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateOpenstackFn: updateOpenstackError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateOpenstackFn: updateOpenstackOK, }, WantOutput: "Updated OpenStack logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestOpenstackDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteOpenstackFn: deleteOpenstackError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteOpenstackFn: deleteOpenstackOK, }, WantOutput: "Deleted OpenStack logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createOpenstackOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { return nil, errTest } func listOpenstacksOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listOpenstacksError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getOpenstackOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getOpenstackError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { return nil, errTest } func deleteOpenstackOK(_ context.Context, _ *fastly.DeleteOpenstackInput) error { return nil } func deleteOpenstackError(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/openstack/openstack_test.go ================================================ package openstack_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *openstack.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *openstack.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/openstack/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/openstack/update.go ================================================ package openstack import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. AccessKey argparser.OptionalString AutoClone argparser.OptionalAutoClone BucketName 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 ProcessingRegion argparser.OptionalString PublicKey argparser.OptionalString ResponseCondition argparser.OptionalString TimestampFormat argparser.OptionalString URL 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 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "OpenStack") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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, }) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/papertrail/create.go ================================================ package papertrail import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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 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, }) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Format(c.CmdClause, &c.Format) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Papertrail") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/papertrail/delete.go ================================================ package papertrail import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/papertrail/describe.go ================================================ package papertrail import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/papertrail/doc.go ================================================ // Package papertrail contains commands to inspect and manipulate Fastly service Papertrail // logging endpoints. package papertrail ================================================ FILE: pkg/commands/service/logging/papertrail/list.go ================================================ package papertrail import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(papertrail.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/papertrail/papertrail_integration_test.go ================================================ package papertrail_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/papertrail" ) func TestPapertrailCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --address example.com:123 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreatePapertrailFn: createPapertrailOK, }, WantOutput: "Created Papertrail logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --address example.com:123 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreatePapertrailFn: createPapertrailError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestPapertrailList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPapertrailsFn: listPapertrailsOK, }, WantOutput: listPapertrailsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPapertrailsFn: listPapertrailsOK, }, WantOutput: listPapertrailsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPapertrailsFn: listPapertrailsOK, }, WantOutput: listPapertrailsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListPapertrailsFn: listPapertrailsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestPapertrailDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetPapertrailFn: getPapertrailError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetPapertrailFn: getPapertrailOK, }, WantOutput: describePapertrailOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestPapertrailUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdatePapertrailFn: updatePapertrailError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdatePapertrailFn: updatePapertrailOK, }, WantOutput: "Updated Papertrail logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestPapertrailDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeletePapertrailFn: deletePapertrailError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeletePapertrailFn: deletePapertrailOK, }, WantOutput: "Deleted Papertrail logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createPapertrailOK(_ context.Context, 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(_ context.Context, _ *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { return nil, errTest } func listPapertrailsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listPapertrailsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getPapertrailOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getPapertrailError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 Version: 1 `) + "\n" func updatePapertrailOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { return nil, errTest } func deletePapertrailOK(_ context.Context, _ *fastly.DeletePapertrailInput) error { return nil } func deletePapertrailError(_ context.Context, _ *fastly.DeletePapertrailInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/papertrail/papertrail_test.go ================================================ package papertrail_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *papertrail.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *papertrail.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/papertrail/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/papertrail/update.go ================================================ package papertrail import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. Address argparser.OptionalString AutoClone argparser.OptionalAutoClone Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString Port argparser.OptionalInt ProcessingRegion 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 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Papertrail") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/s3/create.go ================================================ package s3 import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "S3") logflags.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)) logflags.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, }) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/s3/delete.go ================================================ package s3 import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/s3/describe.go ================================================ package s3 import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/s3/doc.go ================================================ // Package s3 contains commands to inspect and manipulate Fastly service S3 // logging endpoints. package s3 ================================================ FILE: pkg/commands/service/logging/s3/list.go ================================================ package s3 import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(s3.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/s3/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/s3/s3_integration_test.go ================================================ package s3_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/s3" ) func TestS3Create(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --bucket log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided", }, { Args: "--service-id 123 --version 1 --name log --bucket log --secret-key bar --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", }, { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key bar --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateS3Fn: createS3OK, }, WantOutput: "Created S3 logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key bar --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateS3Fn: createS3Error, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name log2 --bucket log --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateS3Fn: createS3OK, }, WantOutput: "Created S3 logging endpoint log2 (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log2 --bucket log --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateS3Fn: createS3Error, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestS3List(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListS3sFn: listS3sOK, }, WantOutput: listS3sShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListS3sFn: listS3sOK, }, WantOutput: listS3sVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListS3sFn: listS3sOK, }, WantOutput: listS3sVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListS3sFn: listS3sError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestS3Describe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetS3Fn: getS3Error, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetS3Fn: getS3OK, }, WantOutput: describeS3Output, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestS3Update(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateS3Fn: updateS3Error, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateS3Fn: updateS3OK, }, WantOutput: "Updated S3 logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestS3Delete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteS3Fn: deleteS3Error, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteS3Fn: deleteS3OK, }, WantOutput: "Deleted S3 logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createS3OK(_ context.Context, 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(_ context.Context, _ *fastly.CreateS3Input) (*fastly.S3, error) { return nil, errTest } func listS3sOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listS3sError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getS3OK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getS3Error(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateS3Input) (*fastly.S3, error) { return nil, errTest } func deleteS3OK(_ context.Context, _ *fastly.DeleteS3Input) error { return nil } func deleteS3Error(_ context.Context, _ *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----- `) } ================================================ FILE: pkg/commands/service/logging/s3/s3_test.go ================================================ package s3_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *s3.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *s3.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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) } }) } } ================================================ FILE: pkg/commands/service/logging/s3/update.go ================================================ package s3 import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. AccessKey argparser.OptionalString Address argparser.OptionalString AutoClone argparser.OptionalAutoClone BucketName argparser.OptionalString CompressionCodec argparser.OptionalString Domain argparser.OptionalString FileMaxBytes argparser.OptionalInt Format argparser.OptionalString FormatVersion argparser.OptionalInt GzipLevel argparser.OptionalInt IAMRole argparser.OptionalString MessageType argparser.OptionalString NewName argparser.OptionalString Path argparser.OptionalString Period argparser.OptionalInt Placement argparser.OptionalString ProcessingRegion argparser.OptionalString PublicKey argparser.OptionalString Redundancy argparser.OptionalString ResponseCondition argparser.OptionalString SecretKey argparser.OptionalString ServerSideEncryption argparser.OptionalString ServerSideEncryptionKMSKeyID argparser.OptionalString TimestampFormat 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) logflags.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) logflags.Path(c.CmdClause, &c.Path) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "S3") logflags.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)) logflags.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, }) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/scalyr/create.go ================================================ package scalyr import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Scalyr") 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 where logs are received and stored by Scalyr. Either US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/scalyr/delete.go ================================================ package scalyr import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/scalyr/describe.go ================================================ package scalyr import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/scalyr/doc.go ================================================ // Package scalyr contains commands to inspect and manipulate Fastly service Scalyr // logging endpoints. package scalyr ================================================ FILE: pkg/commands/service/logging/scalyr/list.go ================================================ package scalyr import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(scalyr.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/scalyr/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/scalyr/scalyr_integration_test.go ================================================ package scalyr_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" fsterrs "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/scalyr" ) func TestScalyrCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--name log --version 1 --auth-token abc --autoclone", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: fsterrs.ErrNoServiceID.Error(), }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateScalyrFn: createScalyrOK, }, WantOutput: "Created Scalyr logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --auth-token abc --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateScalyrFn: createScalyrError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestScalyrList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListScalyrsFn: listScalyrsOK, }, WantOutput: listScalyrsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListScalyrsFn: listScalyrsOK, }, WantOutput: listScalyrsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListScalyrsFn: listScalyrsOK, }, WantOutput: listScalyrsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListScalyrsFn: listScalyrsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestScalyrDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetScalyrFn: getScalyrError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetScalyrFn: getScalyrOK, }, WantOutput: describeScalyrOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestScalyrUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateScalyrFn: updateScalyrError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateScalyrFn: updateScalyrOK, }, WantOutput: "Updated Scalyr logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestScalyrDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteScalyrFn: deleteScalyrError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteScalyrFn: deleteScalyrOK, }, WantOutput: "Deleted Scalyr logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createScalyrOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { return nil, errTest } func listScalyrsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listScalyrsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getScalyrOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getScalyrError(_ context.Context, _ *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 Processing region: us Project ID: example-project Region: US Response condition: Prevent default logging Service ID: 123 Token: abc Version: 1 `) + "\n" func updateScalyrOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { return nil, errTest } func deleteScalyrOK(_ context.Context, _ *fastly.DeleteScalyrInput) error { return nil } func deleteScalyrError(_ context.Context, _ *fastly.DeleteScalyrInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/scalyr/scalyr_test.go ================================================ package scalyr_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *scalyr.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *scalyr.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/scalyr/update.go ================================================ package scalyr import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ProjectID argparser.OptionalString Region argparser.OptionalString ResponseCondition argparser.OptionalString Token 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Scalyr") 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 where logs are received and stored by Scalyr. Either US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/sftp/create.go ================================================ package sftp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion argparser.OptionalString 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) logflags.CompressionCodec(c.CmdClause, &c.CompressionCodec) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "SFTP") c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/sftp/delete.go ================================================ package sftp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/sftp/describe.go ================================================ package sftp import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/sftp/doc.go ================================================ // Package sftp contains commands to inspect and manipulate Fastly service SFTP // logging endpoints. package sftp ================================================ FILE: pkg/commands/service/logging/sftp/list.go ================================================ package sftp import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(sftp.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/sftp/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/sftp/sftp_integration_test.go ================================================ package sftp_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/sftp" ) func TestSFTPCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --address example.com --user user --ssh-known-hosts knownHosts() --port 80 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSFTPFn: createSFTPOK, }, WantOutput: "Created SFTP logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --address example.com --user user --ssh-known-hosts knownHosts() --port 80 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSFTPFn: createSFTPError, }, WantError: errTest.Error(), }, { Args: "--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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestSFTPList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSFTPsFn: listSFTPsOK, }, WantOutput: listSFTPsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSFTPsFn: listSFTPsOK, }, WantOutput: listSFTPsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSFTPsFn: listSFTPsOK, }, WantOutput: listSFTPsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSFTPsFn: listSFTPsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestSFTPDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSFTPFn: getSFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSFTPFn: getSFTPOK, }, WantOutput: describeSFTPOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestSFTPUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSFTPFn: updateSFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSFTPFn: updateSFTPOK, }, WantOutput: "Updated SFTP logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestSFTPDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSFTPFn: deleteSFTPError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSFTPFn: deleteSFTPOK, }, WantOutput: "Deleted SFTP logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createSFTPOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateSFTPInput) (*fastly.SFTP, error) { return nil, errTest } func listSFTPsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listSFTPsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getSFTPOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getSFTPError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { return nil, errTest } func deleteSFTPOK(_ context.Context, _ *fastly.DeleteSFTPInput) error { return nil } func deleteSFTPError(_ context.Context, _ *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-----`) } ================================================ FILE: pkg/commands/service/logging/sftp/sftp_test.go ================================================ package sftp_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "no updates", cmd: updateCommandNoUpdates(), api: mock.API{ GetVersionFn: testutil.GetVersion, 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *sftp.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *sftp.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/sftp/update.go ================================================ package sftp import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. Address argparser.OptionalString AutoClone argparser.OptionalAutoClone CompressionCodec argparser.OptionalString Format argparser.OptionalString FormatVersion argparser.OptionalInt GzipLevel argparser.OptionalInt MessageType argparser.OptionalString NewName argparser.OptionalString Password argparser.OptionalString Path argparser.OptionalString Period argparser.OptionalInt Placement argparser.OptionalString ProcessingRegion argparser.OptionalString Port argparser.OptionalInt PublicKey argparser.OptionalString ResponseCondition argparser.OptionalString SSHKnownHosts 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 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) logflags.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) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.GzipLevel(c.CmdClause, &c.GzipLevel) logflags.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) logflags.Period(c.CmdClause, &c.Period) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "SFTP") logflags.PublicKey(c.CmdClause, &c.PublicKey) logflags.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) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/splunk/create.go ================================================ package splunk import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Splunk") logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/splunk/delete.go ================================================ package splunk import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/splunk/describe.go ================================================ package splunk import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/splunk/doc.go ================================================ // Package splunk contains commands to inspect and manipulate Fastly service Splunk // logging endpoints. package splunk ================================================ FILE: pkg/commands/service/logging/splunk/list.go ================================================ package splunk import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(splunk.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/splunk/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/splunk/splunk_integration_test.go ================================================ package splunk_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/splunk" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestSplunkCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSplunkFn: createSplunkOK, }, WantOutput: "Created Splunk logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSplunkFn: createSplunkError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestSplunkList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSplunksFn: listSplunksOK, }, WantOutput: listSplunksShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSplunksFn: listSplunksOK, }, WantOutput: listSplunksVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSplunksFn: listSplunksOK, }, WantOutput: listSplunksVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSplunksFn: listSplunksError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestSplunkDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSplunkFn: getSplunkError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSplunkFn: getSplunkOK, }, WantOutput: describeSplunkOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestSplunkUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSplunkFn: updateSplunkError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSplunkFn: updateSplunkOK, }, WantOutput: "Updated Splunk logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestSplunkDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSplunkFn: deleteSplunkError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSplunkFn: deleteSplunkOK, }, WantOutput: "Deleted Splunk logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createSplunkOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateSplunkInput) (*fastly.Splunk, error) { return nil, errTest } func listSplunksOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listSplunksError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getSplunkOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getSplunkError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { return nil, errTest } func deleteSplunkOK(_ context.Context, _ *fastly.DeleteSplunkInput) error { return nil } func deleteSplunkError(_ context.Context, _ *fastly.DeleteSplunkInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/splunk/splunk_test.go ================================================ package splunk_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *splunk.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *splunk.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/splunk/update.go ================================================ package splunk import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString TLSCACert argparser.OptionalString TLSClientCert argparser.OptionalString TLSClientKey argparser.OptionalString TLSHostname argparser.OptionalString Token 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 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) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Splunk") logflags.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, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/sumologic/create.go ================================================ package sumologic import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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) logflags.Format(c.CmdClause, &c.Format) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Sumologic") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/sumologic/delete.go ================================================ package sumologic import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/sumologic/describe.go ================================================ package sumologic import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/sumologic/doc.go ================================================ // Package sumologic contains commands to inspect and manipulate Fastly service Sumologic // logging endpoints. package sumologic ================================================ FILE: pkg/commands/service/logging/sumologic/list.go ================================================ package sumologic import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(sumologic.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/sumologic/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/sumologic/sumologic_integration_test.go ================================================ package sumologic_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/sumologic" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestSumologicCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSumologicFn: createSumologicOK, }, WantOutput: "Created Sumologic logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --url example.com --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSumologicFn: createSumologicError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestSumologicList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSumologicsFn: listSumologicsOK, }, WantOutput: listSumologicsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSumologicsFn: listSumologicsOK, }, WantOutput: listSumologicsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSumologicsFn: listSumologicsOK, }, WantOutput: listSumologicsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSumologicsFn: listSumologicsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestSumologicDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSumologicFn: getSumologicError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSumologicFn: getSumologicOK, }, WantOutput: describeSumologicOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestSumologicUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSumologicFn: updateSumologicError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSumologicFn: updateSumologicOK, }, WantOutput: "Updated Sumologic logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestSumologicDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSumologicFn: deleteSumologicError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSumologicFn: deleteSumologicOK, }, WantOutput: "Deleted Sumologic logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createSumologicOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { return nil, errTest } func listSumologicsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listSumologicsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getSumologicOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getSumologicError(_ context.Context, _ *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 Processing region: us Response condition: Prevent default logging Service ID: 123 URL: example.com Version: 1 `) + "\n" func updateSumologicOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { return nil, errTest } func deleteSumologicOK(_ context.Context, _ *fastly.DeleteSumologicInput) error { return nil } func deleteSumologicError(_ context.Context, _ *fastly.DeleteSumologicInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/sumologic/sumologic_test.go ================================================ package sumologic_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } 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{ GetVersionFn: testutil.GetVersion, 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{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *sumologic.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/sumologic/update.go ================================================ package sumologic import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 Format argparser.OptionalString FormatVersion argparser.OptionalInt // Inconsistent with other logging endpoints, but remaining as int to avoid breaking changes in fastly/go-fastly. MessageType argparser.OptionalString NewName argparser.OptionalString Placement argparser.OptionalString ProcessingRegion 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 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, }) logflags.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) logflags.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) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "Sumologic") logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/syslog/create.go ================================================ package syslog import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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 ProcessingRegion 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', }) logflags.Format(c.CmdClause, &c.Format) logflags.FormatVersion(c.CmdClause, &c.FormatVersion) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Placement(c.CmdClause, &c.Placement) c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "syslog") logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) c.RegisterFlag(argparser.StringFlagOpts{ Action: c.ServiceName.Set, Name: argparser.FlagServiceName, Description: argparser.FlagServiceNameDesc, Dst: &c.ServiceName.Value, }) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.TLSClientKey(c.CmdClause, &c.TLSClientKey) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/logging/syslog/delete.go ================================================ package syslog import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/logging/syslog/describe.go ================================================ package syslog import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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), "Processing region": fastly.ToValue(o.ProcessingRegion), "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 } ================================================ FILE: pkg/commands/service/logging/syslog/doc.go ================================================ // Package syslog contains commands to inspect and manipulate Fastly service Syslog // logging endpoints. package syslog ================================================ FILE: pkg/commands/service/logging/syslog/list.go ================================================ package syslog import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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.Fprintf(out, "\t\tProcessing region: %s\n", fastly.ToValue(syslog.ProcessingRegion)) } fmt.Fprintln(out) return nil } ================================================ FILE: pkg/commands/service/logging/syslog/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/logging/syslog/syslog_integration_test.go ================================================ package syslog_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" root "github.com/fastly/cli/pkg/commands/service" parent "github.com/fastly/cli/pkg/commands/service/logging" sub "github.com/fastly/cli/pkg/commands/service/logging/syslog" ) func TestSyslogCreate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --name log --address 127.0.0.1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSyslogFn: createSyslogOK, }, WantOutput: "Created Syslog logging endpoint log (service 123 version 4)", }, { Args: "--service-id 123 --version 1 --name log --address 127.0.0.1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSyslogFn: createSyslogError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "create"}, scenarios) } func TestSyslogList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSyslogsFn: listSyslogsOK, }, WantOutput: listSyslogsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSyslogsFn: listSyslogsOK, }, WantOutput: listSyslogsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSyslogsFn: listSyslogsOK, }, WantOutput: listSyslogsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListSyslogsFn: listSyslogsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "list"}, scenarios) } func TestSyslogDescribe(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 logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSyslogFn: getSyslogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSyslogFn: getSyslogOK, }, WantOutput: describeSyslogOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "describe"}, scenarios) } func TestSyslogUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --new-name log", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSyslogFn: updateSyslogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --new-name log --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSyslogFn: updateSyslogOK, }, WantOutput: "Updated Syslog logging endpoint log (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "update"}, scenarios) } func TestSyslogDelete(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 logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSyslogFn: deleteSyslogError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name logs --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSyslogFn: deleteSyslogOK, }, WantOutput: "Deleted Syslog logging endpoint logs (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, parent.CommandName, sub.CommandName, "delete"}, scenarios) } var errTest = errors.New("fixture error") func createSyslogOK(_ context.Context, 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(_ context.Context, _ *fastly.CreateSyslogInput) (*fastly.Syslog, error) { return nil, errTest } func listSyslogsOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, { 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"), ProcessingRegion: fastly.ToPointer("us"), }, }, nil } func listSyslogsError(_ context.Context, _ *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 (auth: 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 Processing region: us 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 Processing region: us `) + "\n\n" func getSyslogOK(_ context.Context, 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"), ProcessingRegion: fastly.ToPointer("us"), }, nil } func getSyslogError(_ context.Context, _ *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 Processing region: us 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(_ context.Context, 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(_ context.Context, _ *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { return nil, errTest } func deleteSyslogOK(_ context.Context, _ *fastly.DeleteSyslogInput) error { return nil } func deleteSyslogError(_ context.Context, _ *fastly.DeleteSyslogInput) error { return errTest } ================================================ FILE: pkg/commands/service/logging/syslog/syslog_test.go ================================================ package syslog_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { name: "error missing serviceID", cmd: createCommandMissingServiceID(), want: nil, wantError: errors.ErrNoServiceID.Error(), }, } { t.Run(testcase.name, func(t *testing.T) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"), ProcessingRegion: fastly.ToPointer("eu"), }, }, { 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) { if testcase.wantError == errors.ErrNoServiceID.Error() { t.Setenv("FASTLY_SERVICE_ID", "") } 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func createCommandMissingServiceID() *syslog.CreateCommand { res := createCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} 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"}, ProcessingRegion: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "eu"}, } } func updateCommandMissingServiceID() *syslog.UpdateCommand { res := updateCommandAll() res.Manifest = manifest.Data{} res.ServiceVersion = argparser.OptionalServiceVersion{} return res } ================================================ FILE: pkg/commands/service/logging/syslog/update.go ================================================ package syslog import ( "context" "io" "github.com/fastly/go-fastly/v15/fastly" "4d63.com/optional" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/service/logging/logflags" "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. Address argparser.OptionalString AutoClone argparser.OptionalAutoClone Format argparser.OptionalString FormatVersion argparser.OptionalInt MessageType argparser.OptionalString NewName argparser.OptionalString Placement argparser.OptionalString Port argparser.OptionalInt ProcessingRegion argparser.OptionalString ResponseCondition argparser.OptionalString TLSCACert argparser.OptionalString TLSClientCert argparser.OptionalString TLSClientKey argparser.OptionalString TLSHostname argparser.OptionalString Token argparser.OptionalString UseTLS argparser.OptionalBool } // 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, }) logflags.Format(c.CmdClause, &c.Format) logflags.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) logflags.MessageType(c.CmdClause, &c.MessageType) logflags.Placement(c.CmdClause, &c.Placement) logflags.ProcessingRegion(c.CmdClause, &c.ProcessingRegion, "syslog") 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, }) logflags.ResponseCondition(c.CmdClause, &c.ResponseCondition) logflags.TLSCACert(c.CmdClause, &c.TLSCACert) logflags.TLSClientCert(c.CmdClause, &c.TLSClientCert) logflags.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 } if c.ProcessingRegion.WasSet { input.ProcessingRegion = &c.ProcessingRegion.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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/purge/doc.go ================================================ // Package purge contains commands to inspect and manipulate Fastly edge cache. package purge ================================================ FILE: pkg/commands/service/purge/purge.go ================================================ package purge import ( "bufio" "context" "fmt" "io" "os" "path/filepath" "sort" "github.com/fastly/go-fastly/v15/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" // PurgeCommand calls the Fastly API to purge items from the cache. type PurgeCommand struct { //revive:disable:exported argparser.Base all bool file string key string serviceName argparser.OptionalServiceNameID soft bool url string } // NewPurgeCommand returns a usable command registered under the parent. func NewPurgeCommand(parent argparser.Registerer, g *global.Data) *PurgeCommand { var c PurgeCommand 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 } // Exec implements the command interface. func (c *PurgeCommand) 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 *PurgeCommand) purgeAll(serviceID string, out io.Writer) error { p, err := c.Globals.APIClient.PurgeAll(context.TODO(), &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 } // purgeKey now uses the bulk purge endpoint to avoid serialization of the 'key' field values. // This serialization occurs due to the nature of the POST /service/{service_id}/purge/{surrogate_key} // endpoint storing the 'key' as part of the URL. func (c *PurgeCommand) purgeKey(serviceID string, out io.Writer) error { m, err := c.Globals.APIClient.PurgeKeys(context.TODO(), &fastly.PurgeKeysInput{ ServiceID: serviceID, Keys: []string{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 } purgeID, ok := m[c.key] if !ok { return fmt.Errorf("no purge ID returned for key: %s", c.key) } // The bulk purge endpoint doesn't return a 'Status' field like the single-key // endpoint did. To avoid a breaking change in the CLI output, we hardcode // 'Status: ok' in the success message to maintain consistent behavior. text.Success(out, "Purged key: %s (soft: %t). Status: ok, ID: %s", c.key, c.soft, purgeID) return nil } func (c *PurgeCommand) 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 } return c.purgeBulkKeys(serviceID, keys, out) } func (c *PurgeCommand) purgeBulkKeys(serviceID string, keys []string, out io.Writer) error { m, err := c.Globals.APIClient.PurgeKeys(context.TODO(), &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 *PurgeCommand) purgeURL(out io.Writer) error { p, err := c.Globals.APIClient.Purge(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/purge/purge_test.go ================================================ package purge_test import ( "context" "reflect" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" purge "github.com/fastly/cli/pkg/commands/service/purge" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestPurgeAll(t *testing.T) { const ( testServiceID = "123" testStatus = "ok" ) scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--all", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate --soft flag isn't usable", Args: "--all --service-id " + testServiceID + " --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{ GetVersionFn: testutil.GetVersion, PurgeAllFn: func(_ context.Context, _ *fastly.PurgeAllInput) (*fastly.Purge, error) { return nil, testutil.Err }, }, Args: "--all --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { Name: "validate PurgeAll API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeAllFn: func(_ context.Context, _ *fastly.PurgeAllInput) (*fastly.Purge, error) { return &fastly.Purge{ Status: fastly.ToPointer(testStatus), }, nil }, }, Args: "--all --service-id " + testServiceID, WantOutput: "Purge all status: " + testStatus, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, purge.CommandName}, scenarios) } func TestPurgeKeys(t *testing.T) { const ( testServiceID = "123" testKey1 = "foo" testKey2 = "bar" testKey3 = "baz" testPurgeID1 = "123" testPurgeID2 = "456" testPurgeID3 = "789" ) var keys []string scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--file ./testdata/keys", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate PurgeKeys API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { return nil, testutil.Err }, }, Args: "--file ./testdata/keys --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { Name: "validate PurgeKeys API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeKeysFn: func(_ context.Context, i *fastly.PurgeKeysInput) (map[string]string, error) { // Track the keys parsed keys = i.Keys return map[string]string{ testKey1: testPurgeID1, testKey2: testPurgeID2, testKey3: testPurgeID3, }, nil }, }, Args: "--file ./testdata/keys --service-id " + testServiceID, WantOutput: "KEY ID\nbar 456\nbaz 789\nfoo 123\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, purge.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) { const ( testServiceID = "123" testKey = "foobar" testPurgeID = "123" testStatus = "ok" ) scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--key " + testKey, EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate PurgeKeys API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { return nil, testutil.Err }, }, Args: "--key " + testKey + " --service-id " + testServiceID, WantError: testutil.Err.Error(), }, { Name: "validate PurgeKeys API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { return map[string]string{ testKey: testPurgeID, }, nil }, }, Args: "--key " + testKey + " --service-id " + testServiceID, WantOutput: "Purged key: " + testKey + " (soft: false). Status: " + testStatus + ", ID: " + testPurgeID, }, { Name: "validate PurgeKeys API success with soft purge", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeKeysFn: func(_ context.Context, _ *fastly.PurgeKeysInput) (map[string]string, error) { return map[string]string{ testKey: testPurgeID, }, nil }, }, Args: "--key " + testKey + " --service-id " + testServiceID + " --soft", WantOutput: "Purged key: " + testKey + " (soft: true). Status: " + testStatus + ", ID: " + testPurgeID, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, purge.CommandName}, scenarios) } func TestPurgeURL(t *testing.T) { const ( testServiceID = "123" testURL = "https://example.com" testPurgeID = "123" testStatus = "ok" ) scenarios := []testutil.CLIScenario{ { Name: "validate Purge API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeFn: func(_ context.Context, _ *fastly.PurgeInput) (*fastly.Purge, error) { return nil, testutil.Err }, }, Args: "--service-id " + testServiceID + " --url " + testURL, WantError: testutil.Err.Error(), }, { Name: "validate Purge API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeFn: func(_ context.Context, _ *fastly.PurgeInput) (*fastly.Purge, error) { return &fastly.Purge{ Status: fastly.ToPointer(testStatus), PurgeID: fastly.ToPointer(testPurgeID), }, nil }, }, Args: "--service-id " + testServiceID + " --url " + testURL, WantOutput: "Purged URL: " + testURL + " (soft: false). Status: " + testStatus + ", ID: " + testPurgeID, }, { Name: "validate Purge API success with soft purge", API: &mock.API{ GetVersionFn: testutil.GetVersion, PurgeFn: func(_ context.Context, _ *fastly.PurgeInput) (*fastly.Purge, error) { return &fastly.Purge{ Status: fastly.ToPointer(testStatus), PurgeID: fastly.ToPointer(testPurgeID), }, nil }, }, Args: "--service-id " + testServiceID + " --soft --url " + testURL, WantOutput: "Purged URL: " + testURL + " (soft: true). Status: " + testStatus + ", ID: " + testPurgeID, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, purge.CommandName}, scenarios) } ================================================ FILE: pkg/commands/service/purge/testdata/keys ================================================ foo bar baz ================================================ FILE: pkg/commands/service/ratelimit/create.go ================================================ package ratelimit import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/ratelimit/delete.go ================================================ package ratelimit import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/ratelimit/describe.go ================================================ package ratelimit import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 "" } ================================================ FILE: pkg/commands/service/ratelimit/doc.go ================================================ // Package ratelimit contains commands to inspect and manipulate Fastly edge // rate limiters. package ratelimit ================================================ FILE: pkg/commands/service/ratelimit/list.go ================================================ package ratelimit import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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() } ================================================ FILE: pkg/commands/service/ratelimit/ratelimit_test.go ================================================ package ratelimit_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/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{ GetVersionFn: testutil.GetVersion, CreateERLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateERLFn: func(_ context.Context, 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, sub.CommandName, "create"}, scenarios) } func TestRateLimitDelete(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate DeleteERL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteERLFn: func(_ context.Context, _ *fastly.DeleteERLInput) error { return testutil.Err }, }, Args: "--id 123", WantError: testutil.Err.Error(), }, { Name: "validate DeleteERL API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteERLFn: func(_ context.Context, _ *fastly.DeleteERLInput) error { return nil }, }, Args: "--id 123", WantOutput: "SUCCESS: Deleted rate limiter '123'\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestRateLimitDescribe(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate GetERL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetERLFn: func(_ context.Context, _ *fastly.GetERLInput) (*fastly.ERL, error) { return nil, testutil.Err }, }, Args: "--id 123", WantError: testutil.Err.Error(), }, { Name: "validate ListERL API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetERLFn: func(_ context.Context, _ *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, sub.CommandName, "describe"}, scenarios) } func TestRateLimitList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate ListERL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListERLsFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, ListERLsFn: func(_ context.Context, _ *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, sub.CommandName, "list"}, scenarios) } func TesRateLimitUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate UpdateERL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateERLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateERLFn: func(_ context.Context, 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, sub.CommandName, "update"}, scenarios) } ================================================ FILE: pkg/commands/service/ratelimit/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/ratelimit/update.go ================================================ package ratelimit import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/resourcelink/create.go ================================================ package resourcelink import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/resourcelink/delete.go ================================================ package resourcelink import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/resourcelink/describe.go ================================================ package resourcelink import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/resourcelink/doc.go ================================================ // Package resourcelink contains commands to inspect and // manipulate service resource links. // https://www.fastly.com/documentation/reference/api/services/resource package resourcelink ================================================ FILE: pkg/commands/service/resourcelink/list.go ================================================ package resourcelink import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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, " ", resource) fmt.Fprintln(out) } return nil } ================================================ FILE: pkg/commands/service/resourcelink/resourcelink_test.go ================================================ package resourcelink_test import ( "context" "fmt" "testing" "time" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/resourcelink" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestCreateServiceResourceCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ // Missing required arguments. { Args: "--service-id abc --resource-id 123", WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--service-id abc --version latest", WantError: "error parsing arguments: required flag --resource-id not provided", }, { Args: "--resource-id abc --version latest", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, // Success. { Args: "--resource-id abc --service-id 123 --version 42", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateResourceFn: func(_ context.Context, 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 }, }, WantOutput: `SUCCESS: Created service resource link "the-name" (rand-id) on service 123 version 42`, }, // Success with --name. { Args: "--resource-id abc --service-id 123 --version 42 --name testing", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateResourceFn: func(_ context.Context, 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 }, }, WantOutput: `SUCCESS: Created service resource link "a-name" (rand-id) on service 123 version 42`, }, // Success with --autoclone. { Args: "--resource-id abc --service-id 123 --version=latest --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetServiceDetailsFn: testutil.GetServiceDetails, ListVersionsFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.CloneVersionInput) (*fastly.Version, error) { return &fastly.Version{Number: fastly.ToPointer(43)}, nil }, CreateResourceFn: func(_ context.Context, 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 }, }, WantOutput: `SUCCESS: Created service resource link "cloned" (rand-id) on service 123 version 43`, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) } func TestDeleteServiceResourceCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ // Missing required arguments. { Args: "--id LINK-ID --service-id abc", WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--id LINK-ID --version 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--service-id abc --version 123", WantError: "error parsing arguments: required flag --id not provided", }, // Success. { Args: "--service-id 123 --version 42 --id LINKID", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteResourceFn: func(_ context.Context, 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 }, }, WantOutput: "SUCCESS: Deleted service resource link LINKID from service 123 version 42", }, // Success with --autoclone. { Args: "--service-id 123 --version 42 --id LINKID --autoclone", API: &mock.API{ GetVersionFn: func(_ context.Context, _ *fastly.GetVersionInput) (*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(_ context.Context, _ *fastly.CloneVersionInput) (*fastly.Version, error) { return &fastly.Version{Number: fastly.ToPointer(43)}, nil }, DeleteResourceFn: func(_ context.Context, 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 }, }, WantOutput: "SUCCESS: Deleted service resource link LINKID from service 123 version 43", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) } func TestDescribeServiceResourceCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ // Missing required arguments. { Args: "--id LINK-ID --service-id abc", WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--id LINK-ID --version 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--service-id abc --version 123", WantError: "error parsing arguments: required flag --id not provided", }, // Success. { Args: "--service-id 123 --version 42 --id LINKID", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetResourceFn: func(_ context.Context, 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 }, }, 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`, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestListServiceResourceCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ // Missing required arguments. { Args: "--service-id abc", WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--version 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, // Success. { Args: "--service-id 123 --version 42", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListResourcesFn: func(_ context.Context, 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 }, }, 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`, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) } func TestUpdateServiceResourceCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ // Missing required arguments. { Args: "--id LINK-ID --name new-name --service-id abc", WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--id LINK-ID --name new-name --version 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Args: "--id LINK-ID --service-id abc --version 123", WantError: "error parsing arguments: required flag --name not provided", }, { Args: "--name new-name --service-id abc --version 123", WantError: "error parsing arguments: required flag --id not provided", }, // Success. { Args: "--id LINK-ID --name new-name --service-id 123 --version 42", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateResourceFn: func(_ context.Context, 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 }, }, WantOutput: "SUCCESS: Updated service resource link LINK-ID on service 123 version 42", }, // Success with --autoclone. { Args: "--id LINK-ID --name new-name --service-id 123 --version 42 --autoclone", API: &mock.API{ GetVersionFn: func(_ context.Context, _ *fastly.GetVersionInput) (*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(_ context.Context, _ *fastly.CloneVersionInput) (*fastly.Version, error) { return &fastly.Version{Number: fastly.ToPointer(43)}, nil }, UpdateResourceFn: func(_ context.Context, 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 }, }, WantOutput: "SUCCESS: Updated service resource link LINK-ID on service 123 version 43", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) } ================================================ FILE: pkg/commands/service/resourcelink/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/resourcelink/update.go ================================================ package resourcelink import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/search.go ================================================ package service import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/service_test.go ================================================ package service_test import ( "bytes" "context" "errors" "io" "net/http" "os" "path/filepath" "regexp" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/app" root "github.com/fastly/cli/pkg/commands/service" "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) { scenarios := []testutil.CLIScenario{ { Name: "success with long flag", Args: "--name Foo", API: &mock.API{CreateServiceFn: createServiceOK}, WantOutput: "Created service 12345", }, { Name: "success with short flag and equals", Args: "-n=Foo", API: &mock.API{CreateServiceFn: createServiceOK}, WantOutput: "Created service 12345", }, { Name: "success with type", Args: "--name Foo --type wasm", API: &mock.API{CreateServiceFn: createServiceOK}, WantOutput: "Created service 12345", }, { Name: "success with type and comment", Args: "--name Foo --type wasm --comment Hello", API: &mock.API{CreateServiceFn: createServiceOK}, WantOutput: "Created service 12345", }, { Name: "success with short flag and comment", Args: "-n Foo --comment Hello", API: &mock.API{CreateServiceFn: createServiceOK}, WantOutput: "Created service 12345", }, { Name: "api failure", Args: "-n Foo", API: &mock.API{CreateServiceFn: createServiceError}, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) } func TestServiceList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "api failure", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetServicesFn: func(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &mock.HTTPClient{ Errors: []error{ testutil.Err, }, Responses: []*http.Response{nil}, }, fastly.ListOpts{}, "/example") }, }, Args: "", WantError: testutil.Err.Error(), }, { Name: "success with pagination", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetServicesFn: func(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &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: "--per-page 1", WantOutput: listServicesShortOutput, }, { Name: "success with verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetServicesFn: func(ctx context.Context, _ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return fastly.NewPaginator[fastly.Service](ctx, &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: "--verbose", WantOutput: listServicesVerboseOutput, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) } func TestServiceDescribe(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "no service id", Args: "", API: &mock.API{GetServiceDetailsFn: describeServiceOK}, EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "success", Args: "--service-id 123", API: &mock.API{GetServiceDetailsFn: describeServiceOK}, WantOutput: describeServiceShortOutput, }, { Name: "verbose flag after args", Args: "--service-id 123 --verbose", API: &mock.API{GetServiceDetailsFn: describeServiceOK}, WantOutput: describeServiceVerboseOutput, }, { Name: "verbose short flag after args", Args: "--service-id 123 -v", API: &mock.API{GetServiceDetailsFn: describeServiceOK}, WantOutput: describeServiceVerboseOutput, }, { Name: "api failure", Args: "--service-id 123", API: &mock.API{GetServiceDetailsFn: describeServiceError}, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) } func TestServiceSearch(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "missing required flag", Args: "", WantError: "error parsing arguments: required flag --name not provided", }, { Name: "success", Args: "--name Foo", API: &mock.API{SearchServiceFn: searchServiceOK}, WantOutput: searchServiceShortOutput, }, { Name: "success with verbose", Args: "--name Foo -v", API: &mock.API{SearchServiceFn: searchServiceOK}, WantOutput: searchServiceVerboseOutput, }, { Name: "missing flag value", Args: "--name", API: &mock.API{SearchServiceFn: searchServiceOK}, WantError: "error parsing arguments: expected argument for flag '--name'", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "search"}, scenarios) } func TestServiceUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "no service id", Args: "", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetServiceFn: getServiceOK, UpdateServiceFn: updateServiceOK, }, EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "missing name or comment", Args: "--service-id 12345", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantError: "error parsing arguments: must provide either --name or --comment to update service", }, { Name: "success with long flag", Args: "--service-id 12345 --name Foo", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantOutput: "Updated service 12345", }, { Name: "success with short flag and equals", Args: "--service-id 12345 -n=Foo", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantOutput: "Updated service 12345", }, { Name: "success with long flag duplicate", Args: "--service-id 12345 --name Foo", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantOutput: "Updated service 12345", }, { Name: "success with name and comment", Args: "--service-id 12345 --name Foo --comment Hello", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantOutput: "Updated service 12345", }, { Name: "success with short flag and comment", Args: "--service-id 12345 -n Foo --comment Hello", API: &mock.API{UpdateServiceFn: updateServiceOK}, WantOutput: "Updated service 12345", }, { Name: "api failure", Args: "--service-id 12345 -n Foo", API: &mock.API{UpdateServiceFn: updateServiceError}, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) } 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 a 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(_ context.Context, i *fastly.CreateServiceInput) (*fastly.Service, error) { return &fastly.Service{ ServiceID: fastly.ToPointer("12345"), Name: i.Name, }, nil } func createServiceError(_ context.Context, _ *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 (auth: 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(_ context.Context, _ *fastly.GetServiceInput) (*fastly.Service, error) { return &fastly.Service{ ServiceID: fastly.ToPointer("12345"), Name: fastly.ToPointer("Foo"), Comment: fastly.ToPointer("Bar"), }, nil } func describeServiceOK(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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(_ context.Context, _ *fastly.GetServiceDetailsInput) (*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 (auth: 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(_ context.Context, _ *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 (auth: 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(_ context.Context, _ *fastly.UpdateServiceInput) (*fastly.Service, error) { return &fastly.Service{ ServiceID: fastly.ToPointer("12345"), }, nil } func updateServiceError(_ context.Context, _ *fastly.UpdateServiceInput) (*fastly.Service, error) { return nil, errTest } func deleteServiceOK(_ context.Context, _ *fastly.DeleteServiceInput) error { return nil } func deleteServiceError(_ context.Context, _ *fastly.DeleteServiceInput) error { return errTest } ================================================ FILE: pkg/commands/service/testdata/fastly-no-serviceid.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/commands/service/testdata/fastly-valid.toml ================================================ manifest_version = 2 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" service_id = "123" ================================================ FILE: pkg/commands/service/update.go ================================================ package service import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/condition/condition_test.go ================================================ package condition_test import ( "context" "errors" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" top "github.com/fastly/cli/pkg/commands/service" root "github.com/fastly/cli/pkg/commands/service/vcl" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateConditionFn: createConditionError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteConditionFn: deleteConditionError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name always_false --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteConditionFn: deleteConditionOK, }, WantOutput: "Deleted condition always_false (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateConditionFn: updateConditionOK, }, WantError: "error parsing arguments: must provide either --new-name, --statement, --type, --priority or --comment to update condition", }, { Args: "--service-id 123 --version 1 --name always_false --new-name false_always --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateConditionFn: updateConditionOK, }, WantOutput: "Updated condition false_always (service 123 version 4)", }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, GetConditionFn: getConditionError, }, WantError: errTest.Error(), }, { Args: "--service-id 123 --version 1 --name always_false", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetConditionFn: getConditionOK, }, WantOutput: describeConditionOutput, }, } testutil.RunCLIScenarios(t, []string{top.CommandName, root.CommandName, sub.CommandName, "describe"}, scenarios) } func TestConditionList(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsOK, }, WantOutput: listConditionsShortOutput, }, { Args: "--service-id 123 --version 1 --verbose", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsOK, }, WantOutput: listConditionsVerboseOutput, }, { Args: "--service-id 123 --version 1 -v", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsOK, }, WantOutput: listConditionsVerboseOutput, }, { Args: "--verbose --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsOK, }, WantOutput: listConditionsVerboseOutput, }, { Args: "-v --service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsOK, }, WantOutput: listConditionsVerboseOutput, }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListConditionsFn: listConditionsError, }, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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 (auth: 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(_ context.Context, 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(_ context.Context, _ *fastly.CreateConditionInput) (*fastly.Condition, error) { return nil, errTest } func deleteConditionOK(_ context.Context, _ *fastly.DeleteConditionInput) error { return nil } func deleteConditionError(_ context.Context, _ *fastly.DeleteConditionInput) error { return errTest } func updateConditionOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateConditionInput) (*fastly.Condition, error) { return nil, errTest } func getConditionOK(_ context.Context, 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(_ context.Context, _ *fastly.GetConditionInput) (*fastly.Condition, error) { return nil, errTest } func listConditionsOK(_ context.Context, 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(_ context.Context, _ *fastly.ListConditionsInput) ([]*fastly.Condition, error) { return nil, errTest } ================================================ FILE: pkg/commands/service/vcl/condition/create.go ================================================ package condition import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/condition/delete.go ================================================ package condition import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/condition/describe.go ================================================ package condition import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/condition/doc.go ================================================ // Package condition contains commands to inspect and manipulate Fastly service condition. package condition ================================================ FILE: pkg/commands/service/vcl/condition/list.go ================================================ package condition import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/condition/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/vcl/condition/update.go ================================================ package condition import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 && !c.comment.WasSet { return fmt.Errorf("error parsing arguments: must provide either --new-name, --statement, --type, --priority or --comment 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.Comment = &c.comment.Value } r, err := c.Globals.APIClient.UpdateCondition(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/vcl/custom/create.go ================================================ package custom import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/custom/custom_test.go ================================================ package custom_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/fastly" top "github.com/fastly/cli/pkg/commands/service" root "github.com/fastly/cli/pkg/commands/service/vcl" sub "github.com/fastly/cli/pkg/commands/service/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 CreateVCL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateVCLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateVCLFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateVCLFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateVCLFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateVCLFn: func(_ context.Context, 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{top.CommandName, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteVCL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteVCLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteVCLFn: func(_ context.Context, _ *fastly.DeleteVCLInput) error { return nil }, }, Args: "--name foobar --service-id 123 --version 3", WantOutput: "Deleted custom VCL 'foobar' (service: 123, version: 3)", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteVCLFn: func(_ context.Context, i *fastly.DeleteVCLInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteVCLFn: func(_ context.Context, i *fastly.DeleteVCLInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteVCLFn: func(_ context.Context, _ *fastly.DeleteVCLInput) error { return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 1", WantOutput: "Deleted custom VCL 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteVCLFn: func(_ context.Context, i *fastly.DeleteVCLInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 2", WantOutput: "Deleted custom VCL 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteVCLFn: func(_ context.Context, i *fastly.DeleteVCLInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 3", WantOutput: "Deleted custom VCL 'foo' (service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, GetVCLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{top.CommandName, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate ListVCLs API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, ListVCLsFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, ListVCLsFn: listVCLs, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: 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{top.CommandName, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate UpdateVCL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateVCLFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateVCLFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, UpdateVCLFn: func(_ context.Context, 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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateVCLFn: func(_ context.Context, i *fastly.UpdateVCLInput) (*fastly.VCL, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--content updated --name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateVCLFn: func(_ context.Context, i *fastly.UpdateVCLInput) (*fastly.VCL, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--content updated --name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateVCLFn: func(_ context.Context, 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 }}, }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateVCLFn: func(_ context.Context, i *fastly.UpdateVCLInput) (*fastly.VCL, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } // 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 2", WantOutput: "Updated custom VCL 'foo' (service: 123, version: 4)", PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateVCLFn: func(_ context.Context, i *fastly.UpdateVCLInput) (*fastly.VCL, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } // 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 3", 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{top.CommandName, root.CommandName, sub.CommandName, "update"}, scenarios) } func getVCL(_ context.Context, 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(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/vcl/custom/delete.go ================================================ package custom import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/custom/describe.go ================================================ package custom import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 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(context.TODO(), 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", text.SanitizeTerminalOutput(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 } ================================================ FILE: pkg/commands/service/vcl/custom/doc.go ================================================ // Package custom contains commands for managing custom VCL. package custom ================================================ FILE: pkg/commands/service/vcl/custom/list.go ================================================ package custom import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/custom/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/vcl/custom/testdata/example.vcl ================================================ # some vcl content ================================================ FILE: pkg/commands/service/vcl/custom/update.go ================================================ package custom import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/describe.go ================================================ package vcl import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 generated VCL 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.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 list appropriate resources. type DescribeCommand struct { argparser.Base argparser.JSONOutput 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.GetGeneratedVCL(context.TODO(), 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.print(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 *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetGeneratedVCLInput { var input fastly.GetGeneratedVCLInput input.ServiceID = serviceID input.ServiceVersion = serviceVersion return &input } // printVerbose displays the information returned from the API in a verbose // format. func (c *DescribeCommand) printVerbose(out io.Writer, serviceVersion int, v *fastly.VCL) { fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) fmt.Fprintf(out, "\n") fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(v.Name)) fmt.Fprintf(out, "Main: %t\n", fastly.ToValue(v.Main)) 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) } fmt.Fprintf(out, "Content: \n%s\n", text.SanitizeTerminalOutput(fastly.ToValue(v.Content))) } // print the generated VCL. func (c *DescribeCommand) print(out io.Writer, v *fastly.VCL) error { fmt.Fprintf(out, "%s\n", text.SanitizeTerminalOutput(fastly.ToValue(v.Content))) return nil } ================================================ FILE: pkg/commands/service/vcl/doc.go ================================================ // Package vcl contains commands for managing VCL. package vcl ================================================ FILE: pkg/commands/service/vcl/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/vcl/snippet/create.go ================================================ package snippet import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/snippet/delete.go ================================================ package snippet import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/snippet/describe.go ================================================ package snippet import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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 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("content", "Outputs the raw content of the identified snippet").Action(c.content.Set).BoolVar(&c.content.Value) 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 content 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 } // Ensure that the --content flag is not used // with --verbose or --json if c.Globals.Verbose() && c.content.WasSet { return fsterr.ErrInvalidContentOutputCombo } if c.JSONOutput.Enabled && c.content.WasSet { return fsterr.ErrInvalidContentOutputCombo } 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(context.TODO(), 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(context.TODO(), 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 { // If the --content flag is set, output only the raw VCL content. if c.content.WasSet { fmt.Fprint(out, fastly.ToValue(ds.Content)) return nil } 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", text.SanitizeTerminalOutput(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 the --content flag is set, output only the raw VCL content. if c.content.WasSet { fmt.Fprint(out, fastly.ToValue(s.Content)) return nil } 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", text.SanitizeTerminalOutput(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 } ================================================ FILE: pkg/commands/service/vcl/snippet/doc.go ================================================ // Package snippet contains commands for managing versioned and dynamic VCL // snippets. package snippet ================================================ FILE: pkg/commands/service/vcl/snippet/list.go ================================================ package snippet import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/snippet/root.go ================================================ 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") } ================================================ FILE: pkg/commands/service/vcl/snippet/snippet_test.go ================================================ package snippet_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/fastly" top "github.com/fastly/cli/pkg/commands/service" root "github.com/fastly/cli/pkg/commands/service/vcl" sub "github.com/fastly/cli/pkg/commands/service/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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate CreateSnippet API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, CreateSnippetFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, CreateSnippetFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateSnippetFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateSnippetFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), CreateSnippetFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, CreateSnippetFn: func(_ context.Context, 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{top.CommandName, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DeleteSnippet API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteSnippetFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, DeleteSnippetFn: func(_ context.Context, _ *fastly.DeleteSnippetInput) error { return nil }, }, Args: "--name foobar --service-id 123 --version 3", WantOutput: "Deleted VCL snippet 'foobar' (service: 123, version: 3)", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteSnippetFn: func(_ context.Context, i *fastly.DeleteSnippetInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeleteSnippetFn: func(_ context.Context, i *fastly.DeleteSnippetInput) error { return fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--name foobar --service-id 123 --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSnippetFn: func(_ context.Context, _ *fastly.DeleteSnippetInput) error { return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 1", WantOutput: "Deleted VCL snippet 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSnippetFn: func(_ context.Context, i *fastly.DeleteSnippetInput) error { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 2", WantOutput: "Deleted VCL snippet 'foo' (service: 123, version: 4)", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), DeleteSnippetFn: func(_ context.Context, i *fastly.DeleteSnippetInput) error { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } return nil }, }, Args: "--autoclone --name foo --service-id 123 --version 3", WantOutput: "Deleted VCL snippet 'foo' (service: 123, version: 4)", }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, GetSnippetFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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", }, { Name: "validate --content flag outputs raw VCL only for versioned snippet", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetSnippetFn: getSnippet, }, Args: "--content --name foobar --service-id 123 --version 3", WantOutput: "# some vcl content", }, { Name: "validate --content flag outputs raw VCL only for dynamic snippet", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetDynamicSnippetFn: getDynamicSnippet, }, Args: "--content --dynamic --service-id 123 --snippet-id 456 --version 3", WantOutput: "# some vcl content", }, { Name: "validate --content flag with --verbose returns error", API: &mock.API{ GetVersionFn: testutil.GetVersion, }, Args: "--content --name foobar --service-id 123 --verbose --version 3", WantError: "invalid flag combination, --content cannot be used together with --json or --verbose", }, { Name: "validate --content flag with --json returns error", API: &mock.API{ GetVersionFn: testutil.GetVersion, }, Args: "--content --json --name foobar --service-id 123 --version 3", WantError: "invalid flag combination, --content cannot be used together with --json or --verbose", }, } testutil.RunCLIScenarios(t, []string{top.CommandName, 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{ GetVersionFn: testutil.GetVersion, ListSnippetsFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, ListSnippetsFn: listSnippets, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: 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{top.CommandName, 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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate versioned snippet missing --name", API: &mock.API{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, }, 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{ GetVersionFn: testutil.GetVersion, UpdateSnippetFn: func(_ context.Context, _ *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{ GetVersionFn: testutil.GetVersion, UpdateSnippetFn: func(_ context.Context, 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{ GetVersionFn: testutil.GetVersion, UpdateDynamicSnippetFn: func(_ context.Context, 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 API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateSnippetFn: func(_ context.Context, i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been activated cannot be updated", i.ServiceVersion) }, }, Args: "--content inline_vcl --name foo --new-name bar --service-id 123 --type recv --version 3", WantError: "Cannot update version 3. Versions that have been activated cannot be updated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, UpdateSnippetFn: func(_ context.Context, i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { return nil, fmt.Errorf("Cannot update version %d. Versions that have been locked cannot be updated", i.ServiceVersion) }, }, Args: "--content inline_vcl --name foo --new-name bar --service-id 123 --type recv --version 3", WantError: "Cannot update version 3. Versions that have been locked cannot be updated", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSnippetFn: func(_ context.Context, 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 }}, }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSnippetFn: func(_ context.Context, i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { // Verify operation happens on the cloned version (4), not original (2) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } // 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 2", 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 }}, }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateSnippetFn: func(_ context.Context, i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { // Verify operation happens on the cloned version (4), not original (3) if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected operation on cloned version 4, got %d", i.ServiceVersion) } // 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 3", 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{top.CommandName, root.CommandName, sub.CommandName, "update"}, scenarios) } func getSnippet(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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 } ================================================ FILE: pkg/commands/service/vcl/snippet/testdata/snippet.vcl ================================================ # some vcl content ================================================ FILE: pkg/commands/service/vcl/snippet/update.go ================================================ package snippet import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/service/vcl/vcl_test.go ================================================ package vcl_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/vcl" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestVCLDescribe(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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error reading service: no service ID found", }, { Name: "validate DescribeVCL API error", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGeneratedVCLFn: func(_ context.Context, _ *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) { return nil, testutil.Err }, }, Args: "--service-id 123 --version 3", WantError: testutil.Err.Error(), }, { Name: "validate DescribeVCL API success", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGeneratedVCLFn: getVCL, }, Args: "--service-id 123 --version 3", WantOutput: "# some vcl content\n", }, { Name: "validate missing --verbose flag", API: &mock.API{ GetVersionFn: testutil.GetVersion, GetGeneratedVCLFn: getVCL, }, Args: "--service-id 123 --verbose --version 1", WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (auth: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\nMain: false\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\nContent: \n# some vcl content\n", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) } func getVCL(_ context.Context, i *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) { t := testutil.Date return &fastly.VCL{ Content: fastly.ToPointer("# some vcl content"), Main: fastly.ToPointer(false), Name: fastly.ToPointer("foo"), ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion), CreatedAt: &t, DeletedAt: &t, UpdatedAt: &t, }, nil } ================================================ FILE: pkg/commands/service/version/activate.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/clone.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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 argparser.JSONOutput 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.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, }) return &c } // Exec invokes the application logic for the command. func (c *CloneCommand) 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 } c.Input.ServiceID = serviceID c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) ver, err := c.Globals.APIClient.CloneVersion(context.TODO(), &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, ver); ok { 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 } ================================================ FILE: pkg/commands/service/version/deactivate.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/doc.go ================================================ // Package version contains commands to inspect and manipulate Fastly // service versions. package version ================================================ FILE: pkg/commands/service/version/list.go ================================================ package version import ( "context" "fmt" "io" "time" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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) } ================================================ FILE: pkg/commands/service/version/lock.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/root.go ================================================ package version 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 = "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") } ================================================ FILE: pkg/commands/service/version/serviceversion_test.go ================================================ package version_test import ( "context" "fmt" "strings" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/service" sub "github.com/fastly/cli/pkg/commands/service/version" "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", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantOutput: "Cloned service 123 version 1 to version 4", }, { Name: "validate successful clone json output", Args: "--service-id 123 --version 1 --json", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), }, WantOutput: cloneServiceVersionJSONOutput, }, { Name: "validate error will be passed through if cloning fails", Args: "--service-id 456 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionError, }, WantError: testutil.Err.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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, sub.CommandName, "list"}, scenarios) } func TestVersionUpdate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123 --version 1 --comment foo --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateVersionFn: updateVersionOK, }, WantOutput: "Updated service 123 version 4", }, { Args: "--service-id 123 --version 1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, 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{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), UpdateVersionFn: updateVersionError, }, WantError: testutil.Err.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.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 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: activateVersionError, }, WantError: testutil.Err.Error(), }, { Args: "--service-id 123 --version 1 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: activateVersionOK, }, WantOutput: "Activated service 123 version 4", }, { Args: "--service-id 123 --version 2 --autoclone", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: activateVersionOK, }, WantOutput: "Activated service 123 version 4", }, { Name: "validate API error when modifying active version", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: func(_ context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { return nil, fmt.Errorf("Cannot activate version %d. Versions that have been activated cannot be activated", i.ServiceVersion) }, }, Args: "--service-id 123 --version 3", WantError: "Cannot activate version 3. Versions that have been activated cannot be activated", }, { Name: "validate API error when modifying locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: func(_ context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { return nil, fmt.Errorf("Cannot activate version %d. Versions that have been locked cannot be activated", i.ServiceVersion) }, }, Args: "--service-id 123 --version 3", WantError: "Cannot activate version 3. Versions that have been locked cannot be activated", }, { Args: "--service-id 123 --version 3", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: activateVersionOK, }, WantOutput: "Activated service 123 version 3", }, { Name: "validate --autoclone results in cloned service version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: activateVersionOK, }, Args: "--service-id 123 --version 3 --autoclone", WantOutput: "Activated service 123 version 4", }, { Name: "validate --autoclone on locked version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: func(_ context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected activation on cloned version 4, got %d", i.ServiceVersion) } 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 }, }, Args: "--service-id 123 --version 3 --autoclone", WantOutput: "Activated service 123 version 4", }, { Name: "validate --autoclone on editable version", API: &mock.API{ GetVersionFn: testutil.GetVersion, CloneVersionFn: testutil.CloneVersionResult(4), ActivateVersionFn: func(_ context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { if i.ServiceVersion != 4 { return nil, fmt.Errorf("expected activation on cloned version 4, got %d", i.ServiceVersion) } 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 }, }, Args: "--service-id 123 --version 3 --autoclone", WantOutput: "Activated service 123 version 4", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "activate"}, scenarios) } func TestVersionDeactivate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: deactivateVersionOK, }, WantOutput: "Deactivated service 123 version 1", }, { Args: "--service-id 123 --version 3", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: deactivateVersionOK, }, WantError: "service version 3 is not active", }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: deactivateVersionError, }, WantError: testutil.Err.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "deactivate"}, scenarios) } func TestVersionLock(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, LockVersionFn: lockVersionOK, }, WantOutput: "Locked service 123 version 1", }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, LockVersionFn: lockVersionError, }, WantError: testutil.Err.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "lock"}, scenarios) } func TestVersionStage(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--service-id 123 --version 3", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: stageVersionError, }, WantError: testutil.Err.Error(), }, { Args: "--service-id 123 --version 3", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: stageVersionOK, }, WantOutput: "Staged service 123 version 3", }, { Args: "--service-id 123 --version 4", API: &mock.API{ GetVersionFn: testutil.GetVersion, ActivateVersionFn: stageVersionOK, }, WantOutput: "Staged service 123 version 4", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "stage"}, scenarios) } func TestVersionUnstage(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "--service-id 123", EnvVars: map[string]string{"FASTLY_SERVICE_ID": ""}, WantError: "error parsing arguments: required flag --version not provided", }, { Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: unstageVersionOK, }, WantError: "service version 1 is not staged", }, { Args: "--service-id 123 --version 3", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: unstageVersionError, }, WantError: "service version 3 is not staged", }, { Args: "--service-id 123 --version 4", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: unstageVersionError, }, WantError: testutil.Err.Error(), }, { Args: "--service-id 123 --version 4", API: &mock.API{ GetVersionFn: testutil.GetVersion, DeactivateVersionFn: unstageVersionOK, }, WantOutput: "Unstaged service 123 version 4", }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "unstage"}, scenarios) } var cloneServiceVersionJSONOutput = strings.TrimSpace(` { "Active": null, "Comment": null, "CreatedAt": null, "DeletedAt": null, "Deployed": null, "Locked": null, "Number": 4, "ServiceID": "123", "Staging": null, "Testing": null, "UpdatedAt": null, "Environments": null } `) + "\n" var listVersionsShortOutput = strings.TrimSpace(` NUMBER ACTIVE STAGED LAST EDITED (UTC) 4 false true 2000-01-04 01:00 3 false false 2000-01-03 01:00 2 false false 2000-01-02 01:00 1 true false 2000-01-01 01:00 `) + "\n" var listVersionsVerboseOutput = strings.TrimSpace(` Fastly API endpoint: https://api.fastly.com Fastly API token provided via config file (auth: user) Service ID (via --service-id): 123 Versions: 4 Version 1/4 Number: 4 Service ID: 123 Staged: true Last edited (UTC): 2000-01-04 01:00 Version 2/4 Number: 3 Service ID: 123 Last edited (UTC): 2000-01-03 01:00 Version 3/4 Number: 2 Service ID: 123 Locked: true Last edited (UTC): 2000-01-02 01:00 Version 4/4 Number: 1 Service ID: 123 Active: true Last edited (UTC): 2000-01-01 01:00 `) + "\n\n" func updateVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.UpdateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func activateVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.ActivateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func deactivateVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.DeactivateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func stageVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.ActivateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func unstageVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.DeactivateVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func lockVersionOK(_ context.Context, 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(_ context.Context, _ *fastly.LockVersionInput) (*fastly.Version, error) { return nil, testutil.Err } func TestVersionValidate(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing --service-id flag", Args: "--version 1", WantError: "error parsing arguments: required flag --service-id not provided", }, { Name: "validate missing --version flag", Args: "--service-id 123", WantError: "error parsing arguments: required flag --version not provided", }, { Name: "validate successful - valid version without message", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionValid(""), }, WantOutput: "Service 123 version 1 is valid", }, { Name: "validate successful - valid version with message", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionValid("All checks passed"), }, WantOutput: "Service 123 version 1 is valid: All checks passed", }, { Name: "validate successful - invalid version without message", Args: "--service-id 123 --version 2", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionInvalid(""), }, WantOutput: "Service 123 version 2 is not valid", }, { Name: "validate successful - invalid version with message", Args: "--service-id 123 --version 2", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionInvalid("Missing required backend"), }, WantOutput: "Service 123 version 2 is not valid: Missing required backend", }, { Name: "validate with json output - valid version", Args: "--service-id 123 --version 1 --json", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionValid("All checks passed"), }, WantOutput: validateVersionValidJSONOutput, }, { Name: "validate with json output - invalid version", Args: "--service-id 123 --version 2 --json", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionInvalid("Missing required backend"), }, WantOutput: validateVersionInvalidJSONOutput, }, { Name: "validate error from API", Args: "--service-id 123 --version 1", API: &mock.API{ GetVersionFn: testutil.GetVersion, ValidateVersionFn: validateVersionError, }, WantError: testutil.Err.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "validate"}, scenarios) } func validateVersionValid(message string) func(context.Context, *fastly.ValidateVersionInput) (bool, string, error) { return func(_ context.Context, _ *fastly.ValidateVersionInput) (bool, string, error) { return true, message, nil } } func validateVersionInvalid(message string) func(context.Context, *fastly.ValidateVersionInput) (bool, string, error) { return func(_ context.Context, _ *fastly.ValidateVersionInput) (bool, string, error) { return false, message, nil } } func validateVersionError(_ context.Context, _ *fastly.ValidateVersionInput) (bool, string, error) { return false, "", testutil.Err } var validateVersionValidJSONOutput = strings.TrimSpace(` { "message": "All checks passed", "valid": true } `) + "\n" var validateVersionInvalidJSONOutput = strings.TrimSpace(` { "message": "Missing required backend", "valid": false } `) + "\n" ================================================ FILE: pkg/commands/service/version/stage.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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 c.CmdClause = parent.Command("stage", "Stage 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 *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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/unstage.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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 c.CmdClause = parent.Command("unstage", "Unstage 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 *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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/update.go ================================================ package version import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), &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 } ================================================ FILE: pkg/commands/service/version/validate.go ================================================ package version import ( "context" "io" "github.com/fastly/go-fastly/v15/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" ) // ValidateCommand calls the Fastly API to validate a service version. type ValidateCommand struct { argparser.Base argparser.JSONOutput input fastly.ValidateVersionInput serviceVersion argparser.OptionalServiceVersion } // NewValidateCommand returns a usable command registered under the parent. func NewValidateCommand(parent argparser.Registerer, g *global.Data) *ValidateCommand { c := ValidateCommand{ Base: argparser.Base{ Globals: g, }, } c.CmdClause = parent.Command("validate", "Validate a service version") c.RegisterFlagBool(c.JSONFlag()) // --json c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, Dst: &g.Manifest.Flag.ServiceID, Short: 's', Required: true, }) 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 *ValidateCommand) 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, 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) valid, msg, err := c.Globals.APIClient.ValidateVersion(context.TODO(), &c.input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) return err } if ok, err := c.WriteJSON(out, map[string]any{ "valid": valid, "message": msg, }); ok { return err } if valid { if msg != "" { text.Success(out, "Service %s version %d is valid: %s", serviceID, c.input.ServiceVersion, msg) } else { text.Success(out, "Service %s version %d is valid", serviceID, c.input.ServiceVersion) } } else { if msg != "" { text.Error(out, "Service %s version %d is not valid: %s", serviceID, c.input.ServiceVersion, msg) } else { text.Error(out, "Service %s version %d is not valid", serviceID, c.input.ServiceVersion) } } return nil } ================================================ FILE: pkg/commands/shellcomplete/doc.go ================================================ // Package shellcomplete contains a hidden command used to prevent help output // when --completion-script- is passed. package shellcomplete ================================================ FILE: pkg/commands/shellcomplete/root.go ================================================ 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") } ================================================ FILE: pkg/commands/sso/doc.go ================================================ // 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 ================================================ FILE: pkg/commands/sso/root.go ================================================ package sso import ( "fmt" "io" "github.com/fastly/cli/pkg/argparser" authcmd "github.com/fastly/cli/pkg/commands/auth" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // 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 } // 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 c.CmdClause = parent.Command(CommandName, "Single Sign-On authentication (deprecated: use 'fastly auth login --sso --token ' instead)").Hidden() 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 { if !c.Globals.Flags.Quiet { text.Deprecated("This command will be removed in a future release. Use 'fastly auth login --sso --token ' instead.\n\n") } tokenName, isFallback := c.resolveTokenName() if !isFallback && c.Globals.Config.GetAuthToken(tokenName) == nil { return fsterr.RemediationError{ Inner: fmt.Errorf("token %q does not exist", tokenName), Remediation: "Run 'fastly auth login --sso --token ' to create a new SSO token, or 'fastly auth add' to store an existing token.", } } if err := authcmd.RunSSOWithTokenName(in, out, c.Globals, ForceReAuth, false, tokenName); err != nil { return err } c.Globals.Config.Auth.Default = tokenName if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { return fmt.Errorf("error saving config: %w", err) } return nil } func (c *RootCommand) resolveTokenName() (string, bool) { if c.Globals.Flags.Profile != "" { return c.Globals.Flags.Profile, false } if c.Globals.Manifest != nil && c.Globals.Manifest.File.Profile != "" { return c.Globals.Manifest.File.Profile, false } if c.profile != "" { return c.profile, false } if name, _ := c.Globals.Config.GetDefaultAuthToken(); name != "" { return name, false } return "default", true } ================================================ FILE: pkg/commands/sso/sso_test.go ================================================ package sso_test import ( "context" "errors" "testing" "time" "github.com/fastly/go-fastly/v15/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 (uses default auth token name "user" from MockGlobalData) { 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' token", "We need to open your browser to authenticate you.", "Session token 'user' has been stored.", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("user") if at == nil { t.Fatal("expected 'user' auth token to exist") } if at.Token != "123" { t.Errorf("want token: 123, got token: %s", at.Token) } }, }, // 5. Success processing OAuth flow while setting specific profile (test_user) { Args: "sso test_user", ConfigFile: &config.File{ Auth: config.Auth{ Default: "test_user", Tokens: config.AuthTokens{ "test_user": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "mock-token", Email: "test@example.com", }, }, }, }, 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' token", "We need to open your browser to authenticate you.", "Session token 'test_user' has been stored.", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("test_user") if at == nil { t.Fatal("expected 'test_user' auth token to exist") } if at.Token != "123" { t.Errorf("want token: 123, got token: %s", at.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 `pops` 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(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", Email: "test@example.com", }, }, }, }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutputs: []string{ "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("user") if at == nil { t.Fatal("expected 'user' auth token to exist") } if at.Token != "mock-token" { t.Errorf("want token: mock-token, got token: %s", at.Token) } }, }, // 7. SSO token with both access and refresh expired. // The `whoami` command triggers re-auth via the processToken flow. // The user declines re-authentication. { Args: "whoami", ConfigFile: &config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "mock-token", Email: "test@example.com", RefreshToken: "mock-refresh", AccessExpiresAt: time.Now().Add(-600 * time.Second).Format(time.RFC3339), RefreshExpiresAt: time.Now().Add(-600 * time.Second).Format(time.RFC3339), }, }, }, }, Stdin: []string{ "N", // decline re-authentication }, Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) }, WantOutput: "Your auth token has expired and needs re-authentication", DontWantOutput: "{Latitude:1 Longitude:2 X:3 Y:4}", }, // 8. SSO token with both access and refresh expired. // The user accepts re-auth, and the `pops` command executes after. { Args: "pops", API: &mock.API{ AllDatacentersFn: func(_ context.Context) ([]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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeSSO, Token: "mock-token", Email: "test@example.com", RefreshToken: "mock-refresh", AccessExpiresAt: time.Now().Add(-300 * time.Second).Format(time.RFC3339), RefreshExpiresAt: time.Now().Add(-300 * time.Second).Format(time.RFC3339), }, }, }, }, 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 auth token has expired and needs re-authentication", "Starting a local server to handle the authentication flow.", "Session token 'user' has been stored.", "{Latitude:1 Longitude:2 X:3 Y:4}", }, Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { at := opts.Config.GetAuthToken("user") if at == nil { t.Fatal("expected 'user' auth token to exist") } if at.Token != "123" { t.Errorf("want token: 123, got token: %s", at.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) } ================================================ FILE: pkg/commands/stats/aggregate.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" ) // AggregateCommand exposes the Aggregate Stats API. type AggregateCommand struct { argparser.Base by string formatFlag string from string jsonFlag bool region string to string } // NewAggregateCommand is the "stats aggregate" subcommand. func NewAggregateCommand(parent argparser.Registerer, g *global.Data) *AggregateCommand { var c AggregateCommand c.Globals = g c.CmdClause = parent.Command("aggregate", "View aggregated stats across all services") // Optional. c.CmdClause.Flag("from", "Start time").StringVar(&c.from) c.CmdClause.Flag("to", "End 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)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *AggregateCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } input := fastly.GetAggregateInput{} 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.GetAggregateJSON(context.TODO(), &input, &envelope) if err != nil { c.Globals.ErrLog.Add(err) return err } if envelope.Status != statusSuccess { return fmt.Errorf("non-success response: %s", envelope.Msg) } switch c.formatFlag { case "json": for _, block := range envelope.Data { if err := json.NewEncoder(out).Encode(block); err != nil { c.Globals.ErrLog.Add(err) return err } } default: writeHeader(out, envelope.Meta) for _, block := range envelope.Data { if err := fmtBlock(out, "aggregate", block); err != nil { c.Globals.ErrLog.Add(err) return err } } } return nil } ================================================ FILE: pkg/commands/stats/aggregate_test.go ================================================ package stats_test import ( "bytes" "context" "encoding/json" "io" "testing" "github.com/fastly/go-fastly/v15/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 TestAggregate(t *testing.T) { args := testutil.SplitArgs scenarios := []struct { name string args []string api mock.API wantError string wantOutput string }{ { name: "success table", args: args("stats aggregate"), api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, wantOutput: "From:", }, { name: "success json", args: args("stats aggregate --format=json"), api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, wantOutput: `"start_time":0`, }, { name: "success json alias", args: args("stats aggregate --json"), api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, wantOutput: `"start_time":0`, }, { name: "verbose json combo", args: args("stats aggregate --json --verbose"), api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, wantError: "invalid flag combination", }, { name: "non-success status", args: args("stats aggregate"), api: mock.API{GetAggregateJSONFn: getAggregateJSONNonSuccess}, wantError: "non-success response", }, { name: "api error", args: args("stats aggregate"), api: mock.API{GetAggregateJSONFn: getAggregateJSONError}, wantError: errTest.Error(), }, } for _, tc := range scenarios { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { opts := testutil.MockGlobalData(tc.args, &stdout) opts.APIClientFactory = mock.APIClient(tc.api) return opts, nil } err := app.Run(tc.args, nil) testutil.AssertErrorContains(t, err, tc.wantError) testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) }) } } func getAggregateJSONOK(_ context.Context, _ *fastly.GetAggregateInput, 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 getAggregateJSONNonSuccess(_ context.Context, _ *fastly.GetAggregateInput, o any) error { msg := []byte(`{"status": "error", "msg": "bad request", "meta": {}, "data": []}`) return json.Unmarshal(msg, o) } func getAggregateJSONError(_ context.Context, _ *fastly.GetAggregateInput, _ any) error { return errTest } ================================================ FILE: pkg/commands/stats/doc.go ================================================ // Package stats contains commands to inspect Fastly statistic data. package stats ================================================ FILE: pkg/commands/stats/domain_inspector.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "strconv" "time" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // DomainInspectorCommand exposes the Domain Inspector API. type DomainInspectorCommand struct { argparser.Base cursor string datacenters []string domains []string downsample string formatFlag string from string groupBy []string jsonFlag bool limit int metrics []string regions []string serviceName argparser.OptionalServiceNameID to string } // NewDomainInspectorCommand is the "stats domain-inspector" subcommand. func NewDomainInspectorCommand(parent argparser.Registerer, g *global.Data) *DomainInspectorCommand { var c DomainInspectorCommand c.Globals = g c.CmdClause = parent.Command("domain-inspector", "View domain metrics for a Fastly service") // 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, }) c.CmdClause.Flag("from", "Start time (RFC3339 or Unix timestamp)").StringVar(&c.from) c.CmdClause.Flag("to", "End time (RFC3339 or Unix timestamp)").StringVar(&c.to) c.CmdClause.Flag("downsample", "Sample window (minute/hour/day)").EnumVar(&c.downsample, "minute", "hour", "day") c.CmdClause.Flag("metric", "Metrics to retrieve (repeatable, up to 10)").StringsVar(&c.metrics) c.CmdClause.Flag("domain", "Filter by domain (repeatable)").StringsVar(&c.domains) c.CmdClause.Flag("datacenter", "Filter by POP (repeatable)").StringsVar(&c.datacenters) c.CmdClause.Flag("region", "Filter by region (repeatable)").StringsVar(&c.regions) c.CmdClause.Flag("group-by", "Dimensions to group by (repeatable)").StringsVar(&c.groupBy) c.CmdClause.Flag("limit", "Max entries to return").IntVar(&c.limit) c.CmdClause.Flag("cursor", "Pagination cursor from a previous response").StringVar(&c.cursor) c.CmdClause.Flag("format", "Output format (json)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *DomainInspectorCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } 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.GetDomainMetricsInput{ ServiceID: serviceID, Datacenters: c.datacenters, Domains: c.domains, GroupBy: c.groupBy, Metrics: c.metrics, Regions: c.regions, } if c.cursor != "" { input.Cursor = &c.cursor } if c.downsample != "" { input.Downsample = &c.downsample } if c.from != "" { t, err := parseTime(c.from) if err != nil { return fmt.Errorf("invalid --from value: %w", err) } input.Start = &t } if c.to != "" { t, err := parseTime(c.to) if err != nil { return fmt.Errorf("invalid --to value: %w", err) } input.End = &t } if c.limit > 0 { input.Limit = &c.limit } switch c.formatFlag { case "json": var envelope struct { Status *string `json:"status"` } var raw json.RawMessage if err := c.Globals.APIClient.GetDomainMetricsForServiceJSON(context.TODO(), &input, &raw); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) return err } if err := json.Unmarshal(raw, &envelope); err != nil { return err } if fastly.ToValue(envelope.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(envelope.Status)) } _, err := out.Write(raw) if err != nil { return err } fmt.Fprintln(out) return nil default: resp, err := c.Globals.APIClient.GetDomainMetricsForService(context.TODO(), &input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) return err } if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) } text.PrintDomainInspectorTbl(out, resp) return nil } } func parseTime(s string) (time.Time, error) { if t, err := time.Parse(time.RFC3339, s); err == nil { return t, nil } if epoch, err := strconv.ParseInt(s, 10, 64); err == nil { return time.Unix(epoch, 0), nil } return time.Time{}, fmt.Errorf("cannot parse %q as RFC3339 or Unix timestamp", s) } ================================================ FILE: pkg/commands/stats/domain_inspector_test.go ================================================ package stats_test import ( "bytes" "context" "encoding/json" "fmt" "io" "testing" "time" "github.com/fastly/go-fastly/v15/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 TestDomainInspector(t *testing.T) { args := testutil.SplitArgs scenarios := []struct { name string args []string api mock.API wantError string wantOutput string }{ { name: "success table", args: args("stats domain-inspector --service-id 123"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsOK, }, wantOutput: "REQUESTS", }, { name: "success json", args: args("stats domain-inspector --service-id 123 --format=json"), api: mock.API{ GetDomainMetricsForServiceJSONFn: getDomainMetricsJSONOK, }, wantOutput: "status", }, { name: "success json alias", args: args("stats domain-inspector --service-id 123 --json"), api: mock.API{ GetDomainMetricsForServiceJSONFn: getDomainMetricsJSONOK, }, wantOutput: "status", }, { name: "verbose json combo", args: args("stats domain-inspector --service-id 123 --json --verbose"), api: mock.API{}, wantError: "invalid flag combination", }, { name: "non-success status", args: args("stats domain-inspector --service-id 123"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsNonSuccess, }, wantError: "non-success response", }, { name: "missing service ID", args: args("stats domain-inspector"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsOK, }, wantError: "error reading service", }, { name: "non-success status json", args: args("stats domain-inspector --service-id 123 --format=json"), api: mock.API{ GetDomainMetricsForServiceJSONFn: getDomainMetricsJSONNonSuccess, }, wantError: "non-success response", }, { name: "api error", args: args("stats domain-inspector --service-id 123"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsError, }, wantError: errTest.Error(), }, { name: "from RFC3339 maps to Start", args: args("stats domain-inspector --service-id 123 --from 2024-01-15T10:00:00Z"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), }, wantOutput: "REQUESTS", }, { name: "from Unix epoch maps to Start", args: args("stats domain-inspector --service-id 123 --from 1705312800"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Unix(1705312800, 0)), }, wantOutput: "REQUESTS", }, { name: "to RFC3339 maps to End", args: args("stats domain-inspector --service-id 123 --to 2024-01-15T11:00:00Z"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), }, wantOutput: "REQUESTS", }, { name: "from invalid format error", args: args("stats domain-inspector --service-id 123 --from not-a-time"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsOK, }, wantError: "invalid --from value", }, { name: "to invalid format error", args: args("stats domain-inspector --service-id 123 --to not-a-time"), api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsOK, }, wantError: "invalid --to value", }, } for _, tc := range scenarios { t.Run(tc.name, func(t *testing.T) { // Clear FASTLY_SERVICE_ID for tests that validate missing service ID if tc.name == "missing service ID" { t.Setenv("FASTLY_SERVICE_ID", "") } var stdout bytes.Buffer app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { opts := testutil.MockGlobalData(tc.args, &stdout) opts.APIClientFactory = mock.APIClient(tc.api) return opts, nil } err := app.Run(tc.args, nil) testutil.AssertErrorContains(t, err, tc.wantError) testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) }) } } func domainMetricsOKResult() (*fastly.DomainInspector, error) { return &fastly.DomainInspector{ Status: fastly.ToPointer("success"), Meta: &fastly.DomainMeta{ Start: fastly.ToPointer("2024-01-15T10:00:00Z"), End: fastly.ToPointer("2024-01-15T11:00:00Z"), }, Data: []*fastly.DomainData{ { Values: []*fastly.DomainMetrics{ { Requests: fastly.ToPointer(uint64(100)), Bandwidth: fastly.ToPointer(uint64(5000)), }, }, }, }, }, nil } func getDomainMetricsOK(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return domainMetricsOKResult() } func getDomainMetricsJSONOK(_ context.Context, _ *fastly.GetDomainMetricsInput, dst any) error { msg := []byte(`{"status":"success","data":[]}`) return json.Unmarshal(msg, dst) } func getDomainMetricsJSONNonSuccess(_ context.Context, _ *fastly.GetDomainMetricsInput, dst any) error { msg := []byte(`{"status":"error","data":[]}`) return json.Unmarshal(msg, dst) } func getDomainMetricsNonSuccess(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return &fastly.DomainInspector{ Status: fastly.ToPointer("error"), }, nil } func getDomainMetricsAssertStart(want time.Time) func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return func(_ context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { if i.Start == nil { return nil, fmt.Errorf("expected Start to be set, got nil") } if !i.Start.Equal(want) { return nil, fmt.Errorf("expected Start %v, got %v", want, *i.Start) } return domainMetricsOKResult() } } func getDomainMetricsAssertEnd(want time.Time) func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return func(_ context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { if i.End == nil { return nil, fmt.Errorf("expected End to be set, got nil") } if !i.End.Equal(want) { return nil, fmt.Errorf("expected End %v, got %v", want, *i.End) } return domainMetricsOKResult() } } func getDomainMetricsError(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return nil, errTest } ================================================ FILE: pkg/commands/stats/historical.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/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 field string formatFlag string from string jsonFlag bool 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") // 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, }) c.CmdClause.Flag("field", "Filter to a single stats field (e.g. bandwidth, requests)").StringVar(&c.field) 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)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *HistoricalCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } 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.field != "" { input.Field = &c.field } 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(context.TODO(), &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": if err := writeBlocksJSON(out, envelope.Data); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) return err } default: writeHeader(out, envelope.Meta) if c.field != "" { for _, block := range envelope.Data { if err := fmtFieldLine(out, c.field, block); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) return err } } } else if err := writeBlocks(out, serviceID, envelope.Data); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) return err } } 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, blocks []statsResponseData) error { for _, block := range blocks { if err := json.NewEncoder(out).Encode(block); err != nil { return err } } return nil } ================================================ FILE: pkg/commands/stats/historical_test.go ================================================ package stats_test import ( "context" "encoding/json" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/stats" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestHistorical(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "success", Args: "--service-id=123", API: &mock.API{GetStatsJSONFn: getStatsJSONOK}, WantOutput: historicalOK, }, { Name: "api failure", Args: "--service-id=123", API: &mock.API{GetStatsJSONFn: getStatsJSONError}, WantError: errTest.Error(), }, { Name: "success with json format", Args: "--service-id=123 --format=json", API: &mock.API{GetStatsJSONFn: getStatsJSONOK}, WantOutput: historicalJSONOK, }, { Name: "success with json alias", Args: "--service-id=123 --json", API: &mock.API{GetStatsJSONFn: getStatsJSONOK}, WantOutput: historicalJSONOK, }, { Name: "verbose json combo", Args: "--service-id=123 --json --verbose", API: &mock.API{GetStatsJSONFn: getStatsJSONOK}, WantError: "invalid flag combination", }, { Name: "success with field filter", Args: "--service-id=123 --field=bandwidth", API: &mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, WantOutput: "bandwidth: 123", DontWantOutputs: []string{"Service ID:", "Hit Rate:", "Avg Hit Time:"}, }, { Name: "success with field filter and json format", Args: "--service-id=123 --field=bandwidth --format=json", API: &mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, WantOutput: `"bandwidth":123`, }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "historical"}, scenarios) } 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 unmarshalStatsJSON(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 getStatsJSONOK(_ context.Context, _ *fastly.GetStatsInput, o any) error { return unmarshalStatsJSON(o) } func unmarshalStatsFieldJSON(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, "bandwidth": 123}] }`) return json.Unmarshal(msg, o) } func getStatsJSONFieldOK(_ context.Context, i *fastly.GetStatsInput, o any) error { if i.Field == nil || *i.Field != "bandwidth" { return errTest } return unmarshalStatsFieldJSON(o) } func getStatsJSONError(_ context.Context, _ *fastly.GetStatsInput, _ any) error { return errTest } ================================================ FILE: pkg/commands/stats/obj.go ================================================ package stats // The structs in this file are similar to those in go-fastly, but // intended for json use rather than mapstructure. type statsResponse struct { Status string `json:"status"` Msg string `json:"msg"` Meta statsResponseMeta `json:"meta"` Data []statsResponseData `json:"data"` } type statsResponseMeta struct { From string `json:"from"` To string `json:"to"` By string `json:"by"` Region string `json:"region"` } type statsResponseData map[string]any type realtimeResponse struct { Timestamp uint64 `json:"timestamp"` Data []realtimeResponseData `json:"data"` } type realtimeResponseData struct { Recorded float64 `json:"recorded"` Aggregated statsResponseData `json:"aggregated"` } ================================================ FILE: pkg/commands/stats/origin_inspector.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // OriginInspectorCommand exposes the Origin Inspector API. type OriginInspectorCommand struct { argparser.Base cursor string datacenters []string downsample string formatFlag string from string groupBy []string hosts []string jsonFlag bool limit int metrics []string regions []string serviceName argparser.OptionalServiceNameID to string } // NewOriginInspectorCommand is the "stats origin-inspector" subcommand. func NewOriginInspectorCommand(parent argparser.Registerer, g *global.Data) *OriginInspectorCommand { var c OriginInspectorCommand c.Globals = g c.CmdClause = parent.Command("origin-inspector", "View origin metrics for a Fastly service") // 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, }) c.CmdClause.Flag("from", "Start time (RFC3339 or Unix timestamp)").StringVar(&c.from) c.CmdClause.Flag("to", "End time (RFC3339 or Unix timestamp)").StringVar(&c.to) c.CmdClause.Flag("downsample", "Sample window (minute/hour/day)").EnumVar(&c.downsample, "minute", "hour", "day") c.CmdClause.Flag("metric", "Metrics to retrieve (repeatable, up to 10)").StringsVar(&c.metrics) c.CmdClause.Flag("host", "Filter by origin host (repeatable)").StringsVar(&c.hosts) c.CmdClause.Flag("datacenter", "Filter by POP (repeatable)").StringsVar(&c.datacenters) c.CmdClause.Flag("region", "Filter by region (repeatable)").StringsVar(&c.regions) c.CmdClause.Flag("group-by", "Dimensions to group by (repeatable)").StringsVar(&c.groupBy) c.CmdClause.Flag("limit", "Max entries to return").IntVar(&c.limit) c.CmdClause.Flag("cursor", "Pagination cursor from a previous response").StringVar(&c.cursor) c.CmdClause.Flag("format", "Output format (json)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *OriginInspectorCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } 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.GetOriginMetricsInput{ ServiceID: serviceID, Datacenters: c.datacenters, GroupBy: c.groupBy, Hosts: c.hosts, Metrics: c.metrics, Regions: c.regions, } if c.cursor != "" { input.Cursor = &c.cursor } if c.downsample != "" { input.Downsample = &c.downsample } if c.from != "" { t, err := parseTime(c.from) if err != nil { return fmt.Errorf("invalid --from value: %w", err) } input.Start = &t } if c.to != "" { t, err := parseTime(c.to) if err != nil { return fmt.Errorf("invalid --to value: %w", err) } input.End = &t } if c.limit > 0 { input.Limit = &c.limit } switch c.formatFlag { case "json": var envelope struct { Status *string `json:"status"` } var raw json.RawMessage if err := c.Globals.APIClient.GetOriginMetricsForServiceJSON(context.TODO(), &input, &raw); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) return err } if err := json.Unmarshal(raw, &envelope); err != nil { return err } if fastly.ToValue(envelope.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(envelope.Status)) } _, err := out.Write(raw) if err != nil { return err } fmt.Fprintln(out) return nil default: resp, err := c.Globals.APIClient.GetOriginMetricsForService(context.TODO(), &input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) return err } if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) } text.PrintOriginInspectorTbl(out, resp) return nil } } ================================================ FILE: pkg/commands/stats/origin_inspector_test.go ================================================ package stats_test import ( "bytes" "context" "encoding/json" "fmt" "io" "testing" "time" "github.com/fastly/go-fastly/v15/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 TestOriginInspector(t *testing.T) { args := testutil.SplitArgs scenarios := []struct { name string args []string api mock.API wantError string wantOutput string }{ { name: "success table", args: args("stats origin-inspector --service-id 123"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsOK, }, wantOutput: "RESPONSES", }, { name: "success json", args: args("stats origin-inspector --service-id 123 --format=json"), api: mock.API{ GetOriginMetricsForServiceJSONFn: getOriginMetricsJSONOK, }, wantOutput: "status", }, { name: "success json alias", args: args("stats origin-inspector --service-id 123 --json"), api: mock.API{ GetOriginMetricsForServiceJSONFn: getOriginMetricsJSONOK, }, wantOutput: "status", }, { name: "verbose json combo", args: args("stats origin-inspector --service-id 123 --json --verbose"), api: mock.API{}, wantError: "invalid flag combination", }, { name: "non-success status", args: args("stats origin-inspector --service-id 123"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsNonSuccess, }, wantError: "non-success response", }, { name: "missing service ID", args: args("stats origin-inspector"), api: mock.API{}, wantError: "error reading service", }, { name: "non-success status json", args: args("stats origin-inspector --service-id 123 --format=json"), api: mock.API{ GetOriginMetricsForServiceJSONFn: getOriginMetricsJSONNonSuccess, }, wantError: "non-success response", }, { name: "api error", args: args("stats origin-inspector --service-id 123"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsError, }, wantError: errTest.Error(), }, { name: "from RFC3339 maps to Start", args: args("stats origin-inspector --service-id 123 --from 2024-01-15T10:00:00Z"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), }, wantOutput: "RESPONSES", }, { name: "from Unix epoch maps to Start", args: args("stats origin-inspector --service-id 123 --from 1705312800"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Unix(1705312800, 0)), }, wantOutput: "RESPONSES", }, { name: "to RFC3339 maps to End", args: args("stats origin-inspector --service-id 123 --to 2024-01-15T11:00:00Z"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), }, wantOutput: "RESPONSES", }, { name: "from invalid format error", args: args("stats origin-inspector --service-id 123 --from not-a-time"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsOK, }, wantError: "invalid --from value", }, { name: "to invalid format error", args: args("stats origin-inspector --service-id 123 --to not-a-time"), api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsOK, }, wantError: "invalid --to value", }, } for _, tc := range scenarios { t.Run(tc.name, func(t *testing.T) { // Clear FASTLY_SERVICE_ID for tests that validate missing service ID if tc.name == "missing service ID" { t.Setenv("FASTLY_SERVICE_ID", "") } var stdout bytes.Buffer app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { opts := testutil.MockGlobalData(tc.args, &stdout) opts.APIClientFactory = mock.APIClient(tc.api) return opts, nil } err := app.Run(tc.args, nil) testutil.AssertErrorContains(t, err, tc.wantError) testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) }) } } func originMetricsOKResult() (*fastly.OriginInspector, error) { return &fastly.OriginInspector{ Status: fastly.ToPointer("success"), Meta: &fastly.OriginMeta{ Start: fastly.ToPointer("2024-01-15T10:00:00Z"), End: fastly.ToPointer("2024-01-15T11:00:00Z"), }, Data: []*fastly.OriginData{ { Values: []*fastly.OriginMetrics{ { Responses: fastly.ToPointer(uint64(200)), Status2xx: fastly.ToPointer(uint64(180)), Status4xx: fastly.ToPointer(uint64(15)), Status5xx: fastly.ToPointer(uint64(5)), }, }, }, }, }, nil } func getOriginMetricsOK(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return originMetricsOKResult() } func getOriginMetricsJSONOK(_ context.Context, _ *fastly.GetOriginMetricsInput, dst any) error { msg := []byte(`{"status":"success","data":[]}`) return json.Unmarshal(msg, dst) } func getOriginMetricsJSONNonSuccess(_ context.Context, _ *fastly.GetOriginMetricsInput, dst any) error { msg := []byte(`{"status":"error","data":[]}`) return json.Unmarshal(msg, dst) } func getOriginMetricsNonSuccess(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return &fastly.OriginInspector{ Status: fastly.ToPointer("error"), }, nil } func getOriginMetricsAssertStart(want time.Time) func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return func(_ context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { if i.Start == nil { return nil, fmt.Errorf("expected Start to be set, got nil") } if !i.Start.Equal(want) { return nil, fmt.Errorf("expected Start %v, got %v", want, *i.Start) } return originMetricsOKResult() } } func getOriginMetricsAssertEnd(want time.Time) func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return func(_ context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { if i.End == nil { return nil, fmt.Errorf("expected End to be set, got nil") } if !i.End.Equal(want) { return nil, fmt.Errorf("expected End %v, got %v", want, *i.End) } return originMetricsOKResult() } } func getOriginMetricsError(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return nil, errTest } ================================================ FILE: pkg/commands/stats/realtime.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/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 jsonFlag bool 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") // 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, }) c.CmdClause.Flag("format", "Output format (json)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *RealtimeCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } 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(context.TODO(), &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(context.TODO(), &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 } } } } ================================================ FILE: pkg/commands/stats/realtime_test.go ================================================ package stats_test import ( "bytes" "io" "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" ) func TestRealtime(t *testing.T) { args := testutil.SplitArgs scenarios := []struct { name string args []string api mock.API wantError string }{ { name: "verbose json combo", args: args("stats realtime --service-id 123 --json --verbose"), api: mock.API{}, wantError: "invalid flag combination", }, { name: "verbose format json combo", args: args("stats realtime --service-id 123 --format=json --verbose"), api: mock.API{}, wantError: "invalid flag combination", }, } for _, tc := range scenarios { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { opts := testutil.MockGlobalData(tc.args, &stdout) opts.APIClientFactory = mock.APIClient(tc.api) return opts, nil } err := app.Run(tc.args, nil) testutil.AssertErrorContains(t, err, tc.wantError) }) } } ================================================ FILE: pkg/commands/stats/regions.go ================================================ package stats import ( "context" "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(context.TODO()) 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 } ================================================ FILE: pkg/commands/stats/regions_test.go ================================================ package stats_test import ( "context" "errors" "testing" "github.com/fastly/go-fastly/v15/fastly" root "github.com/fastly/cli/pkg/commands/stats" "github.com/fastly/cli/pkg/mock" "github.com/fastly/cli/pkg/testutil" ) func TestRegions(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "success", Args: "", API: &mock.API{GetRegionsFn: getRegionsOK}, WantOutput: "foo\nbar\nbaz\n", }, { Name: "api error", Args: "", API: &mock.API{GetRegionsFn: getRegionsError}, WantError: errTest.Error(), }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "regions"}, scenarios) } func getRegionsOK(_ context.Context) (*fastly.RegionsResponse, error) { return &fastly.RegionsResponse{ Data: []string{"foo", "bar", "baz"}, }, nil } var errTest = errors.New("fixture error") func getRegionsError(_ context.Context) (*fastly.RegionsResponse, error) { return nil, errTest } ================================================ FILE: pkg/commands/stats/root.go ================================================ package stats import ( "io" "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "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 statistics for Fastly services: historical, realtime, aggregate, usage, domain and origin inspection") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } func resolveJSONFormat(formatFlag *string, jsonFlag bool, g *global.Data) error { if jsonFlag { *formatFlag = "json" } if *formatFlag == "json" { g.Flags.JSON = true if g.Verbose() { return fsterr.ErrInvalidVerboseJSONCombo } } return nil } ================================================ FILE: pkg/commands/stats/template.go ================================================ package stats import ( "fmt" "io" "text/template" "time" "github.com/mitchellh/mapstructure" "github.com/fastly/go-fastly/v15/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 fmtFieldLine(out io.Writer, field string, block statsResponseData) error { 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() val, ok := block[field] if !ok { return fmt.Errorf("field %q not found in stats response", field) } _, err := fmt.Fprintf(out, "%s\t%s: %v\n", startTime.Format(time.RFC3339), field, val) return err } func fmtBlock(out io.Writer, service string, block statsResponseData) error { var agg fastly.Stats if err := mapstructure.Decode(block, &agg); err != nil { return err } aggHits := fastly.ToValue(agg.Hits) aggMiss := fastly.ToValue(agg.Miss) aggErrs := fastly.ToValue(agg.Errors) // 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%%", fastly.ToValue(agg.HitRatio)*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) } ================================================ FILE: pkg/commands/stats/usage.go ================================================ package stats import ( "context" "encoding/json" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/text" ) // UsageCommand exposes the Usage Stats API. type UsageCommand struct { argparser.Base by string byService bool formatFlag string from string jsonFlag bool region string to string } // NewUsageCommand is the "stats usage" subcommand. func NewUsageCommand(parent argparser.Registerer, g *global.Data) *UsageCommand { var c UsageCommand c.Globals = g c.CmdClause = parent.Command("usage", "View usage stats (bandwidth, requests)") // Optional. c.CmdClause.Flag("from", "Start time").StringVar(&c.from) c.CmdClause.Flag("to", "End 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("by-service", "Break down usage by service").BoolVar(&c.byService) c.CmdClause.Flag("format", "Output format (json)").Hidden().EnumVar(&c.formatFlag, "json") c.CmdClause.Flag("json", argparser.FlagJSONDesc).Short('j').BoolVar(&c.jsonFlag) return &c } // Exec implements the command interface. func (c *UsageCommand) Exec(_ io.Reader, out io.Writer) error { if err := resolveJSONFormat(&c.formatFlag, c.jsonFlag, c.Globals); err != nil { return err } input := fastly.GetUsageInput{} 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 } if c.byService { return c.execByService(out, &input) } return c.execPlain(out, &input) } func (c *UsageCommand) execPlain(out io.Writer, input *fastly.GetUsageInput) error { resp, err := c.Globals.APIClient.GetUsage(context.TODO(), input) if err != nil { c.Globals.ErrLog.Add(err) return err } if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Message)) } filterUsageByRegion(resp.Data, c.region) switch c.formatFlag { case "json": return writeUsageJSON(out, resp.Data) default: text.PrintUsageTbl(out, resp.Data) return nil } } func (c *UsageCommand) execByService(out io.Writer, input *fastly.GetUsageInput) error { resp, err := c.Globals.APIClient.GetUsageByService(context.TODO(), input) if err != nil { c.Globals.ErrLog.Add(err) return err } if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Message)) } filterUsageByServiceByRegion(resp.Data, c.region) switch c.formatFlag { case "json": return writeUsageByServiceJSON(out, resp.Data) default: text.PrintUsageByServiceTbl(out, resp.Data) return nil } } func writeUsageJSON(out io.Writer, data *fastly.RegionsUsage) error { if data == nil { return json.NewEncoder(out).Encode(map[string]any{}) } return json.NewEncoder(out).Encode(usageToMap(*data)) } func writeUsageByServiceJSON(out io.Writer, data *fastly.ServicesByRegionsUsage) error { if data == nil { return json.NewEncoder(out).Encode(map[string]any{}) } result := make(map[string]any) for region, services := range *data { if services == nil { continue } regionMap := make(map[string]any) for svcID, usage := range *services { regionMap[svcID] = usageEntry(usage) } result[region] = regionMap } return json.NewEncoder(out).Encode(result) } func usageToMap(data fastly.RegionsUsage) map[string]any { result := make(map[string]any) for region, usage := range data { result[region] = usageEntry(usage) } return result } func filterUsageByRegion(data *fastly.RegionsUsage, region string) { if region == "" || data == nil { return } for k := range *data { if k != region { delete(*data, k) } } } func filterUsageByServiceByRegion(data *fastly.ServicesByRegionsUsage, region string) { if region == "" || data == nil { return } for k := range *data { if k != region { delete(*data, k) } } } func usageEntry(u *fastly.Usage) map[string]any { if u == nil { return map[string]any{ "bandwidth": uint64(0), "requests": uint64(0), "compute_requests": uint64(0), } } return map[string]any{ "bandwidth": fastly.ToValue(u.Bandwidth), "requests": fastly.ToValue(u.Requests), "compute_requests": fastly.ToValue(u.ComputeRequests), } } ================================================ FILE: pkg/commands/stats/usage_test.go ================================================ package stats_test import ( "bytes" "context" "io" "strings" "testing" "github.com/fastly/go-fastly/v15/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 TestUsage(t *testing.T) { args := testutil.SplitArgs scenarios := []struct { name string args []string api mock.API wantError string wantOutput string wantAbsent string }{ { name: "success plain", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageOK}, wantOutput: "usa", }, { name: "success json", args: args("stats usage --format=json"), api: mock.API{GetUsageFn: getUsageOK}, wantOutput: "bandwidth", }, { name: "success json alias", args: args("stats usage --json"), api: mock.API{GetUsageFn: getUsageOK}, wantOutput: "bandwidth", }, { name: "verbose json combo", args: args("stats usage --json --verbose"), api: mock.API{GetUsageFn: getUsageOK}, wantError: "invalid flag combination", }, { name: "success by-service", args: args("stats usage --by-service"), api: mock.API{GetUsageByServiceFn: getUsageByServiceOK}, wantOutput: "svc123", }, { name: "success by-service json", args: args("stats usage --by-service --format=json"), api: mock.API{GetUsageByServiceFn: getUsageByServiceOK}, wantOutput: "svc123", }, { name: "nil usage entry json", args: args("stats usage --format=json"), api: mock.API{GetUsageFn: getUsageNilEntry}, wantOutput: `"bandwidth"`, }, { name: "nil usage entry table skipped", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageWithNilEntry}, wantOutput: "europe", }, { name: "region filter plain", args: args("stats usage --region=europe"), api: mock.API{GetUsageFn: getUsageMultiRegion}, wantOutput: "europe", wantAbsent: "usa", }, { name: "region filter by-service", args: args("stats usage --by-service --region=europe"), api: mock.API{GetUsageByServiceFn: getUsageByServiceMultiRegion}, wantOutput: "svc456", wantAbsent: "usa", }, { name: "non-success status", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageNonSuccess}, wantError: "non-success response", }, { name: "api error", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageError}, wantError: errTest.Error(), }, } for _, tc := range scenarios { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { opts := testutil.MockGlobalData(tc.args, &stdout) opts.APIClientFactory = mock.APIClient(tc.api) return opts, nil } err := app.Run(tc.args, nil) testutil.AssertErrorContains(t, err, tc.wantError) testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) if tc.wantAbsent != "" && strings.Contains(stdout.String(), tc.wantAbsent) { t.Errorf("output should not contain %q, got: %s", tc.wantAbsent, stdout.String()) } }) } } func getUsageOK(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return &fastly.UsageResponse{ Status: fastly.ToPointer("success"), Data: &fastly.RegionsUsage{ "usa": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(1000)), Requests: fastly.ToPointer(uint64(500)), ComputeRequests: fastly.ToPointer(uint64(100)), }, }, }, nil } func getUsageByServiceOK(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) { return &fastly.UsageByServiceResponse{ Status: fastly.ToPointer("success"), Data: &fastly.ServicesByRegionsUsage{ "usa": &fastly.ServicesUsage{ "svc123": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(1000)), Requests: fastly.ToPointer(uint64(500)), ComputeRequests: fastly.ToPointer(uint64(100)), }, }, }, }, nil } func getUsageNilEntry(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return &fastly.UsageResponse{ Status: fastly.ToPointer("success"), Data: &fastly.RegionsUsage{ "empty_region": nil, }, }, nil } func getUsageWithNilEntry(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return &fastly.UsageResponse{ Status: fastly.ToPointer("success"), Data: &fastly.RegionsUsage{ "empty_region": nil, "europe": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(2000)), Requests: fastly.ToPointer(uint64(300)), ComputeRequests: fastly.ToPointer(uint64(50)), }, }, }, nil } func getUsageMultiRegion(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return &fastly.UsageResponse{ Status: fastly.ToPointer("success"), Data: &fastly.RegionsUsage{ "usa": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(1000)), Requests: fastly.ToPointer(uint64(500)), ComputeRequests: fastly.ToPointer(uint64(100)), }, "europe": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(2000)), Requests: fastly.ToPointer(uint64(300)), ComputeRequests: fastly.ToPointer(uint64(50)), }, }, }, nil } func getUsageByServiceMultiRegion(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) { return &fastly.UsageByServiceResponse{ Status: fastly.ToPointer("success"), Data: &fastly.ServicesByRegionsUsage{ "usa": &fastly.ServicesUsage{ "svc123": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(1000)), Requests: fastly.ToPointer(uint64(500)), ComputeRequests: fastly.ToPointer(uint64(100)), }, }, "europe": &fastly.ServicesUsage{ "svc456": &fastly.Usage{ Bandwidth: fastly.ToPointer(uint64(2000)), Requests: fastly.ToPointer(uint64(300)), ComputeRequests: fastly.ToPointer(uint64(50)), }, }, }, }, nil } func getUsageNonSuccess(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return &fastly.UsageResponse{ Status: fastly.ToPointer("error"), Message: fastly.ToPointer("bad request"), }, nil } func getUsageError(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return nil, errTest } ================================================ FILE: pkg/commands/tls/config/config_test.go ================================================ package config_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetCustomTLSConfigurationFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListCustomTLSConfigurationsFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/config/describe.go ================================================ package config import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/config/doc.go ================================================ // Package config contains commands to inspect and manipulate Fastly TLS // configuration. package config ================================================ FILE: pkg/commands/tls/config/list.go ================================================ package config import ( "context" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/config/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/config/update.go ================================================ package config import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/activation/activation_test.go ================================================ package activation_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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" mockResponseConfigID = "789" mockResponseDomain = "tls.example.com" validateAPIError = "validate API error" validateAPISuccess = "validate API success" validateMissingIDFlag = "validate missing --id flag" ) func TestTLSCustomActivationEnable(t *testing.T) { scenarios := []testutil.CLIScenario{ { Name: "validate missing CertID flag", Args: fmt.Sprintf("--tls-config-id %s --tls-domain %s", mockResponseConfigID, mockResponseDomain), WantError: "required flag --cert-id not provided", }, { Name: "validate missing ConfigID flag", Args: fmt.Sprintf("--cert-id %s --tls-domain %s", mockResponseCertID, mockResponseDomain), WantError: "required flag --tls-config-id not provided", }, { Name: "validate missing Domain Flag", Args: fmt.Sprintf("--cert-id %s --tls-config-id %s", mockResponseCertID, mockResponseConfigID), WantError: "required flag --tls-domain not provided", }, { Name: validateAPIError, API: &mock.API{ CreateTLSActivationFn: func(_ context.Context, _ *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { return nil, testutil.Err }, }, Args: fmt.Sprintf("--cert-id %s --tls-config-id %s --tls-domain %s", mockResponseCertID, mockResponseConfigID, mockResponseDomain), WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ CreateTLSActivationFn: func(_ context.Context, _ *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { return &fastly.TLSActivation{ ID: mockResponseID, }, nil }, }, Args: fmt.Sprintf("--cert-id %s --tls-config-id %s --tls-domain %s", mockResponseCertID, mockResponseConfigID, mockResponseDomain), WantOutput: fmt.Sprintf("SUCCESS: Enabled TLS Activation '%s' (Certificate '%s', Configuration '%s')", mockResponseID, mockResponseCertID, mockResponseConfigID), }, } 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(_ context.Context, _ *fastly.DeleteTLSActivationInput) error { return testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ DeleteTLSActivationFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetTLSActivationFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListTLSActivationsFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/custom/activation/create.go ================================================ package activation import ( "context" "io" "github.com/fastly/go-fastly/v15/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("tls-config-id", "Alphanumeric string identifying a TLS configuration").Required().StringVar(&c.tlsConfigID) c.CmdClause.Flag("tls-domain", "The domain name associated with the TLS certificate").Required().StringVar(&c.tlsDomain) return &c } // CreateCommand calls the Fastly API to create an appropriate resource. type CreateCommand struct { argparser.Base certID string tlsConfigID string tlsDomain 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(context.TODO(), input) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "TLS Configuration ID": c.tlsConfigID, "TLS Activation Certificate ID": c.certID, "TLS Domain": c.tlsDomain, }) return err } text.Success(out, "Enabled TLS Activation '%s' (Certificate '%s', Configuration '%s')", r.ID, c.certID, c.tlsConfigID) 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.Configuration = &fastly.TLSConfiguration{ID: c.tlsConfigID} input.Certificate = &fastly.CustomTLSCertificate{ID: c.certID} input.Domain = &fastly.TLSDomain{ID: c.tlsDomain} return &input } ================================================ FILE: pkg/commands/tls/custom/activation/delete.go ================================================ package activation import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/activation/describe.go ================================================ package activation import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/activation/doc.go ================================================ // Package activation contains commands to inspect and manipulate Fastly custom // TLS activations. package activation ================================================ FILE: pkg/commands/tls/custom/activation/list.go ================================================ package activation import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/activation/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/custom/activation/update.go ================================================ package activation import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/certificate/certificate_test.go ================================================ package certificate_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.DeleteCustomTLSCertificateInput) error { return testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ DeleteCustomTLSCertificateFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetCustomTLSCertificateFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListCustomTLSCertificatesFn: func(_ context.Context, _ *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 (auth: 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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) } ================================================ FILE: pkg/commands/tls/custom/certificate/create.go ================================================ package certificate import ( "context" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/certificate/delete.go ================================================ package certificate import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/certificate/describe.go ================================================ package certificate import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/certificate/doc.go ================================================ // Package certificate contains commands to inspect and manipulate Fastly // custom TLS certificates. package certificate ================================================ FILE: pkg/commands/tls/custom/certificate/list.go ================================================ package certificate import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/certificate/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/custom/certificate/testdata/certificate.crt ================================================ this is a fake cert ================================================ FILE: pkg/commands/tls/custom/certificate/update.go ================================================ package certificate import ( "context" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/doc.go ================================================ // Package custom contains commands to inspect and manipulate Fastly custom TLS // certificates. package custom ================================================ FILE: pkg/commands/tls/custom/domain/doc.go ================================================ // Package domain contains commands to inspect and manipulate Fastly custom TLS // domains. package domain ================================================ FILE: pkg/commands/tls/custom/domain/domain_test.go ================================================ package domain_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListTLSDomainsFn: func(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/custom/domain/list.go ================================================ package domain import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/domain/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/custom/privatekey/create.go ================================================ package privatekey import ( "context" "fmt" "io" "os" "path/filepath" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/privatekey/delete.go ================================================ package privatekey import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/privatekey/describe.go ================================================ package privatekey import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/privatekey/doc.go ================================================ // Package privatekey contains commands to inspect and manipulate Fastly custom // TLS private keys. package privatekey ================================================ FILE: pkg/commands/tls/custom/privatekey/list.go ================================================ package privatekey import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/custom/privatekey/privatekey_test.go ================================================ package privatekey_test import ( "context" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.DeletePrivateKeyInput) error { return testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ DeletePrivateKeyFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetPrivateKeyFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListPrivateKeysFn: func(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/custom/privatekey/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/custom/privatekey/testdata/testkey.pem ================================================ this is a test key ================================================ FILE: pkg/commands/tls/custom/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/platform/create.go ================================================ package platform import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/platform/delete.go ================================================ package platform import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/platform/describe.go ================================================ package platform import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/platform/doc.go ================================================ // Package platform contains commands to inspect and manipulate Fastly batch TLS // certificates. package platform ================================================ FILE: pkg/commands/tls/platform/list.go ================================================ package platform import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/platform/platform_test.go ================================================ package platform_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *fastly.DeleteBulkCertificateInput) error { return testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ DeleteBulkCertificateFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetBulkCertificateFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListBulkCertificatesFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/platform/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/platform/update.go ================================================ package platform import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/subscription/create.go ================================================ package subscription import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/subscription/delete.go ================================================ package subscription import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/subscription/describe.go ================================================ package subscription import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/subscription/doc.go ================================================ // Package subscription contains commands to inspect and manipulate Fastly // procured TLS certificates. package subscription ================================================ FILE: pkg/commands/tls/subscription/list.go ================================================ package subscription import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tls/subscription/root.go ================================================ 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") } ================================================ FILE: pkg/commands/tls/subscription/subscription_test.go ================================================ package subscription_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return nil, testutil.Err }, }, Args: "--domain example.com", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ CreateTLSSubscriptionFn: func(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, 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(_ context.Context, _ *fastly.DeleteTLSSubscriptionInput) error { return testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ DeleteTLSSubscriptionFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ GetTLSSubscriptionFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) { return nil, testutil.Err }, }, WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ ListTLSSubscriptionsFn: func(_ context.Context, _ *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(_ context.Context, _ *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return nil, testutil.Err }, }, Args: "--id example", WantError: testutil.Err.Error(), }, { Name: validateAPISuccess, API: &mock.API{ UpdateTLSSubscriptionFn: func(_ context.Context, _ *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) } ================================================ FILE: pkg/commands/tls/subscription/update.go ================================================ package subscription import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/tools/doc.go ================================================ // Package tools contains tools for working with the Fastly platform. package tools ================================================ FILE: pkg/commands/tools/domain/doc.go ================================================ // Package domain contains Domain Discovery API tools. package domain ================================================ FILE: pkg/commands/tools/domain/root.go ================================================ package domain import ( "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" ) // RootCommand is the parent command for all tool subcommands in this package. // It should be installed under the primary `tools` 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 tools 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, "Domain Discovery API tools") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/tools/domain/status.go ================================================ package domain import ( "context" "errors" "fmt" "io" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/tools/status" "github.com/fastly/cli/pkg/argparser" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" ) // GetDomainStatusCommand calls the Fastly API to check the availability of a domain name. type GetDomainStatusCommand struct { argparser.Base argparser.JSONOutput // Required. domain string // Optional. scope argparser.OptionalString } // NewDomainStatusCommand returns a usable DomainStatusCommand registered under the parent. func NewDomainStatusCommand(parent argparser.Registerer, g *global.Data) *GetDomainStatusCommand { cmd := GetDomainStatusCommand{ Base: argparser.Base{ Globals: g, }, } cmd.CmdClause = parent.Command("status", "Check domain name availability") // Required. cmd.CmdClause.Arg("domain", "Domain name to check").Required().StringVar(&cmd.domain) // Optional. cmd.RegisterFlagBool(cmd.JSONFlag()) cmd.CmdClause.Flag("scope", "Specify `--scope=estimate` to perform an “estimated” availability check, which checks the DNS and domain aftermarkets, not domain registries").Action(cmd.scope.Set).StringVar(&cmd.scope.Value) return &cmd } // Exec invokes the application logic for the command. func (g *GetDomainStatusCommand) Exec(_ io.Reader, out io.Writer) error { if g.Globals.Verbose() && g.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &status.GetInput{ Domain: g.domain, } if g.scope.WasSet { scope := status.Scope(g.scope.Value) if scope != status.ScopeEstimate { return fsterr.RemediationError{ Inner: errors.New("invalid scope provided"), Remediation: "Use `--scope=estimate` for an estimated status check", } } input.Scope = fastly.ToPointer(scope) } fc, ok := g.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to acquire the Fastly API client") } st, err := status.Get(context.TODO(), fc, input) if err != nil { g.Globals.ErrLog.Add(err) return err } if ok, err := g.WriteJSON(out, st); ok { return err } printStatusSummary(out, st) return nil } // printStatusSummary displays the information returned from the API in a summarized format. func printStatusSummary(w io.Writer, st *status.Status) { fmt.Fprintf(w, "Domain: %s\n", st.Domain) fmt.Fprintf(w, "Zone: %s\n", st.Zone) fmt.Fprintf(w, "Status: %s\n", st.Status) fmt.Fprintf(w, "Tags: %s\n", st.Tags) if st.Scope != nil { fmt.Fprintf(w, "Scope: %s\n", *st.Scope) } if len(st.Offers) > 0 { fmt.Fprintf(w, "Offers:\n") for _, o := range st.Offers { fmt.Fprintf(w, " - Vendor: %s\n", o.Vendor) fmt.Fprintf(w, " Currency: %s\n", o.Currency) fmt.Fprintf(w, " Price: %s\n", o.Price) } } } ================================================ FILE: pkg/commands/tools/domain/status_test.go ================================================ package domain_test import ( "bytes" "io" "net/http" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/tools/status" "github.com/fastly/cli/pkg/commands/tools" "github.com/fastly/cli/pkg/commands/tools/domain" "github.com/fastly/cli/pkg/testutil" ) func TestNewDomainsV1ToolsStatusCommand(t *testing.T) { scenarios := []testutil.CLIScenario{ { Args: "", WantError: "error parsing arguments: required argument 'domain' not provided", }, { Args: "fastly-cli-testing.com --scope not-estimate", WantError: "invalid scope provided", }, { Args: "fastly-cli-testing.com", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(status.Status{ Domain: "fastly-cli-testing.com", Zone: "com", Status: "undelegated inactive", Tags: "generic", }))), }, }, }, WantOutput: `Domain: fastly-cli-testing.com Zone: com Status: undelegated inactive Tags: generic `, }, { Args: "--scope estimate fastly-cli-testing-offers.com", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(status.Status{ Domain: "fastly-cli-testing-offers.com", Zone: "com", Status: "marketed priced transferable active", Tags: "generic", Scope: fastly.ToPointer(status.ScopeEstimate), Offers: []status.Offer{ { Vendor: "example.com", Currency: "USD", Price: "20000.00", }, }, }))), }, }, }, WantOutput: `Domain: fastly-cli-testing-offers.com Zone: com Status: marketed priced transferable active Tags: generic Scope: estimate Offers: - Vendor: example.com Currency: USD Price: 20000.00 `, }, { Args: "-j --scope estimate fastly-cli-testing-offers.com", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(status.Status{ Domain: "fastly-cli-testing-offers.com", Zone: "com", Status: "marketed priced transferable active", Tags: "generic", Scope: fastly.ToPointer(status.ScopeEstimate), Offers: []status.Offer{ { Vendor: "example.com", Currency: "USD", Price: "20000.00", }, }, }))), }, }, }, WantOutput: `{ "domain": "fastly-cli-testing-offers.com", "zone": "com", "status": "marketed priced transferable active", "scope": "estimate", "tags": "generic", "offers": [ { "vendor": "example.com", "price": "20000.00", "currency": "USD" } ] } `, }, } testutil.RunCLIScenarios(t, []string{tools.CommandName, domain.CommandName, "status"}, scenarios) } ================================================ FILE: pkg/commands/tools/domain/suggest.go ================================================ package domain import ( "context" "errors" "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/tools/suggest" "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" ) // GetDomainSuggestionsCommand calls the Fastly API and results domain search results for a given query. type GetDomainSuggestionsCommand struct { argparser.Base argparser.JSONOutput // Required. query []string // Optional. defaults argparser.OptionalString keywords argparser.OptionalString location argparser.OptionalString vendor argparser.OptionalString } // NewDomainSuggestionsCommand returns a usable DomainSuggestionCommand registered under the parent. func NewDomainSuggestionsCommand(parent argparser.Registerer, g *global.Data) *GetDomainSuggestionsCommand { cmd := GetDomainSuggestionsCommand{ Base: argparser.Base{ Globals: g, }, } cmd.CmdClause = parent.Command("suggest", "Request domain search results for a given query") // Required. cmd.CmdClause.Arg("query", "Search query, e.g. “acme coffee shop”").Required().StringsVar(&cmd.query) // Optional. cmd.CmdClause.Flag("defaults", "Comma-separated list of default zones to include in the search results response, e.g. `--defaults=uk,co.uk`").Action(cmd.defaults.Set).StringVar(&cmd.defaults.Value) cmd.RegisterFlagBool(cmd.JSONFlag()) cmd.CmdClause.Flag("keywords", "Comma-separated list of keywords for seeding the search results, e.g. `--keywords=dance,party`").Action(cmd.keywords.Set).StringVar(&cmd.keywords.Value) cmd.CmdClause.Flag("location", "Override IP geolocation with a two-character country code, e.g. `--location=in` to include Indian zones in the search results").Action(cmd.location.Set).StringVar(&cmd.location.Value) cmd.CmdClause.Flag("vendor", "The domain name of a specific registrar or vendor, to filter the search results to the list of zones they support").Action(cmd.vendor.Set).StringVar(&cmd.vendor.Value) return &cmd } // Exec invokes the application logic for the command. func (g *GetDomainSuggestionsCommand) Exec(_ io.Reader, out io.Writer) error { if g.Globals.Verbose() && g.JSONOutput.Enabled { return fsterr.ErrInvalidVerboseJSONCombo } input := &suggest.GetInput{ Query: strings.Join(g.query, " "), } if g.defaults.WasSet { input.Defaults = fastly.ToPointer(g.defaults.Value) } if g.keywords.WasSet { input.Keywords = fastly.ToPointer(g.keywords.Value) } if g.location.WasSet { input.Location = fastly.ToPointer(g.location.Value) } if g.vendor.WasSet { input.Vendor = fastly.ToPointer(g.vendor.Value) } fc, ok := g.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to acquire the Fastly API client") } suggestions, err := suggest.Get(context.TODO(), fc, input) if err != nil { g.Globals.ErrLog.Add(err) return err } if ok, err := g.WriteJSON(out, suggestions); ok { return err } if g.Globals.Verbose() { printSuggestionsVerbose(out, suggestions) } else { printSuggestionsSummary(out, suggestions) } return nil } // printSuggestionsSummary displays the information returned from the API in a summarized // format. func printSuggestionsSummary(out io.Writer, suggestions *suggest.Suggestions) { t := text.NewTable(out) t.AddHeader("Domain", "Subdomain", "Zone", "Path") for _, suggestion := range suggestions.Results { var path string if suggestion.Path != nil { path = *suggestion.Path } t.AddLine(suggestion.Domain, suggestion.Subdomain, suggestion.Zone, path) } t.Print() } // printSuggestionsVerbose displays the information returned from the API in a verbose // format. func printSuggestionsVerbose(out io.Writer, suggestions *suggest.Suggestions) { for _, suggestion := range suggestions.Results { fmt.Fprintf(out, "Domain: %s\n", suggestion.Domain) fmt.Fprintf(out, "Subdomain: %s\n", suggestion.Subdomain) fmt.Fprintf(out, "Zone: %s\n", suggestion.Zone) if suggestion.Path != nil { fmt.Fprintf(out, "Path: %s\n", *suggestion.Path) } fmt.Fprintf(out, "\n") } } ================================================ FILE: pkg/commands/tools/domain/suggest_test.go ================================================ package domain_test import ( "bytes" "io" "net/http" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/go-fastly/v15/fastly/domainmanagement/v1/tools/suggest" "github.com/fastly/cli/pkg/commands/tools" "github.com/fastly/cli/pkg/commands/tools/domain" "github.com/fastly/cli/pkg/testutil" ) func TestNewDomainsV1ToolsSuggestCommand(t *testing.T) { testSuggestions := suggest.Suggestions{ Results: []suggest.Suggestion{ { Domain: "fastlytest.ing", Subdomain: "fastlytest.", Zone: "ing", }, { Domain: "fastlytesti.ng", Subdomain: "fastlytesti.", Zone: "ng", }, { Domain: "fastlytesting.com", Subdomain: "fastlytesting.", Zone: "com", }, { Domain: "fastlytesting.net", Subdomain: "fastlytesting.", Zone: "net", }, { Domain: "fastlytest.in", Subdomain: "fastlytest.", Zone: "in", Path: fastly.ToPointer("/g"), }, }, } scenarios := []testutil.CLIScenario{ { Args: "", WantError: "error parsing arguments: required argument 'query' not provided", }, { Args: "fastly testing", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testSuggestions))), }, }, }, WantOutput: `Domain Subdomain Zone Path fastlytest.ing fastlytest. ing fastlytesti.ng fastlytesti. ng fastlytesting.com fastlytesting. com fastlytesting.net fastlytesting. net fastlytest.in fastlytest. in /g `, }, { Args: "--keywords=food,kitchen --defaults=club foo", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(suggest.Suggestions{ Results: []suggest.Suggestion{ { Domain: "foo.eat", Subdomain: "foo.", Zone: "eat", }, { Domain: "foo.cafe", Subdomain: "foo.", Zone: "cafe", }, { Domain: "foo.menu", Subdomain: "foo.", Zone: "menu", }, { Domain: "foo.kitchen", Subdomain: "foo.", Zone: "kitchen", }, { Domain: "foo.club", Subdomain: "foo.", Zone: "club", }, }, }))), }, }, }, WantOutput: `Domain Subdomain Zone Path foo.eat foo. eat foo.cafe foo. cafe foo.menu foo. menu foo.kitchen foo. kitchen foo.club foo. club `, }, { Args: "-j fastly testing", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testSuggestions))), }, }, }, WantOutput: `{ "results": [ { "domain": "fastlytest.ing", "subdomain": "fastlytest.", "zone": "ing" }, { "domain": "fastlytesti.ng", "subdomain": "fastlytesti.", "zone": "ng" }, { "domain": "fastlytesting.com", "subdomain": "fastlytesting.", "zone": "com" }, { "domain": "fastlytesting.net", "subdomain": "fastlytesting.", "zone": "net" }, { "domain": "fastlytest.in", "subdomain": "fastlytest.", "zone": "in", "path": "/g" } ] } `, }, { Args: "-v fastly testing", Client: &http.Client{ Transport: &testutil.MockRoundTripper{ Response: &http.Response{ StatusCode: http.StatusOK, Status: http.StatusText(http.StatusOK), Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testSuggestions))), }, }, }, WantOutput: `Fastly API endpoint: https://api.fastly.com Fastly API token provided via config file (auth: user) Domain: fastlytest.ing Subdomain: fastlytest. Zone: ing Domain: fastlytesti.ng Subdomain: fastlytesti. Zone: ng Domain: fastlytesting.com Subdomain: fastlytesting. Zone: com Domain: fastlytesting.net Subdomain: fastlytesting. Zone: net Domain: fastlytest.in Subdomain: fastlytest. Zone: in Path: /g `, }, } testutil.RunCLIScenarios(t, []string{tools.CommandName, domain.CommandName, "suggest"}, scenarios) } ================================================ FILE: pkg/commands/tools/root.go ================================================ package tools import ( "io" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/global" ) // RootCommand is the parent command for all tool 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 = "tools" // NewRootCommand returns a new tools 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, "Tools for working with the Fastly platform") return &c } // Exec implements the command interface. func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { panic("unreachable") } ================================================ FILE: pkg/commands/update/check.go ================================================ 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 and // returns 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(w) func CheckAsync( currentVersion string, av github.AssetVersioner, ) (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 { 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) if result.latest.Major != result.current.Major { fmt.Fprintf(w, "\nNote: Please review the release notes for the major version(s) listed below before upgrading.\n") for ver := result.current.Major + 1; ver <= result.latest.Major; ver++ { fmt.Fprintf(w, "Version %d.0.0: https://github.com/fastly/cli/releases/tag/v%d.0.0\n", ver, ver) } } fmt.Fprintf(w, "Run `fastly update` to get the latest version.\n") fmt.Fprintf(w, "\n") } } } ================================================ FILE: pkg/commands/update/check_test.go ================================================ 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, ) f(&buf) if want, have := testcase.wantOutput, buf.String(); want != have { t.Error(cmp.Diff(want, have)) } }) } } ================================================ FILE: pkg/commands/update/doc.go ================================================ // Package update contains functions for checking the current CLI version // against the latest version release. package update ================================================ FILE: pkg/commands/update/root.go ================================================ 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 } ================================================ FILE: pkg/commands/user/create.go ================================================ package user import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/user/delete.go ================================================ package user import ( "context" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/user/describe.go ================================================ package user import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO()) 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(context.TODO(), 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) } } ================================================ FILE: pkg/commands/user/doc.go ================================================ // Package user contains commands to inspect and manipulate Fastly user // accounts. package user ================================================ FILE: pkg/commands/user/list.go ================================================ package user import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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 } ================================================ FILE: pkg/commands/user/root.go ================================================ 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") } ================================================ FILE: pkg/commands/user/update.go ================================================ package user import ( "context" "fmt" "io" "github.com/fastly/go-fastly/v15/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(context.TODO(), 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(context.TODO(), 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 } ================================================ FILE: pkg/commands/user/user_test.go ================================================ package user_test import ( "context" "fmt" "testing" "github.com/fastly/go-fastly/v15/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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, _ *fastly.DeleteUserInput) error { return testutil.Err }, }, Args: "--id foo123", WantError: testutil.Err.Error(), }, { Name: "validate DeleteUser API success", API: &mock.API{ DeleteUserFn: func(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context) (*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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context, _ *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(_ context.Context, 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(_ context.Context) (*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(ctx context.Context, _ *fastly.ListCustomerUsersInput) ([]*fastly.User, error) { user, _ := getUser(ctx, &fastly.GetUserInput{UserID: "123"}) userCurrent, _ := getCurrentUser(ctx) 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 (auth: user) %s%s`, describeUserOutput(), describeCurrentUserOutput()) } ================================================ FILE: pkg/commands/version/doc.go ================================================ // Package version contains commands to inspect the Fastly CLI version. package version ================================================ FILE: pkg/commands/version/root.go ================================================ package version import ( "fmt" "io" "os/exec" "path/filepath" "strings" "time" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/github" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/revision" ) // 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 ================================================ FILE: pkg/commands/version/version_test.go ================================================ 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 a 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()) } ================================================ FILE: pkg/commands/whoami/doc.go ================================================ // Package whoami contains commands to inspect the currently authenticated user. package whoami ================================================ FILE: pkg/commands/whoami/root.go ================================================ 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"` } ================================================ FILE: pkg/commands/whoami/whoami_test.go ================================================ 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: "401 from API", args: args("whoami"), client: codeClient{code: http.StatusUnauthorized}, wantError: "error executing API request: error response", }, { 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 (auth: 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" ================================================ FILE: pkg/config/auth.go ================================================ package config // Auth represents the new auth configuration section. // It stores named tokens and tracks which one is the default. type Auth struct { Default string `toml:"default" json:"default"` Tokens AuthTokens `toml:"tokens" json:"tokens"` } // AuthTokens is a map of token name to token entry. type AuthTokens map[string]*AuthToken // AuthToken represents a single stored credential. type AuthToken struct { Type string `toml:"type" json:"type"` Token string `toml:"token" json:"token"` Label string `toml:"label,omitempty" json:"label,omitempty"` AccountID string `toml:"account_id,omitempty" json:"account_id,omitempty"` Email string `toml:"email,omitempty" json:"email,omitempty"` // API token metadata (populated from /tokens/self when available). APITokenName string `toml:"api_token_name,omitempty" json:"api_token_name,omitempty"` APITokenScope string `toml:"api_token_scope,omitempty" json:"api_token_scope,omitempty"` APITokenExpiresAt string `toml:"api_token_expires_at,omitempty" json:"api_token_expires_at,omitempty"` APITokenID string `toml:"api_token_id,omitempty" json:"api_token_id,omitempty"` // SSO-specific fields (only populated when Type == "sso"). RefreshToken string `toml:"refresh_token,omitempty" json:"refresh_token,omitempty"` AccessExpiresAt string `toml:"access_expires_at,omitempty" json:"access_expires_at,omitempty"` RefreshExpiresAt string `toml:"refresh_expires_at,omitempty" json:"refresh_expires_at,omitempty"` AccessToken string `toml:"access_token,omitempty" json:"access_token,omitempty"` NeedsReauth bool `toml:"needs_reauth,omitempty" json:"needs_reauth,omitempty"` } const AuthTokenTypeStatic = "static" const AuthTokenTypeSSO = "sso" ================================================ FILE: pkg/config/config.go ================================================ 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 { CPP CPP `toml:"cpp"` 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"` } // CPP represents C++ Compute language specific configuration. type CPP struct { // ToolchainConstraint is the `clang++` toolchain constraint for the compiler // that we support (a range is expected, e.g. >= 14.0.0). ToolchainConstraint string `toml:"toolchain_constraint"` // WasmWasiTarget is the C++ 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 { CPP []StarterKit `toml:"cpp"` 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 { // Auth represents the new auth token storage. Auth Auth `toml:"auth,omitempty"` // 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 legacy profile accounts (migrated to [auth]). Profiles Profiles `toml:"profile,omitempty"` // 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 // UserAgentExtension is the string we'll add to the UserAgent // we send in API requests. UserAgentExtension 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.UserAgentExtension = state[env.UserAgentExtension] 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") }() ================================================ FILE: pkg/config/config_test.go ================================================ 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 a 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 a 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 a 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) } }) } } ================================================ FILE: pkg/config/doc.go ================================================ // Package config manages global configuration parameters. It has a data type // and helpers for getting information out of the runtime environment, and // making it available to commands that need it. package config ================================================ FILE: pkg/config/migrate_auth.go ================================================ package config import ( "fmt" "time" ) // MigrateProfilesToAuth migrates existing profile entries into the new [auth] // config section. It is safe to call multiple times; it only migrates profiles // that do not already have a corresponding auth token entry. // // Returns true if any migration was performed. func (f *File) MigrateProfilesToAuth() bool { if len(f.Profiles) == 0 { return false } if f.Auth.Tokens == nil { f.Auth.Tokens = make(AuthTokens) } migrated := false for name, p := range f.Profiles { if _, exists := f.Auth.Tokens[name]; exists { continue } entry := profileToAuthToken(p) f.Auth.Tokens[name] = entry if p.Default && f.Auth.Default == "" { f.Auth.Default = name } migrated = true } // If no default was set but we migrated something, pick the first one. if f.Auth.Default == "" && len(f.Auth.Tokens) > 0 { for name := range f.Auth.Tokens { f.Auth.Default = name break } } return migrated } // profileToAuthToken converts a legacy Profile to the new AuthToken format. func profileToAuthToken(p *Profile) *AuthToken { entry := &AuthToken{ Token: p.Token, Email: p.Email, AccountID: p.CustomerID, } // Determine if this is an SSO-backed profile. if p.AccessToken != "" || p.RefreshToken != "" || p.AccessTokenCreated != 0 || p.RefreshTokenCreated != 0 { entry.Type = AuthTokenTypeSSO entry.AccessToken = p.AccessToken entry.RefreshToken = p.RefreshToken if p.AccessTokenCreated != 0 && p.AccessTokenTTL != 0 { expiresAt := time.Unix(p.AccessTokenCreated, 0).Add(time.Duration(p.AccessTokenTTL) * time.Second) entry.AccessExpiresAt = expiresAt.Format(time.RFC3339) } if p.RefreshTokenCreated != 0 && p.RefreshTokenTTL != 0 { expiresAt := time.Unix(p.RefreshTokenCreated, 0).Add(time.Duration(p.RefreshTokenTTL) * time.Second) entry.RefreshExpiresAt = expiresAt.Format(time.RFC3339) } // Check if the SSO session is expired and cannot be auto-refreshed. if entry.RefreshExpiresAt != "" { t, err := time.Parse(time.RFC3339, entry.RefreshExpiresAt) if err == nil && time.Now().After(t) { entry.NeedsReauth = true } } } else { entry.Type = AuthTokenTypeStatic } if p.CustomerName != "" { entry.Label = fmt.Sprintf("%s (%s)", p.CustomerName, p.Email) } return entry } func (f *File) AuthInitialized() bool { return len(f.Auth.Tokens) > 0 } func (f *File) GetAuthToken(name string) *AuthToken { return f.Auth.Tokens[name] } func (f *File) GetDefaultAuthToken() (string, *AuthToken) { if f.Auth.Default == "" { return "", nil } if t := f.Auth.Tokens[f.Auth.Default]; t != nil { return f.Auth.Default, t } return "", nil } func (f *File) SetAuthToken(name string, token *AuthToken) { if f.Auth.Tokens == nil { f.Auth.Tokens = make(AuthTokens) } f.Auth.Tokens[name] = token } func (f *File) DeleteAuthToken(name string) bool { if f.Auth.Tokens == nil { return false } if _, ok := f.Auth.Tokens[name]; !ok { return false } delete(f.Auth.Tokens, name) if f.Auth.Default == name { f.Auth.Default = "" // Set a new default if possible. for n := range f.Auth.Tokens { f.Auth.Default = n break } } return true } func (f *File) SetDefaultAuthToken(name string) error { if f.Auth.Tokens == nil { return fmt.Errorf("no auth tokens configured") } if _, ok := f.Auth.Tokens[name]; !ok { return fmt.Errorf("token %q not found", name) } f.Auth.Default = name return nil } ================================================ FILE: pkg/config/migrate_auth_test.go ================================================ package config import ( "testing" "time" ) func TestMigrateProfilesToAuth_EmptyProfiles(t *testing.T) { t.Parallel() f := &File{} migrated := f.MigrateProfilesToAuth() if migrated { t.Fatal("expected no migration when profiles are empty") } if f.Auth.Tokens != nil { t.Fatalf("expected Auth.Tokens to remain nil, got %v", f.Auth.Tokens) } if f.Auth.Default != "" { t.Fatalf("expected Auth.Default to be empty, got %q", f.Auth.Default) } } func TestMigrateProfilesToAuth_StaticToken(t *testing.T) { t.Parallel() f := &File{ Profiles: Profiles{ "work": { Token: "tok_abc123", Email: "dev@example.com", CustomerID: "cust_42", }, }, } migrated := f.MigrateProfilesToAuth() if !migrated { t.Fatal("expected migration to occur") } tok := f.Auth.Tokens["work"] if tok == nil { t.Fatal("expected auth token 'work' to exist after migration") } if tok.Type != AuthTokenTypeStatic { t.Fatalf("expected type %q, got %q", AuthTokenTypeStatic, tok.Type) } if tok.Token != "tok_abc123" { t.Fatalf("expected token %q, got %q", "tok_abc123", tok.Token) } if tok.Email != "dev@example.com" { t.Fatalf("expected email %q, got %q", "dev@example.com", tok.Email) } if tok.AccountID != "cust_42" { t.Fatalf("expected account_id %q, got %q", "cust_42", tok.AccountID) } if tok.AccessToken != "" { t.Fatalf("expected empty access_token for static type, got %q", tok.AccessToken) } if tok.RefreshToken != "" { t.Fatalf("expected empty refresh_token for static type, got %q", tok.RefreshToken) } } func TestMigrateProfilesToAuth_SSOToken(t *testing.T) { t.Parallel() // Use timestamps far in the future so NeedsReauth stays false. now := time.Now() created := now.Unix() ttlSeconds := 86400 // 24 hours f := &File{ Profiles: Profiles{ "sso-user": { Token: "tok_sso", Email: "sso@example.com", CustomerID: "cust_99", CustomerName: "Acme Corp", AccessToken: "access_xyz", AccessTokenCreated: created, AccessTokenTTL: ttlSeconds, RefreshToken: "refresh_xyz", RefreshTokenCreated: created, RefreshTokenTTL: ttlSeconds * 30, // 30 days }, }, } migrated := f.MigrateProfilesToAuth() if !migrated { t.Fatal("expected migration to occur") } tok := f.Auth.Tokens["sso-user"] if tok == nil { t.Fatal("expected auth token 'sso-user' to exist after migration") } if tok.Type != AuthTokenTypeSSO { t.Fatalf("expected type %q, got %q", AuthTokenTypeSSO, tok.Type) } if tok.Token != "tok_sso" { t.Fatalf("expected token %q, got %q", "tok_sso", tok.Token) } if tok.AccessToken != "access_xyz" { t.Fatalf("expected access_token %q, got %q", "access_xyz", tok.AccessToken) } if tok.RefreshToken != "refresh_xyz" { t.Fatalf("expected refresh_token %q, got %q", "refresh_xyz", tok.RefreshToken) } if tok.Label != "Acme Corp (sso@example.com)" { t.Fatalf("expected label %q, got %q", "Acme Corp (sso@example.com)", tok.Label) } // Verify expiry timestamps were computed. expectedAccessExpiry := time.Unix(created, 0).Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339) if tok.AccessExpiresAt != expectedAccessExpiry { t.Fatalf("expected access_expires_at %q, got %q", expectedAccessExpiry, tok.AccessExpiresAt) } expectedRefreshExpiry := time.Unix(created, 0).Add(time.Duration(ttlSeconds*30) * time.Second).Format(time.RFC3339) if tok.RefreshExpiresAt != expectedRefreshExpiry { t.Fatalf("expected refresh_expires_at %q, got %q", expectedRefreshExpiry, tok.RefreshExpiresAt) } if tok.NeedsReauth { t.Fatal("expected NeedsReauth to be false for a token with a future refresh expiry") } } func TestMigrateProfilesToAuth_SSOToken_NeedsReauth(t *testing.T) { t.Parallel() // Use timestamps in the past so the refresh token is expired. pastCreated := time.Now().Add(-48 * time.Hour).Unix() ttlSeconds := 3600 // 1 hour -- well in the past f := &File{ Profiles: Profiles{ "expired-sso": { Token: "tok_old", Email: "old@example.com", AccessToken: "access_old", AccessTokenCreated: pastCreated, AccessTokenTTL: ttlSeconds, RefreshToken: "refresh_old", RefreshTokenCreated: pastCreated, RefreshTokenTTL: ttlSeconds, }, }, } f.MigrateProfilesToAuth() tok := f.Auth.Tokens["expired-sso"] if tok == nil { t.Fatal("expected auth token to exist") } if tok.Type != AuthTokenTypeSSO { t.Fatalf("expected type %q, got %q", AuthTokenTypeSSO, tok.Type) } if !tok.NeedsReauth { t.Fatal("expected NeedsReauth to be true for an expired refresh token") } } func TestMigrateProfilesToAuth_DefaultPreserved(t *testing.T) { t.Parallel() f := &File{ Profiles: Profiles{ "alpha": { Token: "tok_alpha", Email: "alpha@example.com", }, "beta": { Token: "tok_beta", Email: "beta@example.com", Default: true, }, "gamma": { Token: "tok_gamma", Email: "gamma@example.com", }, }, } migrated := f.MigrateProfilesToAuth() if !migrated { t.Fatal("expected migration to occur") } if f.Auth.Default != "beta" { t.Fatalf("expected default to be %q, got %q", "beta", f.Auth.Default) } // All three should be migrated. for _, name := range []string{"alpha", "beta", "gamma"} { if f.Auth.Tokens[name] == nil { t.Fatalf("expected auth token %q to exist", name) } } } func TestMigrateProfilesToAuth_NoDefaultPicksOne(t *testing.T) { t.Parallel() f := &File{ Profiles: Profiles{ "only": { Token: "tok_only", Email: "only@example.com", // Default is false }, }, } f.MigrateProfilesToAuth() // When no profile has Default=true, migration should still pick a default. if f.Auth.Default == "" { t.Fatal("expected a default to be assigned when none was explicitly set") } if f.Auth.Tokens[f.Auth.Default] == nil { t.Fatalf("expected the assigned default %q to exist in tokens", f.Auth.Default) } } func TestMigrateProfilesToAuth_AlreadyMigrated(t *testing.T) { t.Parallel() existing := &AuthToken{ Type: AuthTokenTypeStatic, Token: "original_token", Email: "original@example.com", } f := &File{ Auth: Auth{ Default: "existing", Tokens: AuthTokens{ "existing": existing, }, }, Profiles: Profiles{ "existing": { Token: "profile_token_different", Email: "different@example.com", }, }, } migrated := f.MigrateProfilesToAuth() // The profile name matches an existing auth token, so it should be skipped. // Since no new tokens were added, migrated should be false. if migrated { t.Fatal("expected no migration when all profiles already have corresponding auth tokens") } // The original auth token should be untouched. tok := f.Auth.Tokens["existing"] if tok.Token != "original_token" { t.Fatalf("expected original token %q to be preserved, got %q", "original_token", tok.Token) } if tok.Email != "original@example.com" { t.Fatalf("expected original email to be preserved, got %q", tok.Email) } } func TestMigrateProfilesToAuth_Idempotent(t *testing.T) { t.Parallel() f := &File{ Profiles: Profiles{ "user1": { Token: "tok_1", Email: "user1@example.com", Default: true, }, "user2": { Token: "tok_2", Email: "user2@example.com", }, }, } // First migration. first := f.MigrateProfilesToAuth() if !first { t.Fatal("expected first migration to return true") } if len(f.Auth.Tokens) != 2 { t.Fatalf("expected 2 auth tokens after first migration, got %d", len(f.Auth.Tokens)) } // Capture state after first migration. tok1Token := f.Auth.Tokens["user1"].Token tok2Token := f.Auth.Tokens["user2"].Token defaultName := f.Auth.Default // Second migration: should be a no-op. second := f.MigrateProfilesToAuth() if second { t.Fatal("expected second migration to return false (no new tokens added)") } if len(f.Auth.Tokens) != 2 { t.Fatalf("expected 2 auth tokens after second migration, got %d", len(f.Auth.Tokens)) } if f.Auth.Tokens["user1"].Token != tok1Token { t.Fatal("user1 token was modified on second migration") } if f.Auth.Tokens["user2"].Token != tok2Token { t.Fatal("user2 token was modified on second migration") } if f.Auth.Default != defaultName { t.Fatalf("default changed from %q to %q on second migration", defaultName, f.Auth.Default) } } func TestAuthToken_CRUD(t *testing.T) { t.Parallel() f := &File{} // Initially there are no tokens. if f.AuthInitialized() { t.Fatal("expected AuthInitialized to return false on empty File") } if tok := f.GetAuthToken("anything"); tok != nil { t.Fatalf("expected nil from GetAuthToken on empty File, got %v", tok) } // Set a token. token1 := &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_crud_1", Email: "crud1@example.com", } f.SetAuthToken("first", token1) if !f.AuthInitialized() { t.Fatal("expected AuthInitialized to return true after SetAuthToken") } got := f.GetAuthToken("first") if got == nil { t.Fatal("expected to get auth token 'first'") } if got.Token != "tok_crud_1" { t.Fatalf("expected token %q, got %q", "tok_crud_1", got.Token) } // Set another token. token2 := &AuthToken{ Type: AuthTokenTypeSSO, Token: "tok_crud_2", Email: "crud2@example.com", } f.SetAuthToken("second", token2) if len(f.Auth.Tokens) != 2 { t.Fatalf("expected 2 tokens, got %d", len(f.Auth.Tokens)) } // Overwrite an existing token. token1Updated := &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_crud_1_updated", Email: "crud1_updated@example.com", } f.SetAuthToken("first", token1Updated) got = f.GetAuthToken("first") if got.Token != "tok_crud_1_updated" { t.Fatalf("expected updated token %q, got %q", "tok_crud_1_updated", got.Token) } if len(f.Auth.Tokens) != 2 { t.Fatalf("expected 2 tokens after overwrite, got %d", len(f.Auth.Tokens)) } // Set a default. err := f.SetDefaultAuthToken("second") if err != nil { t.Fatalf("unexpected error setting default: %v", err) } if f.Auth.Default != "second" { t.Fatalf("expected default %q, got %q", "second", f.Auth.Default) } // Try to set default to a non-existent token. err = f.SetDefaultAuthToken("nonexistent") if err == nil { t.Fatal("expected error when setting default to non-existent token") } // Delete a non-default token. deleted := f.DeleteAuthToken("first") if !deleted { t.Fatal("expected DeleteAuthToken to return true") } if f.GetAuthToken("first") != nil { t.Fatal("expected 'first' to be deleted") } if len(f.Auth.Tokens) != 1 { t.Fatalf("expected 1 token after deletion, got %d", len(f.Auth.Tokens)) } // Default should still be "second". if f.Auth.Default != "second" { t.Fatalf("expected default to remain %q, got %q", "second", f.Auth.Default) } // Delete a non-existent token. deleted = f.DeleteAuthToken("nonexistent") if deleted { t.Fatal("expected DeleteAuthToken to return false for non-existent token") } // Delete from nil tokens map. emptyFile := &File{} deleted = emptyFile.DeleteAuthToken("anything") if deleted { t.Fatal("expected DeleteAuthToken to return false on nil tokens map") } } func TestDeleteAuthToken_ReassignsDefault(t *testing.T) { t.Parallel() f := &File{} f.SetAuthToken("primary", &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_primary", }) f.SetAuthToken("secondary", &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_secondary", }) err := f.SetDefaultAuthToken("primary") if err != nil { t.Fatalf("unexpected error: %v", err) } // Delete the default token. deleted := f.DeleteAuthToken("primary") if !deleted { t.Fatal("expected DeleteAuthToken to return true") } // The default should be reassigned to the remaining token. if f.Auth.Default == "" { t.Fatal("expected default to be reassigned after deleting the default token") } if f.Auth.Default == "primary" { t.Fatal("expected default to no longer be the deleted token") } if f.Auth.Tokens[f.Auth.Default] == nil { t.Fatalf("expected reassigned default %q to reference an existing token", f.Auth.Default) } } func TestDeleteAuthToken_LastToken(t *testing.T) { t.Parallel() f := &File{} f.SetAuthToken("only", &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_only", }) err := f.SetDefaultAuthToken("only") if err != nil { t.Fatalf("unexpected error: %v", err) } deleted := f.DeleteAuthToken("only") if !deleted { t.Fatal("expected DeleteAuthToken to return true") } // With no tokens remaining, default should be empty. if f.Auth.Default != "" { t.Fatalf("expected default to be empty after deleting the only token, got %q", f.Auth.Default) } if len(f.Auth.Tokens) != 0 { t.Fatalf("expected 0 tokens, got %d", len(f.Auth.Tokens)) } } func TestGetDefaultAuthToken(t *testing.T) { t.Parallel() // No default set, no tokens. f := &File{} name, tok := f.GetDefaultAuthToken() if name != "" || tok != nil { t.Fatalf("expected empty name and nil token, got name=%q tok=%v", name, tok) } // Tokens exist but no default is set. f.SetAuthToken("orphan", &AuthToken{ Type: AuthTokenTypeStatic, Token: "tok_orphan", }) name, tok = f.GetDefaultAuthToken() if name != "" || tok != nil { t.Fatalf("expected empty name and nil token when no default is set, got name=%q tok=%v", name, tok) } // Set a default. err := f.SetDefaultAuthToken("orphan") if err != nil { t.Fatalf("unexpected error: %v", err) } name, tok = f.GetDefaultAuthToken() if name != "orphan" { t.Fatalf("expected default name %q, got %q", "orphan", name) } if tok == nil { t.Fatal("expected non-nil token for default") } if tok.Token != "tok_orphan" { t.Fatalf("expected token value %q, got %q", "tok_orphan", tok.Token) } // Default points to a token that has been removed out-of-band // (e.g. by direct map manipulation). f.Auth.Default = "ghost" name, tok = f.GetDefaultAuthToken() if name != "" || tok != nil { t.Fatalf("expected empty name and nil token when default references non-existent token, got name=%q tok=%v", name, tok) } } func TestSetDefaultAuthToken_NoTokens(t *testing.T) { t.Parallel() f := &File{} err := f.SetDefaultAuthToken("anything") if err == nil { t.Fatal("expected error when setting default with nil tokens map") } } func TestProfileToAuthToken_StaticMinimal(t *testing.T) { t.Parallel() p := &Profile{ Token: "minimal_tok", Email: "min@example.com", } tok := profileToAuthToken(p) if tok.Type != AuthTokenTypeStatic { t.Fatalf("expected type %q, got %q", AuthTokenTypeStatic, tok.Type) } if tok.Token != "minimal_tok" { t.Fatalf("expected token %q, got %q", "minimal_tok", tok.Token) } if tok.Email != "min@example.com" { t.Fatalf("expected email %q, got %q", "min@example.com", tok.Email) } if tok.Label != "" { t.Fatalf("expected empty label when CustomerName is empty, got %q", tok.Label) } if tok.AccessToken != "" || tok.RefreshToken != "" { t.Fatal("expected empty SSO fields for static token") } if tok.AccessExpiresAt != "" || tok.RefreshExpiresAt != "" { t.Fatal("expected empty expiry fields for static token") } } func TestProfileToAuthToken_SSOWithLabel(t *testing.T) { t.Parallel() now := time.Now() created := now.Unix() p := &Profile{ Token: "sso_tok", Email: "sso@example.com", CustomerID: "cust_sso", CustomerName: "SSO Corp", AccessToken: "at_123", AccessTokenCreated: created, AccessTokenTTL: 3600, RefreshToken: "rt_456", RefreshTokenCreated: created, RefreshTokenTTL: 86400, } tok := profileToAuthToken(p) if tok.Type != AuthTokenTypeSSO { t.Fatalf("expected type %q, got %q", AuthTokenTypeSSO, tok.Type) } if tok.Label != "SSO Corp (sso@example.com)" { t.Fatalf("expected label %q, got %q", "SSO Corp (sso@example.com)", tok.Label) } if tok.AccountID != "cust_sso" { t.Fatalf("expected account_id %q, got %q", "cust_sso", tok.AccountID) } if tok.AccessToken != "at_123" { t.Fatalf("expected access_token %q, got %q", "at_123", tok.AccessToken) } if tok.RefreshToken != "rt_456" { t.Fatalf("expected refresh_token %q, got %q", "rt_456", tok.RefreshToken) } if tok.AccessExpiresAt == "" { t.Fatal("expected access_expires_at to be set") } if tok.RefreshExpiresAt == "" { t.Fatal("expected refresh_expires_at to be set") } } func TestProfileToAuthToken_SSOPartialFields(t *testing.T) { t.Parallel() // Only AccessToken is set, no timestamps -- should still be classified as SSO // but without computed expiry. p := &Profile{ Token: "partial_tok", Email: "partial@example.com", AccessToken: "at_partial", } tok := profileToAuthToken(p) if tok.Type != AuthTokenTypeSSO { t.Fatalf("expected type %q, got %q", AuthTokenTypeSSO, tok.Type) } if tok.AccessExpiresAt != "" { t.Fatalf("expected empty access_expires_at when created/ttl are zero, got %q", tok.AccessExpiresAt) } if tok.RefreshExpiresAt != "" { t.Fatalf("expected empty refresh_expires_at when created/ttl are zero, got %q", tok.RefreshExpiresAt) } if tok.NeedsReauth { t.Fatal("expected NeedsReauth to be false when RefreshExpiresAt is empty") } } func TestMigrateProfilesToAuth_MixedPartial(t *testing.T) { t.Parallel() existing := &AuthToken{ Type: AuthTokenTypeStatic, Token: "existing_tok", } f := &File{ Auth: Auth{ Default: "existing", Tokens: AuthTokens{ "existing": existing, }, }, Profiles: Profiles{ "existing": { Token: "should_be_skipped", Email: "skipped@example.com", }, "new-profile": { Token: "new_tok", Email: "new@example.com", Default: true, }, }, } migrated := f.MigrateProfilesToAuth() if !migrated { t.Fatal("expected migration for the new profile") } // The existing token should be untouched. if f.Auth.Tokens["existing"].Token != "existing_tok" { t.Fatal("existing token was overwritten during partial migration") } // The new profile should be migrated. newTok := f.Auth.Tokens["new-profile"] if newTok == nil { t.Fatal("expected 'new-profile' to be migrated") } if newTok.Token != "new_tok" { t.Fatalf("expected token %q, got %q", "new_tok", newTok.Token) } // Default should remain "existing" because it was already set, even though // new-profile has Default=true in the profile. The migration only sets // Auth.Default when Auth.Default is empty. if f.Auth.Default != "existing" { t.Fatalf("expected default to remain %q, got %q", "existing", f.Auth.Default) } if len(f.Auth.Tokens) != 2 { t.Fatalf("expected 2 tokens, got %d", len(f.Auth.Tokens)) } } ================================================ FILE: pkg/config/testdata/config-current.toml ================================================ config_version = 2 [fastly] api_endpoint = "https://api.fastly.com" [cli] version = "0.0.0" # this matches the dev version ================================================ FILE: pkg/config/testdata/config-incompatible-config-version.toml ================================================ 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" ================================================ FILE: pkg/config/testdata/config-invalid.toml ================================================ [fastly] api_endpoint = "https://api.fastly.com # missing end quote ================================================ FILE: pkg/config/testdata/config-legacy.toml ================================================ [fastly] api_endpoint = "https://api.fastly.com" [user] email = "testing@fastly.com" token = "foobar" ================================================ FILE: pkg/config/testdata/config-outdated-cli-version.toml ================================================ config_version = 2 [fastly] api_endpoint = "https://api.fastly.com" [cli] version = "1.2.3" ================================================ FILE: pkg/config/testdata/config.toml ================================================ 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" ================================================ FILE: pkg/config/testdata/static/config-invalid.toml ================================================ [fastly] api_endpoint = "https://api.fastly.com # missing end quote ================================================ FILE: pkg/config/testdata/static/config.toml ================================================ 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" ================================================ FILE: pkg/debug/debug.go ================================================ 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) } } ================================================ FILE: pkg/debug/doc.go ================================================ // Package debug contains functions to ease development of the Fastly CLI. package debug ================================================ FILE: pkg/env/doc.go ================================================ // Package env contains environment variable constants. package env ================================================ FILE: pkg/env/env.go ================================================ 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" // UserAgentExtension informs the CLI of an additional string // which should be added to the UserAgent included in // requests made by the CLI. UserAgentExtension = "FASTLY_USER_AGENT_EXTENSION" // 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" // WorkspaceID is the env we look for in Workspace related commands if none is provided. WorkspaceID = "FASTLY_WORKSPACE_ID" // DisableAuthCommand hides all authentication-related commands (auth, // auth-token, sso, profile, whoami) and the --token flag when set. DisableAuthCommand = "FASTLY_DISABLE_AUTH_COMMAND" ) // AuthCommandDisabled reports whether FASTLY_DISABLE_AUTH_COMMAND is set to a // non-empty value. func AuthCommandDisabled() bool { return os.Getenv(DisableAuthCommand) != "" } // 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) } ================================================ FILE: pkg/env/env_test.go ================================================ 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() } }) } } ================================================ FILE: pkg/errors/deduce.go ================================================ package errors import ( "errors" "fmt" "net/http" "os" "strings" "github.com/fastly/go-fastly/v15/fastly" ) // httpStatusError is satisfied by any error that carries an HTTP status code. // This avoids importing concrete types (like undocumented.APIError) and the // import cycles that would create. type httpStatusError interface { error HTTPStatusCode() int } // Deduce attempts to deduce a RemediationError from a plain error. If the error // is already a RemediationError it is returned directly. Certain deep error // types, like a Fastly SDK HTTPError, are detected and converted in appropriate // cases to e.g. AuthRemediation. If no specific remediation can be suggested, a // remediation to file a bug is used. func Deduce(err error) RemediationError { var re RemediationError if errors.As(err, &re) { return re // assume the useful suggestion is already baked-in } var httpError *fastly.HTTPError if errors.As(err, &httpError) { remediation := BugRemediation switch httpError.StatusCode { case http.StatusUnauthorized: remediation = AuthRemediation() case http.StatusForbidden: remediation = ForbiddenRemediation() } return RemediationError{Inner: SimplifyFastlyError(*httpError), Remediation: remediation} } var statusErr httpStatusError if errors.As(err, &statusErr) { remediation := BugRemediation switch statusErr.HTTPStatusCode() { case http.StatusUnauthorized: remediation = AuthRemediation() case http.StatusForbidden: remediation = ForbiddenRemediation() } return RemediationError{Inner: err, Remediation: remediation} } if errors.Is(err, os.ErrNotExist) { return RemediationError{Inner: err, Remediation: HostRemediation} } if t, ok := err.(interface{ Temporary() bool }); ok && t.Temporary() { return RemediationError{Inner: err, Remediation: NetworkRemediation} } return RemediationError{Inner: err, Remediation: BugRemediation} } // SimplifyFastlyError reduces the potentially complex and multi-line Error // rendering of a fastly.HTTPError to something more palatable for a CLI. func SimplifyFastlyError(httpError fastly.HTTPError) error { switch len(httpError.Errors) { case 1: s := fmt.Sprintf( "the Fastly API returned %d %s: %s", httpError.StatusCode, http.StatusText(httpError.StatusCode), strings.TrimSpace(httpError.Errors[0].Title), ) if detail := httpError.Errors[0].Detail; detail != "" { s += fmt.Sprintf(" (%s)", detail) } return errors.New(s) default: return fmt.Errorf( "the Fastly API returned %d %s", httpError.StatusCode, http.StatusText(httpError.StatusCode), ) } } ================================================ FILE: pkg/errors/deduce_test.go ================================================ package errors_test import ( "fmt" "net/http" "os" "testing" "github.com/fastly/cli/pkg/api/undocumented" "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/go-fastly/v15/fastly" ) func TestDeduce(t *testing.T) { var ( re1 = errors.RemediationError{Inner: fmt.Errorf("foo")} re2 = errors.RemediationError{Inner: fmt.Errorf("bar"), Remediation: "Reticulate your splines."} http503 = &fastly.HTTPError{StatusCode: http.StatusInternalServerError} http401 = &fastly.HTTPError{StatusCode: http.StatusUnauthorized} http403 = &fastly.HTTPError{StatusCode: http.StatusForbidden} wrappedNotExist = fmt.Errorf("couldn't do the thing: %w", os.ErrNotExist) undoc401 = undocumented.NewError(fmt.Errorf("error response"), http.StatusUnauthorized) undoc403 = undocumented.NewError(fmt.Errorf("error response"), http.StatusForbidden) undoc500 = undocumented.NewError(fmt.Errorf("error response"), http.StatusInternalServerError) wrappedUndoc401 = fmt.Errorf("call failed: %w", undoc401) ) for _, testcase := range []struct { name string input error want errors.RemediationError }{ { name: "RemediationError with no remediation", input: re1, want: re1, }, { name: "RemediationError with remediation", input: re2, want: re2, }, { name: "fastly.HTTPError 503", input: http503, want: errors.RemediationError{Inner: errors.SimplifyFastlyError(*http503), Remediation: errors.BugRemediation}, }, { name: "fastly.HTTPError 401", input: http401, want: errors.RemediationError{Inner: errors.SimplifyFastlyError(*http401), Remediation: errors.AuthRemediation()}, }, { name: "fastly.HTTPError 403", input: http403, want: errors.RemediationError{Inner: errors.SimplifyFastlyError(*http403), Remediation: errors.ForbiddenRemediation()}, }, { name: "undocumented APIError 401", input: undoc401, want: errors.RemediationError{Inner: undoc401, Remediation: errors.AuthRemediation()}, }, { name: "undocumented APIError 403", input: undoc403, want: errors.RemediationError{Inner: undoc403, Remediation: errors.ForbiddenRemediation()}, }, { name: "undocumented APIError 500", input: undoc500, want: errors.RemediationError{Inner: undoc500, Remediation: errors.BugRemediation}, }, { name: "wrapped undocumented APIError 401", input: wrappedUndoc401, want: errors.RemediationError{Inner: wrappedUndoc401, Remediation: errors.AuthRemediation()}, }, { name: "wrapped os.ErrNotExist", input: wrappedNotExist, want: errors.RemediationError{Inner: wrappedNotExist, Remediation: errors.HostRemediation}, }, { name: "temporary network error", input: isTemporary{fmt.Errorf("baz")}, want: errors.RemediationError{Inner: fmt.Errorf("baz"), Remediation: errors.NetworkRemediation}, }, } { t.Run(testcase.name, func(t *testing.T) { have := errors.Deduce(testcase.input) testutil.AssertString(t, testcase.want.Error(), have.Error()) testutil.AssertString(t, testcase.want.Remediation, have.Remediation) }) } } type isTemporary struct{ error } func (isTemporary) Temporary() bool { return true } ================================================ FILE: pkg/errors/doc.go ================================================ // Package errors contains functions to handle Fastly error types. package errors ================================================ FILE: pkg/errors/errors.go ================================================ package errors 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 returns a RemediationError for when no --token has been provided. func ErrNoToken() RemediationError { return RemediationError{ Inner: fmt.Errorf("no token provided"), Remediation: AuthRemediation(), } } // ErrNonInteractiveNoToken returns an error indicating no token is available // and the session cannot prompt interactively (e.g. --auto-yes or --accept-defaults). func ErrNonInteractiveNoToken() RemediationError { return RemediationError{ Inner: fmt.Errorf("no token provided"), Remediation: NonInteractiveAuthRemediation(), } } // 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, } // 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, } // ErrNoWorkspaceID means no --workspace-id or FASTLY_WORKSPACE_ID environment // variable found. var ErrNoWorkspaceID = RemediationError{ Inner: fmt.Errorf("error reading workspace ID: no workspace ID found"), Remediation: WorkspaceIDRemediation, } // 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, } // 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: 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: file not found"), 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`.", } // ErrInvalidContentOutputCombo means the user provided --content along with the // --verbose or --json flags, which are mutually exclusive behaviours. var ErrInvalidContentOutputCombo = RemediationError{ Inner: fmt.Errorf("invalid flag combination, --content cannot be used together with --json or --verbose"), Remediation: "Use either --content, --verbose or --json separately.", } // 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 mutually 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.", } // 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.", } // ErrInvalidComputeACLCombo means the user omitted either the operation, prefix, or action flag. var ErrInvalidNGWAFScopeType = RemediationError{ Inner: fmt.Errorf("--scope must be either `account` or `workspace`"), Remediation: "please set the account flag to either `account` or `workspace`.", } ================================================ FILE: pkg/errors/exit_error.go ================================================ 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()) } } ================================================ FILE: pkg/errors/log.go ================================================ package errors import ( "fmt" "os" "path/filepath" "regexp" "runtime" "strings" "sync" "text/template" "time" "github.com/fastly/go-fastly/v15/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 } ================================================ FILE: pkg/errors/log_test.go ================================================ 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) } ================================================ FILE: pkg/errors/process.go ================================================ 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 } ================================================ FILE: pkg/errors/remediation_error.go ================================================ package errors import ( "fmt" "io" "strings" "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 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 } // Unwrap returns the inner error. func (re RemediationError) Unwrap() error { return re.Inner } // Error prints the inner error string without any remediation suggestion. func (re RemediationError) Error() string { if re.Inner == nil { return "" } return re.Inner.Error() } // Print the error to the io.Writer for human consumption. If a prefix is // provided, it will be written without modification. The inner error is always // printed via text.Output with an "Error: " prefix and a "." suffix. If a // remediation is provided, it's printed via text.Output. func (re RemediationError) Print(w io.Writer) { if re.Prefix != "" { fmt.Fprintf(w, "%s\n\n", strings.TrimRight(re.Prefix, "\r\n")) } if re.Inner != nil { 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. func AuthRemediation() string { var parts []string if env.AuthCommandDisabled() { parts = []string{ "This error is likely caused by a missing, incorrect, or expired Fastly API token.", fmt.Sprintf("Token precedence: %s > fastly.toml profile > default auth token.", env.APIToken), fmt.Sprintf("Supply a token via %s.", env.APIToken), } } else { parts = []string{ "This error is likely caused by a missing, incorrect, or expired Fastly API token.", fmt.Sprintf("Token precedence: --token (raw or stored name) > %s > fastly.toml profile > default auth token.", env.APIToken), fmt.Sprintf("Run `fastly auth login` to authenticate, or supply a token via --token or %s.", env.APIToken), } } parts = append(parts, "Learn more: fastly.help/cli/cli-auth") return strings.Join(parts, " ") } // ForbiddenRemediation suggests the token may lack required permissions. func ForbiddenRemediation() string { parts := []string{ "This error may indicate insufficient token permissions, an incorrect account context,", "or restricted access to the requested resource.", "Check that your token has the required scope for this operation.", } if env.AuthCommandDisabled() { parts = append(parts, fmt.Sprintf("Verify your token has the required scope via %s or the Fastly dashboard.", env.APIToken)) } else { parts = append(parts, "You can re-authenticate with `fastly auth login` or check your current identity with `fastly whoami`.") } parts = append(parts, "Learn more: fastly.help/cli/cli-auth") return strings.Join(parts, " ") } // 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 DNS configuration, and try again.", }, " ") // HostRemediation suggests there might be an issue with the local host. var HostRemediation = strings.Join([]string{ "This error may be caused by a problem with your host environment, for example", "too-restrictive file permissions, files that already exist, or a full disk.", }, " ") // BugRemediation suggests filing a bug on the GitHub repo. It's good to include // as the final suggested remediation in many errors. var BugRemediation = strings.Join([]string{ "If you believe this error is the result of a bug, please file an issue:", "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 // fastly.toml. var ServiceIDRemediation = strings.Join([]string{ "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", }, " ") // WorkspaceIDRemediation suggests provide a customer ID via --workspace-id flag // or via environment variable. var WorkspaceIDRemediation = strings.Join([]string{ "Please provide one via the --workspace-id flag, or by setting the FASTLY_WORKSPACE_ID environment variable", }, " ") // ExistingDirRemediation suggests moving to another directory and retrying. 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", }, " ") // ErrProfileFlagNotFound is returned when --profile names an unknown auth token. func ErrProfileFlagNotFound(name string) RemediationError { return RemediationError{ Inner: fmt.Errorf("profile %q (from --profile) not found in auth config", name), Remediation: ProfileRemediation(), } } // ProfileRemediation suggests running auth commands. func ProfileRemediation() string { if env.AuthCommandDisabled() { return fmt.Sprintf("Supply a token via the %s environment variable.", env.APIToken) } return "Run `fastly auth login` to authenticate, or `fastly auth list` to view stored tokens." } // 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. func TokenExpirationRemediation() string { if env.AuthCommandDisabled() { return fmt.Sprintf("Supply a fresh token via the %s environment variable.", env.APIToken) } return "Run 'fastly auth login --sso --token ' to refresh the token." } // TokenExpirationRemediationForType returns remediation text appropriate for // the given token type ("static", "sso", or "" for unknown/default). // // NOTE: tokenType values must match config.AuthTokenTypeStatic / config.AuthTokenTypeSSO. // We cannot import pkg/config here (pkg/errors is a foundational package), so the // string literals are used directly. Callers should pass config.AuthToken.Type. func TokenExpirationRemediationForType(tokenType string) string { if env.AuthCommandDisabled() { return fmt.Sprintf("Supply a fresh token via the %s environment variable.", env.APIToken) } if tokenType == "static" { return "Generate a new token from the Fastly dashboard or run 'fastly auth add'." } return "Run 'fastly auth login --sso --token ' to refresh the token." } // NonInteractiveAuthRemediation tells the user how to supply a token when // interactive prompts are suppressed. func NonInteractiveAuthRemediation() string { parts := []string{"Interactive authentication is not available in this mode."} if env.AuthCommandDisabled() { parts = append(parts, fmt.Sprintf("Supply a token via the %s environment variable.", env.APIToken)) } else { parts = append(parts, fmt.Sprintf("Supply a token via --token or the %s environment variable.", env.APIToken)) } parts = append(parts, "Learn more: fastly.help/cli/cli-auth") return strings.Join(parts, " ") } ================================================ FILE: pkg/errors/remediation_test.go ================================================ package errors_test import ( "strings" "testing" "github.com/fastly/cli/pkg/errors" ) func TestRemediationDisableAuthCommand(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "") t.Run("AuthRemediation includes auth login by default", func(t *testing.T) { msg := errors.AuthRemediation() if !strings.Contains(msg, "fastly auth login") { t.Errorf("expected AuthRemediation to mention 'fastly auth login', got: %s", msg) } }) t.Run("AuthRemediation omits auth login and --token when env var set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") msg := errors.AuthRemediation() if strings.Contains(msg, "fastly auth login") { t.Errorf("expected AuthRemediation to omit 'fastly auth login', got: %s", msg) } if strings.Contains(msg, "--token") { t.Errorf("expected AuthRemediation to omit --token, got: %s", msg) } if !strings.Contains(msg, "FASTLY_API_TOKEN") { t.Errorf("expected AuthRemediation to mention FASTLY_API_TOKEN, got: %s", msg) } }) t.Run("ForbiddenRemediation includes auth login by default", func(t *testing.T) { msg := errors.ForbiddenRemediation() if !strings.Contains(msg, "fastly auth login") { t.Errorf("expected ForbiddenRemediation to mention 'fastly auth login', got: %s", msg) } }) t.Run("ForbiddenRemediation omits auth login and whoami when env var set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") msg := errors.ForbiddenRemediation() if strings.Contains(msg, "fastly auth login") { t.Errorf("expected ForbiddenRemediation to omit 'fastly auth login', got: %s", msg) } if strings.Contains(msg, "fastly whoami") { t.Errorf("expected ForbiddenRemediation to omit 'fastly whoami', got: %s", msg) } if !strings.Contains(msg, "FASTLY_API_TOKEN") { t.Errorf("expected ForbiddenRemediation to mention FASTLY_API_TOKEN, got: %s", msg) } }) t.Run("ErrNoToken remediation responds to env var", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") re := errors.ErrNoToken() if strings.Contains(re.Remediation, "fastly auth login") { t.Errorf("expected ErrNoToken remediation to omit 'fastly auth login', got: %s", re.Remediation) } }) t.Run("NonInteractiveAuthRemediation includes --token by default", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "") msg := errors.NonInteractiveAuthRemediation() if !strings.Contains(msg, "--token") { t.Errorf("expected NonInteractiveAuthRemediation to mention --token, got: %s", msg) } }) t.Run("NonInteractiveAuthRemediation omits --token when env var set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") msg := errors.NonInteractiveAuthRemediation() if strings.Contains(msg, "--token") { t.Errorf("expected NonInteractiveAuthRemediation to omit --token, got: %s", msg) } if !strings.Contains(msg, "FASTLY_API_TOKEN") { t.Errorf("expected NonInteractiveAuthRemediation to mention FASTLY_API_TOKEN, got: %s", msg) } }) t.Run("ErrNonInteractiveNoToken omits --token when env var set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") re := errors.ErrNonInteractiveNoToken() if strings.Contains(re.Remediation, "--token") { t.Errorf("expected ErrNonInteractiveNoToken remediation to omit --token, got: %s", re.Remediation) } if !strings.Contains(re.Remediation, "FASTLY_API_TOKEN") { t.Errorf("expected ErrNonInteractiveNoToken remediation to mention FASTLY_API_TOKEN, got: %s", re.Remediation) } }) t.Run("ProfileRemediation omits --token when env var set", func(t *testing.T) { t.Setenv("FASTLY_DISABLE_AUTH_COMMAND", "1") msg := errors.ProfileRemediation() if strings.Contains(msg, "--token") { t.Errorf("expected ProfileRemediation to omit --token, got: %s", msg) } if !strings.Contains(msg, "FASTLY_API_TOKEN") { t.Errorf("expected ProfileRemediation to mention FASTLY_API_TOKEN, got: %s", msg) } }) } ================================================ FILE: pkg/errors/testdata/errors-expected-rotation.log ================================================ 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 ------------------------------ ================================================ FILE: pkg/errors/testdata/errors-expected.log ================================================ 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 ------------------------------ ================================================ FILE: pkg/exec/doc.go ================================================ // Package exec contains helper abstractions for working with external commands. package exec ================================================ FILE: pkg/exec/exec.go ================================================ 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 } ================================================ FILE: pkg/file/archive.go ================================================ package file import ( "context" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "github.com/mholt/archives" "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 } // ExtractArchive extracts an archive file to a destination directory. // The filter function, if provided, determines which files to extract based on their path in the archive. // If filter returns false for a file, it will be skipped. func ExtractArchive(archivePath, destDir string, filter func(string) bool) error { ctx := context.Background() input, err := os.Open(archivePath) if err != nil { return fmt.Errorf("error opening archive: %w", err) } defer input.Close() format, stream, err := archives.Identify(ctx, archivePath, input) if err != nil { return fmt.Errorf("error identifying archive format: %w", err) } if ex, ok := format.(archives.Extractor); ok { err = ex.Extract(ctx, stream, func(_ context.Context, f archives.FileInfo) error { // Apply filter if provided if filter != nil && !filter(f.NameInArchive) { return nil } destPath := filepath.Join(destDir, f.NameInArchive) if f.IsDir() { return os.MkdirAll(destPath, 0o755) } if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return err } outFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) if err != nil { return err } defer outFile.Close() rc, err := f.Open() if err != nil { return err } defer rc.Close() _, err = io.Copy(outFile, rc) return err }) if err != nil { return fmt.Errorf("error extracting contents from archive: %w", err) } } else { return fmt.Errorf("format does not support extraction") } return nil } // Extract all files and folders from the collection. func (a ArchiveBase) Extract() error { if err := ExtractArchive(a.Filename(), a.Dst, nil); err != nil { return 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) } ================================================ FILE: pkg/file/doc.go ================================================ // Package file contains functions to handle different file formats. package file ================================================ FILE: pkg/filesystem/directory.go ================================================ package filesystem import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" ) // FileExists asserts whether a file path exists. func FileExists(path string) bool { _, err := os.Stat(path) 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. 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. func CopyFile(src, dst string) (err error) { // Get source file stats. ss, err := os.Stat(src) if err != nil { return fmt.Errorf("cannot read source file: %s", src) } // Assert that source file is regular file. if !ss.Mode().IsRegular() { // Cannot copy non-regular files (e.g., directories, // symlinks, devices, etc.) return fmt.Errorf("non-regular source file: %s", src) // #nosec G307 } // Get destination file stats. ds, err := os.Stat(dst) if err != nil { if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("cannot read destination file: %s", dst) } } else { // Assert that source file is regular file. if !ds.Mode().IsRegular() { return fmt.Errorf("non-regular destination file: %s", src) } // If same file, return successfully. if os.SameFile(ss, ds) { return nil } } // Open source file for reading. in, err := os.Open(filepath.Clean(src)) if err != nil { return fmt.Errorf("error reading source file: %w", err) } defer in.Close() // #nosec G307 // Create all directories of destination 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) } return out.Sync() } // MakeDirectoryIfNotExists asserts whether a directory exists and makes it // if not. Returns nil if exists or successfully made. func MakeDirectoryIfNotExists(path string) error { fi, err := os.Stat(path) switch { case err == nil && fi.IsDir(): return nil case err == nil && !fi.IsDir(): return fmt.Errorf("%s already exists as a regular file", path) case errors.Is(err, fs.ErrNotExist): return os.MkdirAll(path, 0o750) case err != nil: return err } return nil } ================================================ FILE: pkg/filesystem/doc.go ================================================ // Package filesystem contains functions to handle file operations. package filesystem ================================================ FILE: pkg/filesystem/home.go ================================================ 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 } ================================================ FILE: pkg/fmt/doc.go ================================================ // Package fmt contains helper functions for formatting text. package fmt ================================================ FILE: pkg/fmt/fmt.go ================================================ 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() } ================================================ FILE: pkg/github/doc.go ================================================ // Package github contains functions for checking the latest software // versions hosted by GitHub. package github ================================================ FILE: pkg/github/github.go ================================================ package github import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "runtime" "strings" "github.com/blang/semver" "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/debug" "github.com/fastly/cli/pkg/file" 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) } // Extract using shared utility function if err := file.ExtractArchive(archive, dst, func(name string) bool { return strings.HasPrefix(name, extractPath) }); 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 } ================================================ FILE: pkg/github/github_test.go ================================================ 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) } }) } } ================================================ FILE: pkg/global/doc.go ================================================ // Package global exposes a type to contain global'ish data. // Effectively we use it to avoid an unfortunate import loop issue. package global ================================================ FILE: pkg/global/global.go ================================================ package global import ( "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 ErrOutput io.Writer // 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 // SSORunner runs the SSO authentication flow. It is set by commands.Define() // so that app/run.go can invoke SSO without a registered command. SSORunner func(in io.Reader, out io.Writer, forceReAuth bool, skipPrompt bool) error // Versioners contains multiple software versioning checkers. // e.g. Check for latest CLI or Viceroy version. Versioners Versioners } // Token yields the Fastly API token. // // Order of precedence: // - The --token flag (if it matches a stored auth token name, use that token). // - The --token flag (treated as a raw API token). // - The --profile/-o flag (must match a stored auth token name). // - The FASTLY_API_TOKEN environment variable. // - The `profile` manifest field mapped to an auth token name. // - The default [auth] token (if configured). func (d *Data) Token() (string, lookup.Source) { if d.Flags.Token != "" { if at := d.Config.GetAuthToken(d.Flags.Token); at != nil && at.Token != "" { return at.Token, lookup.SourceAuth } return d.Flags.Token, lookup.SourceFlag } if d.Flags.Profile != "" { if at, ok := d.profileFlagToken(); ok { return at.Token, lookup.SourceAuth } return "", lookup.SourceUndefined } if d.Env.APIToken != "" { return d.Env.APIToken, lookup.SourceEnvironment } if d.Manifest != nil && d.Manifest.File.Profile != "" { if at := d.Config.GetAuthToken(d.Manifest.File.Profile); at != nil && at.Token != "" { return at.Token, lookup.SourceAuth } } if _, at := d.Config.GetDefaultAuthToken(); at != nil && at.Token != "" { return at.Token, lookup.SourceAuth } return "", lookup.SourceUndefined } func (d *Data) profileFlagToken() (*config.AuthToken, bool) { if d.Flags.Profile == "" { return nil, false } at := d.Config.GetAuthToken(d.Flags.Profile) if at == nil || at.Token == "" { return nil, false } return at, true } // ValidateProfileFlag returns an error if --profile/-o is set to a name that // does not resolve to a stored auth token. --token outranks --profile and // short-circuits the check. func (d *Data) ValidateProfileFlag() error { if d.Flags.Token != "" || d.Flags.Profile == "" { return nil } if _, ok := d.profileFlagToken(); ok { return nil } return fsterr.ErrProfileFlagNotFound(d.Flags.Profile) } // AuthTokenName returns the name of the auth token being used, if any. // This is used for display purposes and for SSO refresh of named tokens. func (d *Data) AuthTokenName() string { if d.Flags.Token != "" { if at := d.Config.GetAuthToken(d.Flags.Token); at != nil { return d.Flags.Token } return "" } if d.Flags.Profile != "" { if _, ok := d.profileFlagToken(); ok { return d.Flags.Profile } return "" } if d.Manifest != nil && d.Manifest.File.Profile != "" { if at := d.Config.GetAuthToken(d.Manifest.File.Profile); at != nil { return d.Manifest.File.Profile } } name, _ := d.Config.GetDefaultAuthToken() return name } // 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 // JSON indicates --json output was requested. Detected automatically by // Exec. Unlike Quiet, JSON mode does not suppress stderr warnings. JSON 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 } ================================================ FILE: pkg/global/global_test.go ================================================ package global_test import ( "testing" "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/lookup" "github.com/fastly/cli/pkg/manifest" "github.com/fastly/cli/pkg/threadsafe" ) func TestToken(t *testing.T) { tests := []struct { name string data *global.Data wantToken string wantSource lookup.Source }{ { name: "token flag matches stored auth token name", data: &global.Data{ Flags: global.Flags{Token: "myname"}, Config: config.File{ Auth: config.Auth{ Default: "myname", Tokens: config.AuthTokens{ "myname": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "stored-token-value", }, }, }, }, }, wantToken: "stored-token-value", wantSource: lookup.SourceAuth, }, { name: "token flag raw value when no stored name matches", data: &global.Data{ Flags: global.Flags{Token: "raw-api-token"}, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "other-token", }, }, }, }, }, wantToken: "raw-api-token", wantSource: lookup.SourceFlag, }, { name: "manifest profile selects stored auth token", data: &global.Data{ Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Default: "default-user", Tokens: config.AuthTokens{ "default-user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "default-token", }, "proj": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "project-token", }, }, }, }, }, wantToken: "project-token", wantSource: lookup.SourceAuth, }, { name: "manifest profile falls through when no matching auth token", data: &global.Data{ Manifest: &manifest.Data{ File: manifest.File{Profile: "missing"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "default-token", }, }, }, }, }, wantToken: "default-token", wantSource: lookup.SourceAuth, }, { name: "env var takes precedence over manifest profile", data: &global.Data{ Env: config.Environment{APIToken: "env-token"}, Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "proj": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "project-token", }, }, }, }, }, wantToken: "env-token", wantSource: lookup.SourceEnvironment, }, { name: "no token sources returns undefined", data: &global.Data{ Config: config.File{}, }, wantToken: "", wantSource: lookup.SourceUndefined, }, { name: "profile flag matches stored auth token", data: &global.Data{ Flags: global.Flags{Profile: "alt"}, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, }, }, }, }, wantToken: "alt-token", wantSource: lookup.SourceAuth, }, { name: "profile flag unknown does not fall back even when env/manifest/default exist", data: &global.Data{ Flags: global.Flags{Profile: "missing"}, Env: config.Environment{APIToken: "env-token"}, Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, "proj": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "project-token"}, }, }, }, }, wantToken: "", wantSource: lookup.SourceUndefined, }, { name: "profile flag matches stored entry with empty token returns undefined", data: &global.Data{ Flags: global.Flags{Profile: "blank"}, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, "blank": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: ""}, }, }, }, }, wantToken: "", wantSource: lookup.SourceUndefined, }, { name: "token flag raw value wins over profile flag", data: &global.Data{ Flags: global.Flags{Token: "raw-xyz", Profile: "alt"}, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, }, }, }, }, wantToken: "raw-xyz", wantSource: lookup.SourceFlag, }, { name: "token flag matching stored name wins over profile flag", data: &global.Data{ Flags: global.Flags{Token: "primary", Profile: "alt"}, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "primary-token"}, "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, }, }, }, }, wantToken: "primary-token", wantSource: lookup.SourceAuth, }, { name: "profile flag wins over env var", data: &global.Data{ Flags: global.Flags{Profile: "alt"}, Env: config.Environment{APIToken: "env-token"}, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, }, }, }, }, wantToken: "alt-token", wantSource: lookup.SourceAuth, }, { name: "profile flag wins over manifest profile", data: &global.Data{ Flags: global.Flags{Profile: "alt"}, Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, "proj": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "project-token"}, }, }, }, }, wantToken: "alt-token", wantSource: lookup.SourceAuth, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotToken, gotSource := tt.data.Token() if gotToken != tt.wantToken { t.Errorf("Token() token = %q, want %q", gotToken, tt.wantToken) } if gotSource != tt.wantSource { t.Errorf("Token() source = %v, want %v", gotSource, tt.wantSource) } }) } } func TestAuthTokenName(t *testing.T) { tests := []struct { name string data *global.Data wantName string }{ { name: "token flag matches stored name", data: &global.Data{ Flags: global.Flags{Token: "myname"}, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "myname": &config.AuthToken{Token: "t"}, }, }, }, }, wantName: "myname", }, { name: "token flag raw value returns empty", data: &global.Data{ Flags: global.Flags{Token: "raw-value"}, Config: config.File{ Auth: config.Auth{ Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, }, }, }, }, wantName: "", }, { name: "manifest profile returns profile name", data: &global.Data{ Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Default: "default-user", Tokens: config.AuthTokens{ "default-user": &config.AuthToken{Token: "t"}, "proj": &config.AuthToken{Token: "t2"}, }, }, }, }, wantName: "proj", }, { name: "manifest profile missing falls through to default", data: &global.Data{ Manifest: &manifest.Data{ File: manifest.File{Profile: "missing"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, }, }, }, }, wantName: "user", }, { name: "default auth token name", data: &global.Data{ Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, }, }, }, }, wantName: "user", }, { name: "profile flag matches stored name with non-empty token", data: &global.Data{ Flags: global.Flags{Profile: "alt"}, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, "alt": &config.AuthToken{Token: "alt-token"}, }, }, }, }, wantName: "alt", }, { name: "profile flag matches stored entry with empty token returns empty", data: &global.Data{ Flags: global.Flags{Profile: "blank"}, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, "blank": &config.AuthToken{Token: ""}, }, }, }, }, wantName: "", }, { name: "profile flag unknown returns empty without falling through", data: &global.Data{ Flags: global.Flags{Profile: "missing"}, Manifest: &manifest.Data{ File: manifest.File{Profile: "proj"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{Token: "t"}, "proj": &config.AuthToken{Token: "t2"}, }, }, }, }, wantName: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.data.AuthTokenName() if got != tt.wantName { t.Errorf("AuthTokenName() = %q, want %q", got, tt.wantName) } }) } } func TestTokenManifestProfileMissingNoSideEffect(t *testing.T) { var buf threadsafe.Buffer d := &global.Data{ Manifest: &manifest.Data{ File: manifest.File{Profile: "missing"}, }, Config: config.File{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "default-token", }, }, }, }, Output: &buf, } token, source := d.Token() if token != "default-token" { t.Errorf("Token() = %q, want %q", token, "default-token") } if source != lookup.SourceAuth { t.Errorf("Token() source = %v, want %v", source, lookup.SourceAuth) } if buf.String() != "" { t.Errorf("Token() should not write to output, got: %q", buf.String()) } } ================================================ FILE: pkg/internal/beacon/beacon.go ================================================ 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 } ================================================ FILE: pkg/internal/beacon/beacon_test.go ================================================ 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 } ================================================ FILE: pkg/internal/beacon/doc.go ================================================ // 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 ================================================ FILE: pkg/lookup/doc.go ================================================ // Package lookup defines an enum that identifies a parameter's source. package lookup ================================================ FILE: pkg/lookup/lookup.go ================================================ 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 // SourceAuth indicates the parameter came from the [auth] config section. SourceAuth ) ================================================ FILE: pkg/manifest/data.go ================================================ 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 } ================================================ FILE: pkg/manifest/doc.go ================================================ // Package manifest contains functions and objects for managing interactions // with the Fastly manifest file. package manifest ================================================ FILE: pkg/manifest/file.go ================================================ 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"` // 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"` // Profile selects a named auth token from the CLI config for this project. Profile string `toml:"profile,omitempty"` // 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.Pushpin != nil { pushpin := make(map[string]any) if f.LocalServer.Pushpin.EnablePushpin != nil { pushpin["enable"] = *f.LocalServer.Pushpin.EnablePushpin } if f.LocalServer.Pushpin.PushpinPath != nil { pushpin["pushpin_path"] = *f.LocalServer.Pushpin.PushpinPath } if f.LocalServer.Pushpin.PushpinProxyPort != nil { pushpin["proxy_port"] = *f.LocalServer.Pushpin.PushpinProxyPort } if f.LocalServer.Pushpin.PushpinPublishPort != nil { pushpin["publish_port"] = *f.LocalServer.Pushpin.PushpinPublishPort } localServer["pushpin"] = pushpin } 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 } if e.Env != "" { obj["env"] = e.Env } 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"` LocalServer any `toml:"local_server"` // override this field ManifestVersion Version `toml:"manifest_version"` Name string `toml:"name"` Profile string `toml:"profile,omitempty"` 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, LocalServer: localServer, ManifestVersion: f.ManifestVersion, Name: f.Name, Profile: f.Profile, 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) } defer r.Close() scanner := bufio.NewScanner(r) for scanner.Scan() { parts := strings.SplitN(scanner.Text(), "=", 2) 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 } ================================================ FILE: pkg/manifest/flags.go ================================================ 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 } ================================================ FILE: pkg/manifest/local_server.go ================================================ 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"` Pushpin *LocalPushpinMap `toml:"pushpin,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"` Env string `toml:"env,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 } // LocalPushpinMap represents configuration of a local instance of Pushpin, // used for local experimentation and testing of handoff_fanout. type LocalPushpinMap struct { EnablePushpin *bool `toml:"enable,omitempty"` PushpinPath *string `toml:"pushpin_path,omitempty"` PushpinProxyPort *uint16 `toml:"proxy_port,omitempty"` PushpinPublishPort *uint16 `toml:"publish_port,omitempty"` } 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) } ================================================ FILE: pkg/manifest/local_server_test.go ================================================ 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) } }) } } ================================================ FILE: pkg/manifest/manifest.go ================================================ 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" ) ================================================ FILE: pkg/manifest/manifest_test.go ================================================ 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) } } func TestParseEnvFile(t *testing.T) { tests := map[string]struct { content string wantVars []string wantErr bool }{ "simple key=value": { content: "FOO=bar\n", wantVars: []string{"FOO=bar"}, }, "value contains equals sign": { content: "SECRET=dGVzdA==\n", wantVars: []string{"SECRET=dGVzdA=="}, }, "multiple entries with equals in values": { content: "A=1\nB=x=y=z\n", wantVars: []string{"A=1", "B=x=y=z"}, }, "invalid line without equals": { content: "BADLINE\n", wantErr: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { tmp := t.TempDir() envPath := filepath.Join(tmp, ".env") if err := os.WriteFile(envPath, []byte(tc.content), 0o600); err != nil { t.Fatal(err) } f := &manifest.File{} f.Scripts.EnvFile = envPath err := f.ParseEnvFile() if tc.wantErr { if err == nil { t.Fatal("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if diff := cmp.Diff(tc.wantVars, f.Scripts.EnvVars); diff != "" { t.Fatalf("EnvVars mismatch (-want +got):\n%s", diff) } }) } } ================================================ FILE: pkg/manifest/setup.go ================================================ 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.ObjectStores) > 0 { defined = true } if len(s.KVStores) > 0 { defined = true } if len(s.SecretStores) > 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 { File string `toml:"file,omitempty"` 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"` } ================================================ FILE: pkg/manifest/testdata/fastly-invalid-missing-version.toml ================================================ name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/manifest/testdata/fastly-invalid-unrecognised.toml ================================================ # 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" ================================================ FILE: pkg/manifest/testdata/fastly-invalid-version-exceeded.toml ================================================ 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" ================================================ FILE: pkg/manifest/testdata/fastly-missing-spec-url.toml ================================================ manifest_version = 3 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/manifest/testdata/fastly-valid-integer.toml ================================================ manifest_version = 3 name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] language = "rust" ================================================ FILE: pkg/manifest/testdata/fastly-valid-semver.toml ================================================ 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" ================================================ FILE: pkg/manifest/testdata/fastly-viceroy-update.toml ================================================ # 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.pushpin] enable = true pushpin_path = "path/to/pushpin" proxy_port = 7777 publish_port = 6666 [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" [[local_server.secret_stores.store_two]] key = "fourth" env = "ENV_FOURTH" ================================================ FILE: pkg/manifest/testdata/fastly-warning-dictionaries.toml ================================================ 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" ================================================ FILE: pkg/manifest/version.go ================================================ 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 } ================================================ FILE: pkg/mock/api.go ================================================ package mock import ( "context" "crypto/ed25519" "github.com/fastly/go-fastly/v15/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 { AllDatacentersFn func(context.Context) ([]fastly.Datacenter, error) AllIPsFn func(context.Context) (fastly.IPAddrs, fastly.IPAddrs, error) CreateServiceFn func(context.Context, *fastly.CreateServiceInput) (*fastly.Service, error) GetServicesFn func(context.Context, *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] ListServicesFn func(context.Context, *fastly.ListServicesInput) ([]*fastly.Service, error) GetServiceFn func(context.Context, *fastly.GetServiceInput) (*fastly.Service, error) GetServiceDetailsFn func(context.Context, *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) UpdateServiceFn func(context.Context, *fastly.UpdateServiceInput) (*fastly.Service, error) DeleteServiceFn func(context.Context, *fastly.DeleteServiceInput) error SearchServiceFn func(context.Context, *fastly.SearchServiceInput) (*fastly.Service, error) CloneVersionFn func(context.Context, *fastly.CloneVersionInput) (*fastly.Version, error) ListVersionsFn func(context.Context, *fastly.ListVersionsInput) ([]*fastly.Version, error) GetVersionFn func(context.Context, *fastly.GetVersionInput) (*fastly.Version, error) UpdateVersionFn func(context.Context, *fastly.UpdateVersionInput) (*fastly.Version, error) ActivateVersionFn func(context.Context, *fastly.ActivateVersionInput) (*fastly.Version, error) DeactivateVersionFn func(context.Context, *fastly.DeactivateVersionInput) (*fastly.Version, error) LockVersionFn func(context.Context, *fastly.LockVersionInput) (*fastly.Version, error) LatestVersionFn func(context.Context, *fastly.LatestVersionInput) (*fastly.Version, error) ValidateVersionFn func(context.Context, *fastly.ValidateVersionInput) (bool, string, error) CreateDomainFn func(context.Context, *fastly.CreateDomainInput) (*fastly.Domain, error) ListDomainsFn func(context.Context, *fastly.ListDomainsInput) ([]*fastly.Domain, error) GetDomainFn func(context.Context, *fastly.GetDomainInput) (*fastly.Domain, error) UpdateDomainFn func(context.Context, *fastly.UpdateDomainInput) (*fastly.Domain, error) DeleteDomainFn func(context.Context, *fastly.DeleteDomainInput) error ValidateDomainFn func(context.Context, *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) ValidateAllDomainsFn func(context.Context, *fastly.ValidateAllDomainsInput) ([]*fastly.DomainValidationResult, error) CreateBackendFn func(context.Context, *fastly.CreateBackendInput) (*fastly.Backend, error) ListBackendsFn func(context.Context, *fastly.ListBackendsInput) ([]*fastly.Backend, error) GetBackendFn func(context.Context, *fastly.GetBackendInput) (*fastly.Backend, error) UpdateBackendFn func(context.Context, *fastly.UpdateBackendInput) (*fastly.Backend, error) DeleteBackendFn func(context.Context, *fastly.DeleteBackendInput) error CreateHealthCheckFn func(context.Context, *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) ListHealthChecksFn func(context.Context, *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) GetHealthCheckFn func(context.Context, *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) UpdateHealthCheckFn func(context.Context, *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) DeleteHealthCheckFn func(context.Context, *fastly.DeleteHealthCheckInput) error GetPackageFn func(context.Context, *fastly.GetPackageInput) (*fastly.Package, error) UpdatePackageFn func(context.Context, *fastly.UpdatePackageInput) (*fastly.Package, error) CreateDictionaryFn func(context.Context, *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) GetDictionaryFn func(context.Context, *fastly.GetDictionaryInput) (*fastly.Dictionary, error) DeleteDictionaryFn func(context.Context, *fastly.DeleteDictionaryInput) error ListDictionariesFn func(context.Context, *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) UpdateDictionaryFn func(context.Context, *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) GetDictionaryItemsFn func(context.Context, *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] ListDictionaryItemsFn func(context.Context, *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) GetDictionaryItemFn func(context.Context, *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) CreateDictionaryItemFn func(context.Context, *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) UpdateDictionaryItemFn func(context.Context, *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) DeleteDictionaryItemFn func(context.Context, *fastly.DeleteDictionaryItemInput) error BatchModifyDictionaryItemsFn func(context.Context, *fastly.BatchModifyDictionaryItemsInput) error GetDictionaryInfoFn func(context.Context, *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) CreateBigQueryFn func(context.Context, *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) ListBigQueriesFn func(context.Context, *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) GetBigQueryFn func(context.Context, *fastly.GetBigQueryInput) (*fastly.BigQuery, error) UpdateBigQueryFn func(context.Context, *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) DeleteBigQueryFn func(context.Context, *fastly.DeleteBigQueryInput) error CreateS3Fn func(context.Context, *fastly.CreateS3Input) (*fastly.S3, error) ListS3sFn func(context.Context, *fastly.ListS3sInput) ([]*fastly.S3, error) GetS3Fn func(context.Context, *fastly.GetS3Input) (*fastly.S3, error) UpdateS3Fn func(context.Context, *fastly.UpdateS3Input) (*fastly.S3, error) DeleteS3Fn func(context.Context, *fastly.DeleteS3Input) error CreateKinesisFn func(context.Context, *fastly.CreateKinesisInput) (*fastly.Kinesis, error) ListKinesisFn func(context.Context, *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) GetKinesisFn func(context.Context, *fastly.GetKinesisInput) (*fastly.Kinesis, error) UpdateKinesisFn func(context.Context, *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) DeleteKinesisFn func(context.Context, *fastly.DeleteKinesisInput) error CreateSyslogFn func(context.Context, *fastly.CreateSyslogInput) (*fastly.Syslog, error) ListSyslogsFn func(context.Context, *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) GetSyslogFn func(context.Context, *fastly.GetSyslogInput) (*fastly.Syslog, error) UpdateSyslogFn func(context.Context, *fastly.UpdateSyslogInput) (*fastly.Syslog, error) DeleteSyslogFn func(context.Context, *fastly.DeleteSyslogInput) error CreateLogentriesFn func(context.Context, *fastly.CreateLogentriesInput) (*fastly.Logentries, error) ListLogentriesFn func(context.Context, *fastly.ListLogentriesInput) ([]*fastly.Logentries, error) GetLogentriesFn func(context.Context, *fastly.GetLogentriesInput) (*fastly.Logentries, error) UpdateLogentriesFn func(context.Context, *fastly.UpdateLogentriesInput) (*fastly.Logentries, error) DeleteLogentriesFn func(context.Context, *fastly.DeleteLogentriesInput) error CreatePapertrailFn func(context.Context, *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) ListPapertrailsFn func(context.Context, *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) GetPapertrailFn func(context.Context, *fastly.GetPapertrailInput) (*fastly.Papertrail, error) UpdatePapertrailFn func(context.Context, *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) DeletePapertrailFn func(context.Context, *fastly.DeletePapertrailInput) error CreateSumologicFn func(context.Context, *fastly.CreateSumologicInput) (*fastly.Sumologic, error) ListSumologicsFn func(context.Context, *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) GetSumologicFn func(context.Context, *fastly.GetSumologicInput) (*fastly.Sumologic, error) UpdateSumologicFn func(context.Context, *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) DeleteSumologicFn func(context.Context, *fastly.DeleteSumologicInput) error CreateGCSFn func(context.Context, *fastly.CreateGCSInput) (*fastly.GCS, error) ListGCSsFn func(context.Context, *fastly.ListGCSsInput) ([]*fastly.GCS, error) GetGCSFn func(context.Context, *fastly.GetGCSInput) (*fastly.GCS, error) UpdateGCSFn func(context.Context, *fastly.UpdateGCSInput) (*fastly.GCS, error) DeleteGCSFn func(context.Context, *fastly.DeleteGCSInput) error CreateGrafanaCloudLogsFn func(context.Context, *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) ListGrafanaCloudLogsFn func(context.Context, *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) GetGrafanaCloudLogsFn func(context.Context, *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) UpdateGrafanaCloudLogsFn func(context.Context, *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) DeleteGrafanaCloudLogsFn func(context.Context, *fastly.DeleteGrafanaCloudLogsInput) error CreateFTPFn func(context.Context, *fastly.CreateFTPInput) (*fastly.FTP, error) ListFTPsFn func(context.Context, *fastly.ListFTPsInput) ([]*fastly.FTP, error) GetFTPFn func(context.Context, *fastly.GetFTPInput) (*fastly.FTP, error) UpdateFTPFn func(context.Context, *fastly.UpdateFTPInput) (*fastly.FTP, error) DeleteFTPFn func(context.Context, *fastly.DeleteFTPInput) error CreateSplunkFn func(context.Context, *fastly.CreateSplunkInput) (*fastly.Splunk, error) ListSplunksFn func(context.Context, *fastly.ListSplunksInput) ([]*fastly.Splunk, error) GetSplunkFn func(context.Context, *fastly.GetSplunkInput) (*fastly.Splunk, error) UpdateSplunkFn func(context.Context, *fastly.UpdateSplunkInput) (*fastly.Splunk, error) DeleteSplunkFn func(context.Context, *fastly.DeleteSplunkInput) error CreateScalyrFn func(context.Context, *fastly.CreateScalyrInput) (*fastly.Scalyr, error) ListScalyrsFn func(context.Context, *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) GetScalyrFn func(context.Context, *fastly.GetScalyrInput) (*fastly.Scalyr, error) UpdateScalyrFn func(context.Context, *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) DeleteScalyrFn func(context.Context, *fastly.DeleteScalyrInput) error CreateLogglyFn func(context.Context, *fastly.CreateLogglyInput) (*fastly.Loggly, error) ListLogglyFn func(context.Context, *fastly.ListLogglyInput) ([]*fastly.Loggly, error) GetLogglyFn func(context.Context, *fastly.GetLogglyInput) (*fastly.Loggly, error) UpdateLogglyFn func(context.Context, *fastly.UpdateLogglyInput) (*fastly.Loggly, error) DeleteLogglyFn func(context.Context, *fastly.DeleteLogglyInput) error CreateHoneycombFn func(context.Context, *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) ListHoneycombsFn func(context.Context, *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) GetHoneycombFn func(context.Context, *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) UpdateHoneycombFn func(context.Context, *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) DeleteHoneycombFn func(context.Context, *fastly.DeleteHoneycombInput) error CreateHerokuFn func(context.Context, *fastly.CreateHerokuInput) (*fastly.Heroku, error) ListHerokusFn func(context.Context, *fastly.ListHerokusInput) ([]*fastly.Heroku, error) GetHerokuFn func(context.Context, *fastly.GetHerokuInput) (*fastly.Heroku, error) UpdateHerokuFn func(context.Context, *fastly.UpdateHerokuInput) (*fastly.Heroku, error) DeleteHerokuFn func(context.Context, *fastly.DeleteHerokuInput) error CreateSFTPFn func(context.Context, *fastly.CreateSFTPInput) (*fastly.SFTP, error) ListSFTPsFn func(context.Context, *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) GetSFTPFn func(context.Context, *fastly.GetSFTPInput) (*fastly.SFTP, error) UpdateSFTPFn func(context.Context, *fastly.UpdateSFTPInput) (*fastly.SFTP, error) DeleteSFTPFn func(context.Context, *fastly.DeleteSFTPInput) error CreateLogshuttleFn func(context.Context, *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) ListLogshuttlesFn func(context.Context, *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) GetLogshuttleFn func(context.Context, *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) UpdateLogshuttleFn func(context.Context, *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) DeleteLogshuttleFn func(context.Context, *fastly.DeleteLogshuttleInput) error CreateCloudfilesFn func(context.Context, *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) ListCloudfilesFn func(context.Context, *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) GetCloudfilesFn func(context.Context, *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) UpdateCloudfilesFn func(context.Context, *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) DeleteCloudfilesFn func(context.Context, *fastly.DeleteCloudfilesInput) error CreateDigitalOceanFn func(context.Context, *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) ListDigitalOceansFn func(context.Context, *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) GetDigitalOceanFn func(context.Context, *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) UpdateDigitalOceanFn func(context.Context, *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) DeleteDigitalOceanFn func(context.Context, *fastly.DeleteDigitalOceanInput) error CreateElasticsearchFn func(context.Context, *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) ListElasticsearchFn func(context.Context, *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) GetElasticsearchFn func(context.Context, *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) UpdateElasticsearchFn func(context.Context, *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) DeleteElasticsearchFn func(context.Context, *fastly.DeleteElasticsearchInput) error CreateBlobStorageFn func(context.Context, *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) ListBlobStoragesFn func(context.Context, *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) GetBlobStorageFn func(context.Context, *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) UpdateBlobStorageFn func(context.Context, *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) DeleteBlobStorageFn func(context.Context, *fastly.DeleteBlobStorageInput) error CreateDatadogFn func(context.Context, *fastly.CreateDatadogInput) (*fastly.Datadog, error) ListDatadogFn func(context.Context, *fastly.ListDatadogInput) ([]*fastly.Datadog, error) GetDatadogFn func(context.Context, *fastly.GetDatadogInput) (*fastly.Datadog, error) UpdateDatadogFn func(context.Context, *fastly.UpdateDatadogInput) (*fastly.Datadog, error) DeleteDatadogFn func(context.Context, *fastly.DeleteDatadogInput) error CreateHTTPSFn func(context.Context, *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) ListHTTPSFn func(context.Context, *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) GetHTTPSFn func(context.Context, *fastly.GetHTTPSInput) (*fastly.HTTPS, error) UpdateHTTPSFn func(context.Context, *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) DeleteHTTPSFn func(context.Context, *fastly.DeleteHTTPSInput) error CreateKafkaFn func(context.Context, *fastly.CreateKafkaInput) (*fastly.Kafka, error) ListKafkasFn func(context.Context, *fastly.ListKafkasInput) ([]*fastly.Kafka, error) GetKafkaFn func(context.Context, *fastly.GetKafkaInput) (*fastly.Kafka, error) UpdateKafkaFn func(context.Context, *fastly.UpdateKafkaInput) (*fastly.Kafka, error) DeleteKafkaFn func(context.Context, *fastly.DeleteKafkaInput) error CreatePubsubFn func(context.Context, *fastly.CreatePubsubInput) (*fastly.Pubsub, error) ListPubsubsFn func(context.Context, *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) GetPubsubFn func(context.Context, *fastly.GetPubsubInput) (*fastly.Pubsub, error) UpdatePubsubFn func(context.Context, *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) DeletePubsubFn func(context.Context, *fastly.DeletePubsubInput) error CreateOpenstackFn func(context.Context, *fastly.CreateOpenstackInput) (*fastly.Openstack, error) ListOpenstacksFn func(context.Context, *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) GetOpenstackFn func(context.Context, *fastly.GetOpenstackInput) (*fastly.Openstack, error) UpdateOpenstackFn func(context.Context, *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) DeleteOpenstackFn func(context.Context, *fastly.DeleteOpenstackInput) error GetRegionsFn func(context.Context) (*fastly.RegionsResponse, error) GetStatsJSONFn func(context.Context, *fastly.GetStatsInput, any) error GetAggregateJSONFn func(context.Context, *fastly.GetAggregateInput, any) error GetUsageFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) GetUsageByServiceFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) GetDomainMetricsForServiceFn func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) GetDomainMetricsForServiceJSONFn func(context.Context, *fastly.GetDomainMetricsInput, any) error GetOriginMetricsForServiceFn func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) GetOriginMetricsForServiceJSONFn func(context.Context, *fastly.GetOriginMetricsInput, any) error CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetLoggingEndpointErrorsFn func(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) CreateVCLFn func(context.Context, *fastly.CreateVCLInput) (*fastly.VCL, error) ListVCLsFn func(context.Context, *fastly.ListVCLsInput) ([]*fastly.VCL, error) GetVCLFn func(context.Context, *fastly.GetVCLInput) (*fastly.VCL, error) UpdateVCLFn func(context.Context, *fastly.UpdateVCLInput) (*fastly.VCL, error) DeleteVCLFn func(context.Context, *fastly.DeleteVCLInput) error CreateSnippetFn func(context.Context, *fastly.CreateSnippetInput) (*fastly.Snippet, error) ListSnippetsFn func(context.Context, *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) GetSnippetFn func(context.Context, *fastly.GetSnippetInput) (*fastly.Snippet, error) GetDynamicSnippetFn func(context.Context, *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) UpdateSnippetFn func(context.Context, *fastly.UpdateSnippetInput) (*fastly.Snippet, error) UpdateDynamicSnippetFn func(context.Context, *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) DeleteSnippetFn func(context.Context, *fastly.DeleteSnippetInput) error PurgeFn func(context.Context, *fastly.PurgeInput) (*fastly.Purge, error) PurgeKeyFn func(context.Context, *fastly.PurgeKeyInput) (*fastly.Purge, error) PurgeKeysFn func(context.Context, *fastly.PurgeKeysInput) (map[string]string, error) PurgeAllFn func(context.Context, *fastly.PurgeAllInput) (*fastly.Purge, error) CreateACLFn func(context.Context, *fastly.CreateACLInput) (*fastly.ACL, error) DeleteACLFn func(context.Context, *fastly.DeleteACLInput) error GetACLFn func(context.Context, *fastly.GetACLInput) (*fastly.ACL, error) ListACLsFn func(context.Context, *fastly.ListACLsInput) ([]*fastly.ACL, error) UpdateACLFn func(context.Context, *fastly.UpdateACLInput) (*fastly.ACL, error) CreateACLEntryFn func(context.Context, *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) DeleteACLEntryFn func(context.Context, *fastly.DeleteACLEntryInput) error GetACLEntryFn func(context.Context, *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) GetACLEntriesFn func(context.Context, *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] ListACLEntriesFn func(context.Context, *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) UpdateACLEntryFn func(context.Context, *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) BatchModifyACLEntriesFn func(context.Context, *fastly.BatchModifyACLEntriesInput) error CreateNewRelicFn func(context.Context, *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) DeleteNewRelicFn func(context.Context, *fastly.DeleteNewRelicInput) error GetNewRelicFn func(context.Context, *fastly.GetNewRelicInput) (*fastly.NewRelic, error) ListNewRelicFn func(context.Context, *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) UpdateNewRelicFn func(context.Context, *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) CreateNewRelicOTLPFn func(context.Context, *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) DeleteNewRelicOTLPFn func(context.Context, *fastly.DeleteNewRelicOTLPInput) error GetNewRelicOTLPFn func(context.Context, *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) ListNewRelicOTLPFn func(context.Context, *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) UpdateNewRelicOTLPFn func(context.Context, *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) CreateUserFn func(context.Context, *fastly.CreateUserInput) (*fastly.User, error) DeleteUserFn func(context.Context, *fastly.DeleteUserInput) error GetCurrentUserFn func(context.Context) (*fastly.User, error) GetUserFn func(context.Context, *fastly.GetUserInput) (*fastly.User, error) ListCustomerUsersFn func(context.Context, *fastly.ListCustomerUsersInput) ([]*fastly.User, error) UpdateUserFn func(context.Context, *fastly.UpdateUserInput) (*fastly.User, error) ResetUserPasswordFn func(context.Context, *fastly.ResetUserPasswordInput) error BatchDeleteTokensFn func(context.Context, *fastly.BatchDeleteTokensInput) error CreateTokenFn func(context.Context, *fastly.CreateTokenInput) (*fastly.Token, error) DeleteTokenFn func(context.Context, *fastly.DeleteTokenInput) error DeleteTokenSelfFn func(context.Context) error GetTokenSelfFn func(context.Context) (*fastly.Token, error) ListCustomerTokensFn func(context.Context, *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) ListTokensFn func(context.Context, *fastly.ListTokensInput) ([]*fastly.Token, error) NewListKVStoreKeysPaginatorFn func(context.Context, *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries GetCustomTLSConfigurationFn func(context.Context, *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) ListCustomTLSConfigurationsFn func(context.Context, *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) UpdateCustomTLSConfigurationFn func(context.Context, *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) GetTLSActivationFn func(context.Context, *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) ListTLSActivationsFn func(context.Context, *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) UpdateTLSActivationFn func(context.Context, *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) CreateTLSActivationFn func(context.Context, *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) DeleteTLSActivationFn func(context.Context, *fastly.DeleteTLSActivationInput) error CreateCustomTLSCertificateFn func(context.Context, *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) DeleteCustomTLSCertificateFn func(context.Context, *fastly.DeleteCustomTLSCertificateInput) error GetCustomTLSCertificateFn func(context.Context, *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) ListCustomTLSCertificatesFn func(context.Context, *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) UpdateCustomTLSCertificateFn func(context.Context, *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) ListTLSDomainsFn func(context.Context, *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) CreatePrivateKeyFn func(context.Context, *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) DeletePrivateKeyFn func(context.Context, *fastly.DeletePrivateKeyInput) error GetPrivateKeyFn func(context.Context, *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) ListPrivateKeysFn func(context.Context, *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) CreateBulkCertificateFn func(context.Context, *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) DeleteBulkCertificateFn func(context.Context, *fastly.DeleteBulkCertificateInput) error GetBulkCertificateFn func(context.Context, *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) ListBulkCertificatesFn func(context.Context, *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) UpdateBulkCertificateFn func(context.Context, *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) CreateTLSSubscriptionFn func(context.Context, *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) DeleteTLSSubscriptionFn func(context.Context, *fastly.DeleteTLSSubscriptionInput) error GetTLSSubscriptionFn func(context.Context, *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) ListTLSSubscriptionsFn func(context.Context, *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) UpdateTLSSubscriptionFn func(context.Context, *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) ListServiceAuthorizationsFn func(context.Context, *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) GetServiceAuthorizationFn func(context.Context, *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) CreateServiceAuthorizationFn func(context.Context, *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) UpdateServiceAuthorizationFn func(context.Context, *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) DeleteServiceAuthorizationFn func(context.Context, *fastly.DeleteServiceAuthorizationInput) error CreateConfigStoreFn func(context.Context, *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) DeleteConfigStoreFn func(context.Context, *fastly.DeleteConfigStoreInput) error GetConfigStoreFn func(context.Context, *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) GetConfigStoreMetadataFn func(context.Context, *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) ListConfigStoresFn func(context.Context, *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) ListConfigStoreServicesFn func(context.Context, *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) UpdateConfigStoreFn func(context.Context, *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) CreateConfigStoreItemFn func(context.Context, *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) DeleteConfigStoreItemFn func(context.Context, *fastly.DeleteConfigStoreItemInput) error GetConfigStoreItemFn func(context.Context, *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) ListConfigStoreItemsFn func(context.Context, *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) UpdateConfigStoreItemFn func(context.Context, *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) CreateKVStoreFn func(context.Context, *fastly.CreateKVStoreInput) (*fastly.KVStore, error) GetKVStoreFn func(context.Context, *fastly.GetKVStoreInput) (*fastly.KVStore, error) ListKVStoresFn func(context.Context, *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) DeleteKVStoreFn func(context.Context, *fastly.DeleteKVStoreInput) error ListKVStoreKeysFn func(context.Context, *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) GetKVStoreKeyFn func(context.Context, *fastly.GetKVStoreKeyInput) (string, error) GetKVStoreItemFn func(context.Context, *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) InsertKVStoreKeyFn func(context.Context, *fastly.InsertKVStoreKeyInput) error DeleteKVStoreKeyFn func(context.Context, *fastly.DeleteKVStoreKeyInput) error BatchModifyKVStoreKeyFn func(context.Context, *fastly.BatchModifyKVStoreKeyInput) error CreateSecretStoreFn func(context.Context, *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) GetSecretStoreFn func(context.Context, *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) DeleteSecretStoreFn func(context.Context, *fastly.DeleteSecretStoreInput) error ListSecretStoresFn func(context.Context, *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) CreateSecretFn func(context.Context, *fastly.CreateSecretInput) (*fastly.Secret, error) GetSecretFn func(context.Context, *fastly.GetSecretInput) (*fastly.Secret, error) DeleteSecretFn func(context.Context, *fastly.DeleteSecretInput) error ListSecretsFn func(context.Context, *fastly.ListSecretsInput) (*fastly.Secrets, error) CreateClientKeyFn func(context.Context) (*fastly.ClientKey, error) GetSigningKeyFn func(context.Context) (ed25519.PublicKey, error) CreateResourceFn func(context.Context, *fastly.CreateResourceInput) (*fastly.Resource, error) DeleteResourceFn func(context.Context, *fastly.DeleteResourceInput) error GetResourceFn func(context.Context, *fastly.GetResourceInput) (*fastly.Resource, error) ListResourcesFn func(context.Context, *fastly.ListResourcesInput) ([]*fastly.Resource, error) UpdateResourceFn func(context.Context, *fastly.UpdateResourceInput) (*fastly.Resource, error) CreateERLFn func(context.Context, *fastly.CreateERLInput) (*fastly.ERL, error) DeleteERLFn func(context.Context, *fastly.DeleteERLInput) error GetERLFn func(context.Context, *fastly.GetERLInput) (*fastly.ERL, error) ListERLsFn func(context.Context, *fastly.ListERLsInput) ([]*fastly.ERL, error) UpdateERLFn func(context.Context, *fastly.UpdateERLInput) (*fastly.ERL, error) CreateConditionFn func(context.Context, *fastly.CreateConditionInput) (*fastly.Condition, error) DeleteConditionFn func(context.Context, *fastly.DeleteConditionInput) error GetConditionFn func(context.Context, *fastly.GetConditionInput) (*fastly.Condition, error) ListConditionsFn func(context.Context, *fastly.ListConditionsInput) ([]*fastly.Condition, error) UpdateConditionFn func(context.Context, *fastly.UpdateConditionInput) (*fastly.Condition, error) ListAlertDefinitionsFn func(context.Context, *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) CreateAlertDefinitionFn func(context.Context, *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) GetAlertDefinitionFn func(context.Context, *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) UpdateAlertDefinitionFn func(context.Context, *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) DeleteAlertDefinitionFn func(context.Context, *fastly.DeleteAlertDefinitionInput) error TestAlertDefinitionFn func(context.Context, *fastly.TestAlertDefinitionInput) error ListAlertHistoryFn func(context.Context, *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) ListObservabilityCustomDashboardsFn func(context.Context, *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) CreateObservabilityCustomDashboardFn func(context.Context, *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) GetObservabilityCustomDashboardFn func(context.Context, *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) UpdateObservabilityCustomDashboardFn func(context.Context, *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) DeleteObservabilityCustomDashboardFn func(context.Context, *fastly.DeleteObservabilityCustomDashboardInput) error GetImageOptimizerDefaultSettingsFn func(context.Context, *fastly.GetImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) UpdateImageOptimizerDefaultSettingsFn func(context.Context, *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) } // AllDatacenters implements Interface. func (m API) AllDatacenters(ctx context.Context) ([]fastly.Datacenter, error) { return m.AllDatacentersFn(ctx) } // AllIPs implements Interface. func (m API) AllIPs(ctx context.Context) (fastly.IPAddrs, fastly.IPAddrs, error) { return m.AllIPsFn(ctx) } // CreateService implements Interface. func (m API) CreateService(ctx context.Context, i *fastly.CreateServiceInput) (*fastly.Service, error) { return m.CreateServiceFn(ctx, i) } // GetServices implements Interface. func (m API) GetServices(ctx context.Context, i *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { return m.GetServicesFn(ctx, i) } // ListServices implements Interface. func (m API) ListServices(ctx context.Context, i *fastly.ListServicesInput) ([]*fastly.Service, error) { return m.ListServicesFn(ctx, i) } // GetService implements Interface. func (m API) GetService(ctx context.Context, i *fastly.GetServiceInput) (*fastly.Service, error) { return m.GetServiceFn(ctx, i) } // GetServiceDetails implements Interface. func (m API) GetServiceDetails(ctx context.Context, i *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { return m.GetServiceDetailsFn(ctx, i) } // SearchService implements Interface. func (m API) SearchService(ctx context.Context, i *fastly.SearchServiceInput) (*fastly.Service, error) { return m.SearchServiceFn(ctx, i) } // UpdateService implements Interface. func (m API) UpdateService(ctx context.Context, i *fastly.UpdateServiceInput) (*fastly.Service, error) { return m.UpdateServiceFn(ctx, i) } // DeleteService implements Interface. func (m API) DeleteService(ctx context.Context, i *fastly.DeleteServiceInput) error { return m.DeleteServiceFn(ctx, i) } // CloneVersion implements Interface. func (m API) CloneVersion(ctx context.Context, i *fastly.CloneVersionInput) (*fastly.Version, error) { return m.CloneVersionFn(ctx, i) } // ListVersions implements Interface. func (m API) ListVersions(ctx context.Context, i *fastly.ListVersionsInput) ([]*fastly.Version, error) { return m.ListVersionsFn(ctx, i) } // GetVersion implements Interface. func (m API) GetVersion(ctx context.Context, i *fastly.GetVersionInput) (*fastly.Version, error) { return m.GetVersionFn(ctx, i) } // UpdateVersion implements Interface. func (m API) UpdateVersion(ctx context.Context, i *fastly.UpdateVersionInput) (*fastly.Version, error) { return m.UpdateVersionFn(ctx, i) } // ActivateVersion implements Interface. func (m API) ActivateVersion(ctx context.Context, i *fastly.ActivateVersionInput) (*fastly.Version, error) { return m.ActivateVersionFn(ctx, i) } // DeactivateVersion implements Interface. func (m API) DeactivateVersion(ctx context.Context, i *fastly.DeactivateVersionInput) (*fastly.Version, error) { return m.DeactivateVersionFn(ctx, i) } // LockVersion implements Interface. func (m API) LockVersion(ctx context.Context, i *fastly.LockVersionInput) (*fastly.Version, error) { return m.LockVersionFn(ctx, i) } // LatestVersion implements Interface. func (m API) LatestVersion(ctx context.Context, i *fastly.LatestVersionInput) (*fastly.Version, error) { return m.LatestVersionFn(ctx, i) } // ValidateVersion implements Interface. func (m API) ValidateVersion(ctx context.Context, i *fastly.ValidateVersionInput) (bool, string, error) { return m.ValidateVersionFn(ctx, i) } // CreateDomain implements Interface. func (m API) CreateDomain(ctx context.Context, i *fastly.CreateDomainInput) (*fastly.Domain, error) { return m.CreateDomainFn(ctx, i) } // ListDomains implements Interface. func (m API) ListDomains(ctx context.Context, i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { return m.ListDomainsFn(ctx, i) } // GetDomain implements Interface. func (m API) GetDomain(ctx context.Context, i *fastly.GetDomainInput) (*fastly.Domain, error) { return m.GetDomainFn(ctx, i) } // UpdateDomain implements Interface. func (m API) UpdateDomain(ctx context.Context, i *fastly.UpdateDomainInput) (*fastly.Domain, error) { return m.UpdateDomainFn(ctx, i) } // DeleteDomain implements Interface. func (m API) DeleteDomain(ctx context.Context, i *fastly.DeleteDomainInput) error { return m.DeleteDomainFn(ctx, i) } // ValidateDomain implements Interface. func (m API) ValidateDomain(ctx context.Context, i *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) { return m.ValidateDomainFn(ctx, i) } // ValidateAllDomains implements Interface. func (m API) ValidateAllDomains(ctx context.Context, i *fastly.ValidateAllDomainsInput) (results []*fastly.DomainValidationResult, err error) { return m.ValidateAllDomainsFn(ctx, i) } // CreateBackend implements Interface. func (m API) CreateBackend(ctx context.Context, i *fastly.CreateBackendInput) (*fastly.Backend, error) { return m.CreateBackendFn(ctx, i) } // ListBackends implements Interface. func (m API) ListBackends(ctx context.Context, i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { return m.ListBackendsFn(ctx, i) } // GetBackend implements Interface. func (m API) GetBackend(ctx context.Context, i *fastly.GetBackendInput) (*fastly.Backend, error) { return m.GetBackendFn(ctx, i) } // UpdateBackend implements Interface. func (m API) UpdateBackend(ctx context.Context, i *fastly.UpdateBackendInput) (*fastly.Backend, error) { return m.UpdateBackendFn(ctx, i) } // DeleteBackend implements Interface. func (m API) DeleteBackend(ctx context.Context, i *fastly.DeleteBackendInput) error { return m.DeleteBackendFn(ctx, i) } // CreateHealthCheck implements Interface. func (m API) CreateHealthCheck(ctx context.Context, i *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { return m.CreateHealthCheckFn(ctx, i) } // ListHealthChecks implements Interface. func (m API) ListHealthChecks(ctx context.Context, i *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) { return m.ListHealthChecksFn(ctx, i) } // GetHealthCheck implements Interface. func (m API) GetHealthCheck(ctx context.Context, i *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) { return m.GetHealthCheckFn(ctx, i) } // UpdateHealthCheck implements Interface. func (m API) UpdateHealthCheck(ctx context.Context, i *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { return m.UpdateHealthCheckFn(ctx, i) } // DeleteHealthCheck implements Interface. func (m API) DeleteHealthCheck(ctx context.Context, i *fastly.DeleteHealthCheckInput) error { return m.DeleteHealthCheckFn(ctx, i) } // GetPackage implements Interface. func (m API) GetPackage(ctx context.Context, i *fastly.GetPackageInput) (*fastly.Package, error) { return m.GetPackageFn(ctx, i) } // UpdatePackage implements Interface. func (m API) UpdatePackage(ctx context.Context, i *fastly.UpdatePackageInput) (*fastly.Package, error) { return m.UpdatePackageFn(ctx, i) } // CreateDictionary implements Interface. func (m API) CreateDictionary(ctx context.Context, i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { return m.CreateDictionaryFn(ctx, i) } // GetDictionary implements Interface. func (m API) GetDictionary(ctx context.Context, i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { return m.GetDictionaryFn(ctx, i) } // DeleteDictionary implements Interface. func (m API) DeleteDictionary(ctx context.Context, i *fastly.DeleteDictionaryInput) error { return m.DeleteDictionaryFn(ctx, i) } // ListDictionaries implements Interface. func (m API) ListDictionaries(ctx context.Context, i *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) { return m.ListDictionariesFn(ctx, i) } // UpdateDictionary implements Interface. func (m API) UpdateDictionary(ctx context.Context, i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { return m.UpdateDictionaryFn(ctx, i) } // GetDictionaryItems implements Interface. func (m API) GetDictionaryItems(ctx context.Context, i *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { return m.GetDictionaryItemsFn(ctx, i) } // ListDictionaryItems implements Interface. func (m API) ListDictionaryItems(ctx context.Context, i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { return m.ListDictionaryItemsFn(ctx, i) } // GetDictionaryItem implements Interface. func (m API) GetDictionaryItem(ctx context.Context, i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { return m.GetDictionaryItemFn(ctx, i) } // CreateDictionaryItem implements Interface. func (m API) CreateDictionaryItem(ctx context.Context, i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { return m.CreateDictionaryItemFn(ctx, i) } // UpdateDictionaryItem implements Interface. func (m API) UpdateDictionaryItem(ctx context.Context, i *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) { return m.UpdateDictionaryItemFn(ctx, i) } // DeleteDictionaryItem implements Interface. func (m API) DeleteDictionaryItem(ctx context.Context, i *fastly.DeleteDictionaryItemInput) error { return m.DeleteDictionaryItemFn(ctx, i) } // BatchModifyDictionaryItems implements Interface. func (m API) BatchModifyDictionaryItems(ctx context.Context, i *fastly.BatchModifyDictionaryItemsInput) error { return m.BatchModifyDictionaryItemsFn(ctx, i) } // GetDictionaryInfo implements Interface. func (m API) GetDictionaryInfo(ctx context.Context, i *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) { return m.GetDictionaryInfoFn(ctx, i) } // CreateBigQuery implements Interface. func (m API) CreateBigQuery(ctx context.Context, i *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { return m.CreateBigQueryFn(ctx, i) } // ListBigQueries implements Interface. func (m API) ListBigQueries(ctx context.Context, i *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) { return m.ListBigQueriesFn(ctx, i) } // GetBigQuery implements Interface. func (m API) GetBigQuery(ctx context.Context, i *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { return m.GetBigQueryFn(ctx, i) } // UpdateBigQuery implements Interface. func (m API) UpdateBigQuery(ctx context.Context, i *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { return m.UpdateBigQueryFn(ctx, i) } // DeleteBigQuery implements Interface. func (m API) DeleteBigQuery(ctx context.Context, i *fastly.DeleteBigQueryInput) error { return m.DeleteBigQueryFn(ctx, i) } // CreateS3 implements Interface. func (m API) CreateS3(ctx context.Context, i *fastly.CreateS3Input) (*fastly.S3, error) { return m.CreateS3Fn(ctx, i) } // ListS3s implements Interface. func (m API) ListS3s(ctx context.Context, i *fastly.ListS3sInput) ([]*fastly.S3, error) { return m.ListS3sFn(ctx, i) } // GetS3 implements Interface. func (m API) GetS3(ctx context.Context, i *fastly.GetS3Input) (*fastly.S3, error) { return m.GetS3Fn(ctx, i) } // UpdateS3 implements Interface. func (m API) UpdateS3(ctx context.Context, i *fastly.UpdateS3Input) (*fastly.S3, error) { return m.UpdateS3Fn(ctx, i) } // DeleteS3 implements Interface. func (m API) DeleteS3(ctx context.Context, i *fastly.DeleteS3Input) error { return m.DeleteS3Fn(ctx, i) } // CreateKinesis implements Interface. func (m API) CreateKinesis(ctx context.Context, i *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { return m.CreateKinesisFn(ctx, i) } // ListKinesis implements Interface. func (m API) ListKinesis(ctx context.Context, i *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) { return m.ListKinesisFn(ctx, i) } // GetKinesis implements Interface. func (m API) GetKinesis(ctx context.Context, i *fastly.GetKinesisInput) (*fastly.Kinesis, error) { return m.GetKinesisFn(ctx, i) } // UpdateKinesis implements Interface. func (m API) UpdateKinesis(ctx context.Context, i *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { return m.UpdateKinesisFn(ctx, i) } // DeleteKinesis implements Interface. func (m API) DeleteKinesis(ctx context.Context, i *fastly.DeleteKinesisInput) error { return m.DeleteKinesisFn(ctx, i) } // CreateSyslog implements Interface. func (m API) CreateSyslog(ctx context.Context, i *fastly.CreateSyslogInput) (*fastly.Syslog, error) { return m.CreateSyslogFn(ctx, i) } // ListSyslogs implements Interface. func (m API) ListSyslogs(ctx context.Context, i *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) { return m.ListSyslogsFn(ctx, i) } // GetSyslog implements Interface. func (m API) GetSyslog(ctx context.Context, i *fastly.GetSyslogInput) (*fastly.Syslog, error) { return m.GetSyslogFn(ctx, i) } // UpdateSyslog implements Interface. func (m API) UpdateSyslog(ctx context.Context, i *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { return m.UpdateSyslogFn(ctx, i) } // DeleteSyslog implements Interface. func (m API) DeleteSyslog(ctx context.Context, i *fastly.DeleteSyslogInput) error { return m.DeleteSyslogFn(ctx, i) } // CreateLogentries implements Interface. func (m API) CreateLogentries(ctx context.Context, i *fastly.CreateLogentriesInput) (*fastly.Logentries, error) { return m.CreateLogentriesFn(ctx, i) } // ListLogentries implements Interface. func (m API) ListLogentries(ctx context.Context, i *fastly.ListLogentriesInput) ([]*fastly.Logentries, error) { return m.ListLogentriesFn(ctx, i) } // GetLogentries implements Interface. func (m API) GetLogentries(ctx context.Context, i *fastly.GetLogentriesInput) (*fastly.Logentries, error) { return m.GetLogentriesFn(ctx, i) } // UpdateLogentries implements Interface. func (m API) UpdateLogentries(ctx context.Context, i *fastly.UpdateLogentriesInput) (*fastly.Logentries, error) { return m.UpdateLogentriesFn(ctx, i) } // DeleteLogentries implements Interface. func (m API) DeleteLogentries(ctx context.Context, i *fastly.DeleteLogentriesInput) error { return m.DeleteLogentriesFn(ctx, i) } // CreatePapertrail implements Interface. func (m API) CreatePapertrail(ctx context.Context, i *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { return m.CreatePapertrailFn(ctx, i) } // ListPapertrails implements Interface. func (m API) ListPapertrails(ctx context.Context, i *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) { return m.ListPapertrailsFn(ctx, i) } // GetPapertrail implements Interface. func (m API) GetPapertrail(ctx context.Context, i *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { return m.GetPapertrailFn(ctx, i) } // UpdatePapertrail implements Interface. func (m API) UpdatePapertrail(ctx context.Context, i *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { return m.UpdatePapertrailFn(ctx, i) } // DeletePapertrail implements Interface. func (m API) DeletePapertrail(ctx context.Context, i *fastly.DeletePapertrailInput) error { return m.DeletePapertrailFn(ctx, i) } // CreateSumologic implements Interface. func (m API) CreateSumologic(ctx context.Context, i *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { return m.CreateSumologicFn(ctx, i) } // ListSumologics implements Interface. func (m API) ListSumologics(ctx context.Context, i *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) { return m.ListSumologicsFn(ctx, i) } // GetSumologic implements Interface. func (m API) GetSumologic(ctx context.Context, i *fastly.GetSumologicInput) (*fastly.Sumologic, error) { return m.GetSumologicFn(ctx, i) } // UpdateSumologic implements Interface. func (m API) UpdateSumologic(ctx context.Context, i *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { return m.UpdateSumologicFn(ctx, i) } // DeleteSumologic implements Interface. func (m API) DeleteSumologic(ctx context.Context, i *fastly.DeleteSumologicInput) error { return m.DeleteSumologicFn(ctx, i) } // CreateGCS implements Interface. func (m API) CreateGCS(ctx context.Context, i *fastly.CreateGCSInput) (*fastly.GCS, error) { return m.CreateGCSFn(ctx, i) } // ListGCSs implements Interface. func (m API) ListGCSs(ctx context.Context, i *fastly.ListGCSsInput) ([]*fastly.GCS, error) { return m.ListGCSsFn(ctx, i) } // GetGCS implements Interface. func (m API) GetGCS(ctx context.Context, i *fastly.GetGCSInput) (*fastly.GCS, error) { return m.GetGCSFn(ctx, i) } // UpdateGCS implements Interface. func (m API) UpdateGCS(ctx context.Context, i *fastly.UpdateGCSInput) (*fastly.GCS, error) { return m.UpdateGCSFn(ctx, i) } // DeleteGCS implements Interface. func (m API) DeleteGCS(ctx context.Context, i *fastly.DeleteGCSInput) error { return m.DeleteGCSFn(ctx, i) } // CreateGrafanaCloudLogs implements Interface. func (m API) CreateGrafanaCloudLogs(ctx context.Context, i *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { return m.CreateGrafanaCloudLogsFn(ctx, i) } // ListGrafanaCloudLogs implements Interface. func (m API) ListGrafanaCloudLogs(ctx context.Context, i *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) { return m.ListGrafanaCloudLogsFn(ctx, i) } // GetGrafanaCloudLogs implements Interface. func (m API) GetGrafanaCloudLogs(ctx context.Context, i *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { return m.GetGrafanaCloudLogsFn(ctx, i) } // UpdateGrafanaCloudLogs implements Interface. func (m API) UpdateGrafanaCloudLogs(ctx context.Context, i *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { return m.UpdateGrafanaCloudLogsFn(ctx, i) } // DeleteGrafanaCloudLogs implements Interface. func (m API) DeleteGrafanaCloudLogs(ctx context.Context, i *fastly.DeleteGrafanaCloudLogsInput) error { return m.DeleteGrafanaCloudLogsFn(ctx, i) } // CreateFTP implements Interface. func (m API) CreateFTP(ctx context.Context, i *fastly.CreateFTPInput) (*fastly.FTP, error) { return m.CreateFTPFn(ctx, i) } // ListFTPs implements Interface. func (m API) ListFTPs(ctx context.Context, i *fastly.ListFTPsInput) ([]*fastly.FTP, error) { return m.ListFTPsFn(ctx, i) } // GetFTP implements Interface. func (m API) GetFTP(ctx context.Context, i *fastly.GetFTPInput) (*fastly.FTP, error) { return m.GetFTPFn(ctx, i) } // UpdateFTP implements Interface. func (m API) UpdateFTP(ctx context.Context, i *fastly.UpdateFTPInput) (*fastly.FTP, error) { return m.UpdateFTPFn(ctx, i) } // DeleteFTP implements Interface. func (m API) DeleteFTP(ctx context.Context, i *fastly.DeleteFTPInput) error { return m.DeleteFTPFn(ctx, i) } // CreateSplunk implements Interface. func (m API) CreateSplunk(ctx context.Context, i *fastly.CreateSplunkInput) (*fastly.Splunk, error) { return m.CreateSplunkFn(ctx, i) } // ListSplunks implements Interface. func (m API) ListSplunks(ctx context.Context, i *fastly.ListSplunksInput) ([]*fastly.Splunk, error) { return m.ListSplunksFn(ctx, i) } // GetSplunk implements Interface. func (m API) GetSplunk(ctx context.Context, i *fastly.GetSplunkInput) (*fastly.Splunk, error) { return m.GetSplunkFn(ctx, i) } // UpdateSplunk implements Interface. func (m API) UpdateSplunk(ctx context.Context, i *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { return m.UpdateSplunkFn(ctx, i) } // DeleteSplunk implements Interface. func (m API) DeleteSplunk(ctx context.Context, i *fastly.DeleteSplunkInput) error { return m.DeleteSplunkFn(ctx, i) } // CreateScalyr implements Interface. func (m API) CreateScalyr(ctx context.Context, i *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { return m.CreateScalyrFn(ctx, i) } // ListScalyrs implements Interface. func (m API) ListScalyrs(ctx context.Context, i *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) { return m.ListScalyrsFn(ctx, i) } // GetScalyr implements Interface. func (m API) GetScalyr(ctx context.Context, i *fastly.GetScalyrInput) (*fastly.Scalyr, error) { return m.GetScalyrFn(ctx, i) } // UpdateScalyr implements Interface. func (m API) UpdateScalyr(ctx context.Context, i *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { return m.UpdateScalyrFn(ctx, i) } // DeleteScalyr implements Interface. func (m API) DeleteScalyr(ctx context.Context, i *fastly.DeleteScalyrInput) error { return m.DeleteScalyrFn(ctx, i) } // CreateLoggly implements Interface. func (m API) CreateLoggly(ctx context.Context, i *fastly.CreateLogglyInput) (*fastly.Loggly, error) { return m.CreateLogglyFn(ctx, i) } // ListLoggly implements Interface. func (m API) ListLoggly(ctx context.Context, i *fastly.ListLogglyInput) ([]*fastly.Loggly, error) { return m.ListLogglyFn(ctx, i) } // GetLoggly implements Interface. func (m API) GetLoggly(ctx context.Context, i *fastly.GetLogglyInput) (*fastly.Loggly, error) { return m.GetLogglyFn(ctx, i) } // UpdateLoggly implements Interface. func (m API) UpdateLoggly(ctx context.Context, i *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { return m.UpdateLogglyFn(ctx, i) } // DeleteLoggly implements Interface. func (m API) DeleteLoggly(ctx context.Context, i *fastly.DeleteLogglyInput) error { return m.DeleteLogglyFn(ctx, i) } // CreateHoneycomb implements Interface. func (m API) CreateHoneycomb(ctx context.Context, i *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { return m.CreateHoneycombFn(ctx, i) } // ListHoneycombs implements Interface. func (m API) ListHoneycombs(ctx context.Context, i *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) { return m.ListHoneycombsFn(ctx, i) } // GetHoneycomb implements Interface. func (m API) GetHoneycomb(ctx context.Context, i *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { return m.GetHoneycombFn(ctx, i) } // UpdateHoneycomb implements Interface. func (m API) UpdateHoneycomb(ctx context.Context, i *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { return m.UpdateHoneycombFn(ctx, i) } // DeleteHoneycomb implements Interface. func (m API) DeleteHoneycomb(ctx context.Context, i *fastly.DeleteHoneycombInput) error { return m.DeleteHoneycombFn(ctx, i) } // CreateHeroku implements Interface. func (m API) CreateHeroku(ctx context.Context, i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { return m.CreateHerokuFn(ctx, i) } // ListHerokus implements Interface. func (m API) ListHerokus(ctx context.Context, i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { return m.ListHerokusFn(ctx, i) } // GetHeroku implements Interface. func (m API) GetHeroku(ctx context.Context, i *fastly.GetHerokuInput) (*fastly.Heroku, error) { return m.GetHerokuFn(ctx, i) } // UpdateHeroku implements Interface. func (m API) UpdateHeroku(ctx context.Context, i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { return m.UpdateHerokuFn(ctx, i) } // DeleteHeroku implements Interface. func (m API) DeleteHeroku(ctx context.Context, i *fastly.DeleteHerokuInput) error { return m.DeleteHerokuFn(ctx, i) } // CreateSFTP implements Interface. func (m API) CreateSFTP(ctx context.Context, i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { return m.CreateSFTPFn(ctx, i) } // ListSFTPs implements Interface. func (m API) ListSFTPs(ctx context.Context, i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { return m.ListSFTPsFn(ctx, i) } // GetSFTP implements Interface. func (m API) GetSFTP(ctx context.Context, i *fastly.GetSFTPInput) (*fastly.SFTP, error) { return m.GetSFTPFn(ctx, i) } // UpdateSFTP implements Interface. func (m API) UpdateSFTP(ctx context.Context, i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { return m.UpdateSFTPFn(ctx, i) } // DeleteSFTP implements Interface. func (m API) DeleteSFTP(ctx context.Context, i *fastly.DeleteSFTPInput) error { return m.DeleteSFTPFn(ctx, i) } // CreateLogshuttle implements Interface. func (m API) CreateLogshuttle(ctx context.Context, i *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { return m.CreateLogshuttleFn(ctx, i) } // ListLogshuttles implements Interface. func (m API) ListLogshuttles(ctx context.Context, i *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) { return m.ListLogshuttlesFn(ctx, i) } // GetLogshuttle implements Interface. func (m API) GetLogshuttle(ctx context.Context, i *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { return m.GetLogshuttleFn(ctx, i) } // UpdateLogshuttle implements Interface. func (m API) UpdateLogshuttle(ctx context.Context, i *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { return m.UpdateLogshuttleFn(ctx, i) } // DeleteLogshuttle implements Interface. func (m API) DeleteLogshuttle(ctx context.Context, i *fastly.DeleteLogshuttleInput) error { return m.DeleteLogshuttleFn(ctx, i) } // CreateCloudfiles implements Interface. func (m API) CreateCloudfiles(ctx context.Context, i *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { return m.CreateCloudfilesFn(ctx, i) } // ListCloudfiles implements Interface. func (m API) ListCloudfiles(ctx context.Context, i *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) { return m.ListCloudfilesFn(ctx, i) } // GetCloudfiles implements Interface. func (m API) GetCloudfiles(ctx context.Context, i *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { return m.GetCloudfilesFn(ctx, i) } // UpdateCloudfiles implements Interface. func (m API) UpdateCloudfiles(ctx context.Context, i *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { return m.UpdateCloudfilesFn(ctx, i) } // DeleteCloudfiles implements Interface. func (m API) DeleteCloudfiles(ctx context.Context, i *fastly.DeleteCloudfilesInput) error { return m.DeleteCloudfilesFn(ctx, i) } // CreateDigitalOcean implements Interface. func (m API) CreateDigitalOcean(ctx context.Context, i *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { return m.CreateDigitalOceanFn(ctx, i) } // ListDigitalOceans implements Interface. func (m API) ListDigitalOceans(ctx context.Context, i *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) { return m.ListDigitalOceansFn(ctx, i) } // GetDigitalOcean implements Interface. func (m API) GetDigitalOcean(ctx context.Context, i *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { return m.GetDigitalOceanFn(ctx, i) } // UpdateDigitalOcean implements Interface. func (m API) UpdateDigitalOcean(ctx context.Context, i *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { return m.UpdateDigitalOceanFn(ctx, i) } // DeleteDigitalOcean implements Interface. func (m API) DeleteDigitalOcean(ctx context.Context, i *fastly.DeleteDigitalOceanInput) error { return m.DeleteDigitalOceanFn(ctx, i) } // CreateElasticsearch implements Interface. func (m API) CreateElasticsearch(ctx context.Context, i *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { return m.CreateElasticsearchFn(ctx, i) } // ListElasticsearch implements Interface. func (m API) ListElasticsearch(ctx context.Context, i *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) { return m.ListElasticsearchFn(ctx, i) } // GetElasticsearch implements Interface. func (m API) GetElasticsearch(ctx context.Context, i *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { return m.GetElasticsearchFn(ctx, i) } // UpdateElasticsearch implements Interface. func (m API) UpdateElasticsearch(ctx context.Context, i *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { return m.UpdateElasticsearchFn(ctx, i) } // DeleteElasticsearch implements Interface. func (m API) DeleteElasticsearch(ctx context.Context, i *fastly.DeleteElasticsearchInput) error { return m.DeleteElasticsearchFn(ctx, i) } // CreateBlobStorage implements Interface. func (m API) CreateBlobStorage(ctx context.Context, i *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { return m.CreateBlobStorageFn(ctx, i) } // ListBlobStorages implements Interface. func (m API) ListBlobStorages(ctx context.Context, i *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) { return m.ListBlobStoragesFn(ctx, i) } // GetBlobStorage implements Interface. func (m API) GetBlobStorage(ctx context.Context, i *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { return m.GetBlobStorageFn(ctx, i) } // UpdateBlobStorage implements Interface. func (m API) UpdateBlobStorage(ctx context.Context, i *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { return m.UpdateBlobStorageFn(ctx, i) } // DeleteBlobStorage implements Interface. func (m API) DeleteBlobStorage(ctx context.Context, i *fastly.DeleteBlobStorageInput) error { return m.DeleteBlobStorageFn(ctx, i) } // CreateDatadog implements Interface. func (m API) CreateDatadog(ctx context.Context, i *fastly.CreateDatadogInput) (*fastly.Datadog, error) { return m.CreateDatadogFn(ctx, i) } // ListDatadog implements Interface. func (m API) ListDatadog(ctx context.Context, i *fastly.ListDatadogInput) ([]*fastly.Datadog, error) { return m.ListDatadogFn(ctx, i) } // GetDatadog implements Interface. func (m API) GetDatadog(ctx context.Context, i *fastly.GetDatadogInput) (*fastly.Datadog, error) { return m.GetDatadogFn(ctx, i) } // UpdateDatadog implements Interface. func (m API) UpdateDatadog(ctx context.Context, i *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { return m.UpdateDatadogFn(ctx, i) } // DeleteDatadog implements Interface. func (m API) DeleteDatadog(ctx context.Context, i *fastly.DeleteDatadogInput) error { return m.DeleteDatadogFn(ctx, i) } // CreateHTTPS implements Interface. func (m API) CreateHTTPS(ctx context.Context, i *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { return m.CreateHTTPSFn(ctx, i) } // ListHTTPS implements Interface. func (m API) ListHTTPS(ctx context.Context, i *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) { return m.ListHTTPSFn(ctx, i) } // GetHTTPS implements Interface. func (m API) GetHTTPS(ctx context.Context, i *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { return m.GetHTTPSFn(ctx, i) } // UpdateHTTPS implements Interface. func (m API) UpdateHTTPS(ctx context.Context, i *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { return m.UpdateHTTPSFn(ctx, i) } // DeleteHTTPS implements Interface. func (m API) DeleteHTTPS(ctx context.Context, i *fastly.DeleteHTTPSInput) error { return m.DeleteHTTPSFn(ctx, i) } // CreateKafka implements Interface. func (m API) CreateKafka(ctx context.Context, i *fastly.CreateKafkaInput) (*fastly.Kafka, error) { return m.CreateKafkaFn(ctx, i) } // ListKafkas implements Interface. func (m API) ListKafkas(ctx context.Context, i *fastly.ListKafkasInput) ([]*fastly.Kafka, error) { return m.ListKafkasFn(ctx, i) } // GetKafka implements Interface. func (m API) GetKafka(ctx context.Context, i *fastly.GetKafkaInput) (*fastly.Kafka, error) { return m.GetKafkaFn(ctx, i) } // UpdateKafka implements Interface. func (m API) UpdateKafka(ctx context.Context, i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { return m.UpdateKafkaFn(ctx, i) } // DeleteKafka implements Interface. func (m API) DeleteKafka(ctx context.Context, i *fastly.DeleteKafkaInput) error { return m.DeleteKafkaFn(ctx, i) } // CreatePubsub implements Interface. func (m API) CreatePubsub(ctx context.Context, i *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { return m.CreatePubsubFn(ctx, i) } // ListPubsubs implements Interface. func (m API) ListPubsubs(ctx context.Context, i *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) { return m.ListPubsubsFn(ctx, i) } // GetPubsub implements Interface. func (m API) GetPubsub(ctx context.Context, i *fastly.GetPubsubInput) (*fastly.Pubsub, error) { return m.GetPubsubFn(ctx, i) } // UpdatePubsub implements Interface. func (m API) UpdatePubsub(ctx context.Context, i *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { return m.UpdatePubsubFn(ctx, i) } // DeletePubsub implements Interface. func (m API) DeletePubsub(ctx context.Context, i *fastly.DeletePubsubInput) error { return m.DeletePubsubFn(ctx, i) } // CreateOpenstack implements Interface. func (m API) CreateOpenstack(ctx context.Context, i *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { return m.CreateOpenstackFn(ctx, i) } // ListOpenstack implements Interface. func (m API) ListOpenstack(ctx context.Context, i *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) { return m.ListOpenstacksFn(ctx, i) } // GetOpenstack implements Interface. func (m API) GetOpenstack(ctx context.Context, i *fastly.GetOpenstackInput) (*fastly.Openstack, error) { return m.GetOpenstackFn(ctx, i) } // UpdateOpenstack implements Interface. func (m API) UpdateOpenstack(ctx context.Context, i *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { return m.UpdateOpenstackFn(ctx, i) } // DeleteOpenstack implements Interface. func (m API) DeleteOpenstack(ctx context.Context, i *fastly.DeleteOpenstackInput) error { return m.DeleteOpenstackFn(ctx, i) } // GetRegions implements Interface. func (m API) GetRegions(ctx context.Context) (*fastly.RegionsResponse, error) { return m.GetRegionsFn(ctx) } // GetStatsJSON implements Interface. func (m API) GetStatsJSON(ctx context.Context, i *fastly.GetStatsInput, dst any) error { return m.GetStatsJSONFn(ctx, i, dst) } // GetAggregateJSON implements Interface. func (m API) GetAggregateJSON(ctx context.Context, i *fastly.GetAggregateInput, dst any) error { return m.GetAggregateJSONFn(ctx, i, dst) } // GetUsage implements Interface. func (m API) GetUsage(ctx context.Context, i *fastly.GetUsageInput) (*fastly.UsageResponse, error) { return m.GetUsageFn(ctx, i) } // GetUsageByService implements Interface. func (m API) GetUsageByService(ctx context.Context, i *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) { return m.GetUsageByServiceFn(ctx, i) } // GetDomainMetricsForService implements Interface. func (m API) GetDomainMetricsForService(ctx context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { return m.GetDomainMetricsForServiceFn(ctx, i) } // GetDomainMetricsForServiceJSON implements Interface. func (m API) GetDomainMetricsForServiceJSON(ctx context.Context, i *fastly.GetDomainMetricsInput, dst any) error { return m.GetDomainMetricsForServiceJSONFn(ctx, i, dst) } // GetOriginMetricsForService implements Interface. func (m API) GetOriginMetricsForService(ctx context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { return m.GetOriginMetricsForServiceFn(ctx, i) } // GetOriginMetricsForServiceJSON implements Interface. func (m API) GetOriginMetricsForServiceJSON(ctx context.Context, i *fastly.GetOriginMetricsInput, dst any) error { return m.GetOriginMetricsForServiceJSONFn(ctx, i, dst) } // CreateManagedLogging implements Interface. func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(ctx, i) } // GetLoggingEndpointErrors implements Interface. func (m API) GetLoggingEndpointErrors(ctx context.Context, i *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) { return m.GetLoggingEndpointErrorsFn(ctx, i) } // GetGeneratedVCL implements Interface. func (m API) GetGeneratedVCL(ctx context.Context, i *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) { return m.GetGeneratedVCLFn(ctx, i) } // CreateVCL implements Interface. func (m API) CreateVCL(ctx context.Context, i *fastly.CreateVCLInput) (*fastly.VCL, error) { return m.CreateVCLFn(ctx, i) } // ListVCLs implements Interface. func (m API) ListVCLs(ctx context.Context, i *fastly.ListVCLsInput) ([]*fastly.VCL, error) { return m.ListVCLsFn(ctx, i) } // GetVCL implements Interface. func (m API) GetVCL(ctx context.Context, i *fastly.GetVCLInput) (*fastly.VCL, error) { return m.GetVCLFn(ctx, i) } // UpdateVCL implements Interface. func (m API) UpdateVCL(ctx context.Context, i *fastly.UpdateVCLInput) (*fastly.VCL, error) { return m.UpdateVCLFn(ctx, i) } // DeleteVCL implements Interface. func (m API) DeleteVCL(ctx context.Context, i *fastly.DeleteVCLInput) error { return m.DeleteVCLFn(ctx, i) } // CreateSnippet implements Interface. func (m API) CreateSnippet(ctx context.Context, i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { return m.CreateSnippetFn(ctx, i) } // ListSnippets implements Interface. func (m API) ListSnippets(ctx context.Context, i *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) { return m.ListSnippetsFn(ctx, i) } // GetSnippet implements Interface. func (m API) GetSnippet(ctx context.Context, i *fastly.GetSnippetInput) (*fastly.Snippet, error) { return m.GetSnippetFn(ctx, i) } // GetDynamicSnippet implements Interface. func (m API) GetDynamicSnippet(ctx context.Context, i *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) { return m.GetDynamicSnippetFn(ctx, i) } // UpdateSnippet implements Interface. func (m API) UpdateSnippet(ctx context.Context, i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { return m.UpdateSnippetFn(ctx, i) } // UpdateDynamicSnippet implements Interface. func (m API) UpdateDynamicSnippet(ctx context.Context, i *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) { return m.UpdateDynamicSnippetFn(ctx, i) } // DeleteSnippet implements Interface. func (m API) DeleteSnippet(ctx context.Context, i *fastly.DeleteSnippetInput) error { return m.DeleteSnippetFn(ctx, i) } // Purge implements Interface. func (m API) Purge(ctx context.Context, i *fastly.PurgeInput) (*fastly.Purge, error) { return m.PurgeFn(ctx, i) } // PurgeKey implements Interface. func (m API) PurgeKey(ctx context.Context, i *fastly.PurgeKeyInput) (*fastly.Purge, error) { return m.PurgeKeyFn(ctx, i) } // PurgeKeys implements Interface. func (m API) PurgeKeys(ctx context.Context, i *fastly.PurgeKeysInput) (map[string]string, error) { return m.PurgeKeysFn(ctx, i) } // PurgeAll implements Interface. func (m API) PurgeAll(ctx context.Context, i *fastly.PurgeAllInput) (*fastly.Purge, error) { return m.PurgeAllFn(ctx, i) } // CreateACL implements Interface. func (m API) CreateACL(ctx context.Context, i *fastly.CreateACLInput) (*fastly.ACL, error) { return m.CreateACLFn(ctx, i) } // DeleteACL implements Interface. func (m API) DeleteACL(ctx context.Context, i *fastly.DeleteACLInput) error { return m.DeleteACLFn(ctx, i) } // GetACL implements Interface. func (m API) GetACL(ctx context.Context, i *fastly.GetACLInput) (*fastly.ACL, error) { return m.GetACLFn(ctx, i) } // ListACLs implements Interface. func (m API) ListACLs(ctx context.Context, i *fastly.ListACLsInput) ([]*fastly.ACL, error) { return m.ListACLsFn(ctx, i) } // UpdateACL implements Interface. func (m API) UpdateACL(ctx context.Context, i *fastly.UpdateACLInput) (*fastly.ACL, error) { return m.UpdateACLFn(ctx, i) } // CreateACLEntry implements Interface. func (m API) CreateACLEntry(ctx context.Context, i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) { return m.CreateACLEntryFn(ctx, i) } // DeleteACLEntry implements Interface. func (m API) DeleteACLEntry(ctx context.Context, i *fastly.DeleteACLEntryInput) error { return m.DeleteACLEntryFn(ctx, i) } // GetACLEntry implements Interface. func (m API) GetACLEntry(ctx context.Context, i *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) { return m.GetACLEntryFn(ctx, i) } // GetACLEntries implements Interface. func (m API) GetACLEntries(ctx context.Context, i *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { return m.GetACLEntriesFn(ctx, i) } // ListACLEntries implements Interface. func (m API) ListACLEntries(ctx context.Context, i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) { return m.ListACLEntriesFn(ctx, i) } // UpdateACLEntry implements Interface. func (m API) UpdateACLEntry(ctx context.Context, i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) { return m.UpdateACLEntryFn(ctx, i) } // BatchModifyACLEntries implements Interface. func (m API) BatchModifyACLEntries(ctx context.Context, i *fastly.BatchModifyACLEntriesInput) error { return m.BatchModifyACLEntriesFn(ctx, i) } // CreateNewRelic implements Interface. func (m API) CreateNewRelic(ctx context.Context, i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { return m.CreateNewRelicFn(ctx, i) } // DeleteNewRelic implements Interface. func (m API) DeleteNewRelic(ctx context.Context, i *fastly.DeleteNewRelicInput) error { return m.DeleteNewRelicFn(ctx, i) } // GetNewRelic implements Interface. func (m API) GetNewRelic(ctx context.Context, i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { return m.GetNewRelicFn(ctx, i) } // ListNewRelic implements Interface. func (m API) ListNewRelic(ctx context.Context, i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { return m.ListNewRelicFn(ctx, i) } // UpdateNewRelic implements Interface. func (m API) UpdateNewRelic(ctx context.Context, i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { return m.UpdateNewRelicFn(ctx, i) } // CreateNewRelicOTLP implements Interface. func (m API) CreateNewRelicOTLP(ctx context.Context, i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { return m.CreateNewRelicOTLPFn(ctx, i) } // DeleteNewRelicOTLP implements Interface. func (m API) DeleteNewRelicOTLP(ctx context.Context, i *fastly.DeleteNewRelicOTLPInput) error { return m.DeleteNewRelicOTLPFn(ctx, i) } // GetNewRelicOTLP implements Interface. func (m API) GetNewRelicOTLP(ctx context.Context, i *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { return m.GetNewRelicOTLPFn(ctx, i) } // ListNewRelicOTLP implements Interface. func (m API) ListNewRelicOTLP(ctx context.Context, i *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) { return m.ListNewRelicOTLPFn(ctx, i) } // UpdateNewRelicOTLP implements Interface. func (m API) UpdateNewRelicOTLP(ctx context.Context, i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { return m.UpdateNewRelicOTLPFn(ctx, i) } // CreateUser implements Interface. func (m API) CreateUser(ctx context.Context, i *fastly.CreateUserInput) (*fastly.User, error) { return m.CreateUserFn(ctx, i) } // DeleteUser implements Interface. func (m API) DeleteUser(ctx context.Context, i *fastly.DeleteUserInput) error { return m.DeleteUserFn(ctx, i) } // GetCurrentUser implements Interface. func (m API) GetCurrentUser(ctx context.Context) (*fastly.User, error) { return m.GetCurrentUserFn(ctx) } // GetUser implements Interface. func (m API) GetUser(ctx context.Context, i *fastly.GetUserInput) (*fastly.User, error) { return m.GetUserFn(ctx, i) } // ListCustomerUsers implements Interface. func (m API) ListCustomerUsers(ctx context.Context, i *fastly.ListCustomerUsersInput) ([]*fastly.User, error) { return m.ListCustomerUsersFn(ctx, i) } // UpdateUser implements Interface. func (m API) UpdateUser(ctx context.Context, i *fastly.UpdateUserInput) (*fastly.User, error) { return m.UpdateUserFn(ctx, i) } // ResetUserPassword implements Interface. func (m API) ResetUserPassword(ctx context.Context, i *fastly.ResetUserPasswordInput) error { return m.ResetUserPasswordFn(ctx, i) } // BatchDeleteTokens implements Interface. func (m API) BatchDeleteTokens(ctx context.Context, i *fastly.BatchDeleteTokensInput) error { return m.BatchDeleteTokensFn(ctx, i) } // CreateToken implements Interface. func (m API) CreateToken(ctx context.Context, i *fastly.CreateTokenInput) (*fastly.Token, error) { return m.CreateTokenFn(ctx, i) } // DeleteToken implements Interface. func (m API) DeleteToken(ctx context.Context, i *fastly.DeleteTokenInput) error { return m.DeleteTokenFn(ctx, i) } // DeleteTokenSelf implements Interface. func (m API) DeleteTokenSelf(ctx context.Context) error { return m.DeleteTokenSelfFn(ctx) } // GetTokenSelf implements Interface. func (m API) GetTokenSelf(ctx context.Context) (*fastly.Token, error) { return m.GetTokenSelfFn(ctx) } // ListCustomerTokens implements Interface. func (m API) ListCustomerTokens(ctx context.Context, i *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) { return m.ListCustomerTokensFn(ctx, i) } // ListTokens implements Interface. func (m API) ListTokens(ctx context.Context, i *fastly.ListTokensInput) ([]*fastly.Token, error) { return m.ListTokensFn(ctx, i) } // NewListKVStoreKeysPaginator implements Interface. func (m API) NewListKVStoreKeysPaginator(ctx context.Context, i *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { return m.NewListKVStoreKeysPaginatorFn(ctx, i) } // GetKVStoreItem implements Interface. func (m API) GetKVStoreItem(ctx context.Context, i *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error) { return m.GetKVStoreItemFn(ctx, i) } // GetCustomTLSConfiguration implements Interface. func (m API) GetCustomTLSConfiguration(ctx context.Context, i *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { return m.GetCustomTLSConfigurationFn(ctx, i) } // ListCustomTLSConfigurations implements Interface. func (m API) ListCustomTLSConfigurations(ctx context.Context, i *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) { return m.ListCustomTLSConfigurationsFn(ctx, i) } // UpdateCustomTLSConfiguration implements Interface. func (m API) UpdateCustomTLSConfiguration(ctx context.Context, i *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { return m.UpdateCustomTLSConfigurationFn(ctx, i) } // GetTLSActivation implements Interface. func (m API) GetTLSActivation(ctx context.Context, i *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) { return m.GetTLSActivationFn(ctx, i) } // ListTLSActivations implements Interface. func (m API) ListTLSActivations(ctx context.Context, i *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) { return m.ListTLSActivationsFn(ctx, i) } // UpdateTLSActivation implements Interface. func (m API) UpdateTLSActivation(ctx context.Context, i *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) { return m.UpdateTLSActivationFn(ctx, i) } // CreateTLSActivation implements Interface. func (m API) CreateTLSActivation(ctx context.Context, i *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { return m.CreateTLSActivationFn(ctx, i) } // DeleteTLSActivation implements Interface. func (m API) DeleteTLSActivation(ctx context.Context, i *fastly.DeleteTLSActivationInput) error { return m.DeleteTLSActivationFn(ctx, i) } // CreateCustomTLSCertificate implements Interface. func (m API) CreateCustomTLSCertificate(ctx context.Context, i *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { return m.CreateCustomTLSCertificateFn(ctx, i) } // DeleteCustomTLSCertificate implements Interface. func (m API) DeleteCustomTLSCertificate(ctx context.Context, i *fastly.DeleteCustomTLSCertificateInput) error { return m.DeleteCustomTLSCertificateFn(ctx, i) } // GetCustomTLSCertificate implements Interface. func (m API) GetCustomTLSCertificate(ctx context.Context, i *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { return m.GetCustomTLSCertificateFn(ctx, i) } // ListCustomTLSCertificates implements Interface. func (m API) ListCustomTLSCertificates(ctx context.Context, i *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) { return m.ListCustomTLSCertificatesFn(ctx, i) } // UpdateCustomTLSCertificate implements Interface. func (m API) UpdateCustomTLSCertificate(ctx context.Context, i *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { return m.UpdateCustomTLSCertificateFn(ctx, i) } // ListTLSDomains implements Interface. func (m API) ListTLSDomains(ctx context.Context, i *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) { return m.ListTLSDomainsFn(ctx, i) } // CreatePrivateKey implements Interface. func (m API) CreatePrivateKey(ctx context.Context, i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) { return m.CreatePrivateKeyFn(ctx, i) } // DeletePrivateKey implements Interface. func (m API) DeletePrivateKey(ctx context.Context, i *fastly.DeletePrivateKeyInput) error { return m.DeletePrivateKeyFn(ctx, i) } // GetPrivateKey implements Interface. func (m API) GetPrivateKey(ctx context.Context, i *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) { return m.GetPrivateKeyFn(ctx, i) } // ListPrivateKeys implements Interface. func (m API) ListPrivateKeys(ctx context.Context, i *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) { return m.ListPrivateKeysFn(ctx, i) } // CreateBulkCertificate implements Interface. func (m API) CreateBulkCertificate(ctx context.Context, i *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) { return m.CreateBulkCertificateFn(ctx, i) } // DeleteBulkCertificate implements Interface. func (m API) DeleteBulkCertificate(ctx context.Context, i *fastly.DeleteBulkCertificateInput) error { return m.DeleteBulkCertificateFn(ctx, i) } // GetBulkCertificate implements Interface. func (m API) GetBulkCertificate(ctx context.Context, i *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) { return m.GetBulkCertificateFn(ctx, i) } // ListBulkCertificates implements Interface. func (m API) ListBulkCertificates(ctx context.Context, i *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) { return m.ListBulkCertificatesFn(ctx, i) } // UpdateBulkCertificate implements Interface. func (m API) UpdateBulkCertificate(ctx context.Context, i *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) { return m.UpdateBulkCertificateFn(ctx, i) } // CreateTLSSubscription implements Interface. func (m API) CreateTLSSubscription(ctx context.Context, i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return m.CreateTLSSubscriptionFn(ctx, i) } // DeleteTLSSubscription implements Interface. func (m API) DeleteTLSSubscription(ctx context.Context, i *fastly.DeleteTLSSubscriptionInput) error { return m.DeleteTLSSubscriptionFn(ctx, i) } // GetTLSSubscription implements Interface. func (m API) GetTLSSubscription(ctx context.Context, i *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return m.GetTLSSubscriptionFn(ctx, i) } // ListTLSSubscriptions implements Interface. func (m API) ListTLSSubscriptions(ctx context.Context, i *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) { return m.ListTLSSubscriptionsFn(ctx, i) } // UpdateTLSSubscription implements Interface. func (m API) UpdateTLSSubscription(ctx context.Context, i *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { return m.UpdateTLSSubscriptionFn(ctx, i) } // ListServiceAuthorizations implements Interface. func (m API) ListServiceAuthorizations(ctx context.Context, i *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) { return m.ListServiceAuthorizationsFn(ctx, i) } // GetServiceAuthorization implements Interface. func (m API) GetServiceAuthorization(ctx context.Context, i *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return m.GetServiceAuthorizationFn(ctx, i) } // CreateServiceAuthorization implements Interface. func (m API) CreateServiceAuthorization(ctx context.Context, i *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return m.CreateServiceAuthorizationFn(ctx, i) } // UpdateServiceAuthorization implements Interface. func (m API) UpdateServiceAuthorization(ctx context.Context, i *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { return m.UpdateServiceAuthorizationFn(ctx, i) } // DeleteServiceAuthorization implements Interface. func (m API) DeleteServiceAuthorization(ctx context.Context, i *fastly.DeleteServiceAuthorizationInput) error { return m.DeleteServiceAuthorizationFn(ctx, i) } // CreateConfigStore implements Interface. func (m API) CreateConfigStore(ctx context.Context, i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { return m.CreateConfigStoreFn(ctx, i) } // DeleteConfigStore implements Interface. func (m API) DeleteConfigStore(ctx context.Context, i *fastly.DeleteConfigStoreInput) error { return m.DeleteConfigStoreFn(ctx, i) } // GetConfigStore implements Interface. func (m API) GetConfigStore(ctx context.Context, i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { return m.GetConfigStoreFn(ctx, i) } // GetConfigStoreMetadata implements Interface. func (m API) GetConfigStoreMetadata(ctx context.Context, i *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) { return m.GetConfigStoreMetadataFn(ctx, i) } // ListConfigStores implements Interface. func (m API) ListConfigStores(ctx context.Context, i *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { return m.ListConfigStoresFn(ctx, i) } // ListConfigStoreServices implements Interface. func (m API) ListConfigStoreServices(ctx context.Context, i *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { return m.ListConfigStoreServicesFn(ctx, i) } // UpdateConfigStore implements Interface. func (m API) UpdateConfigStore(ctx context.Context, i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) { return m.UpdateConfigStoreFn(ctx, i) } // CreateConfigStoreItem implements Interface. func (m API) CreateConfigStoreItem(ctx context.Context, i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { return m.CreateConfigStoreItemFn(ctx, i) } // DeleteConfigStoreItem implements Interface. func (m API) DeleteConfigStoreItem(ctx context.Context, i *fastly.DeleteConfigStoreItemInput) error { return m.DeleteConfigStoreItemFn(ctx, i) } // GetConfigStoreItem implements Interface. func (m API) GetConfigStoreItem(ctx context.Context, i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { return m.GetConfigStoreItemFn(ctx, i) } // ListConfigStoreItems implements Interface. func (m API) ListConfigStoreItems(ctx context.Context, i *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { return m.ListConfigStoreItemsFn(ctx, i) } // UpdateConfigStoreItem implements Interface. func (m API) UpdateConfigStoreItem(ctx context.Context, i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { return m.UpdateConfigStoreItemFn(ctx, i) } // CreateKVStore implements Interface. func (m API) CreateKVStore(ctx context.Context, i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { return m.CreateKVStoreFn(ctx, i) } // GetKVStore implements Interface. func (m API) GetKVStore(ctx context.Context, i *fastly.GetKVStoreInput) (*fastly.KVStore, error) { return m.GetKVStoreFn(ctx, i) } // ListKVStores implements Interface. func (m API) ListKVStores(ctx context.Context, i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { return m.ListKVStoresFn(ctx, i) } // DeleteKVStore implements Interface. func (m API) DeleteKVStore(ctx context.Context, i *fastly.DeleteKVStoreInput) error { return m.DeleteKVStoreFn(ctx, i) } // ListKVStoreKeys implements Interface. func (m API) ListKVStoreKeys(ctx context.Context, i *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { return m.ListKVStoreKeysFn(ctx, i) } // GetKVStoreKey implements Interface. func (m API) GetKVStoreKey(ctx context.Context, i *fastly.GetKVStoreKeyInput) (string, error) { return m.GetKVStoreKeyFn(ctx, i) } // InsertKVStoreKey implements Interface. func (m API) InsertKVStoreKey(ctx context.Context, i *fastly.InsertKVStoreKeyInput) error { return m.InsertKVStoreKeyFn(ctx, i) } // DeleteKVStoreKey implements Interface. func (m API) DeleteKVStoreKey(ctx context.Context, i *fastly.DeleteKVStoreKeyInput) error { return m.DeleteKVStoreKeyFn(ctx, i) } // BatchModifyKVStoreKey implements Interface. func (m API) BatchModifyKVStoreKey(ctx context.Context, i *fastly.BatchModifyKVStoreKeyInput) error { return m.BatchModifyKVStoreKeyFn(ctx, i) } // CreateSecretStore implements Interface. func (m API) CreateSecretStore(ctx context.Context, i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { return m.CreateSecretStoreFn(ctx, i) } // GetSecretStore implements Interface. func (m API) GetSecretStore(ctx context.Context, i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { return m.GetSecretStoreFn(ctx, i) } // DeleteSecretStore implements Interface. func (m API) DeleteSecretStore(ctx context.Context, i *fastly.DeleteSecretStoreInput) error { return m.DeleteSecretStoreFn(ctx, i) } // ListSecretStores implements Interface. func (m API) ListSecretStores(ctx context.Context, i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { return m.ListSecretStoresFn(ctx, i) } // CreateSecret implements Interface. func (m API) CreateSecret(ctx context.Context, i *fastly.CreateSecretInput) (*fastly.Secret, error) { return m.CreateSecretFn(ctx, i) } // GetSecret implements Interface. func (m API) GetSecret(ctx context.Context, i *fastly.GetSecretInput) (*fastly.Secret, error) { return m.GetSecretFn(ctx, i) } // DeleteSecret implements Interface. func (m API) DeleteSecret(ctx context.Context, i *fastly.DeleteSecretInput) error { return m.DeleteSecretFn(ctx, i) } // ListSecrets implements Interface. func (m API) ListSecrets(ctx context.Context, i *fastly.ListSecretsInput) (*fastly.Secrets, error) { return m.ListSecretsFn(ctx, i) } // CreateClientKey implements Interface. func (m API) CreateClientKey(ctx context.Context) (*fastly.ClientKey, error) { return m.CreateClientKeyFn(ctx) } // GetSigningKey implements Interface. func (m API) GetSigningKey(ctx context.Context) (ed25519.PublicKey, error) { return m.GetSigningKeyFn(ctx) } // CreateResource implements Interface. func (m API) CreateResource(ctx context.Context, i *fastly.CreateResourceInput) (*fastly.Resource, error) { return m.CreateResourceFn(ctx, i) } // DeleteResource implements Interface. func (m API) DeleteResource(ctx context.Context, i *fastly.DeleteResourceInput) error { return m.DeleteResourceFn(ctx, i) } // GetResource implements Interface. func (m API) GetResource(ctx context.Context, i *fastly.GetResourceInput) (*fastly.Resource, error) { return m.GetResourceFn(ctx, i) } // ListResources implements Interface. func (m API) ListResources(ctx context.Context, i *fastly.ListResourcesInput) ([]*fastly.Resource, error) { return m.ListResourcesFn(ctx, i) } // UpdateResource implements Interface. func (m API) UpdateResource(ctx context.Context, i *fastly.UpdateResourceInput) (*fastly.Resource, error) { return m.UpdateResourceFn(ctx, i) } // CreateERL implements Interface. func (m API) CreateERL(ctx context.Context, i *fastly.CreateERLInput) (*fastly.ERL, error) { return m.CreateERLFn(ctx, i) } // DeleteERL implements Interface. func (m API) DeleteERL(ctx context.Context, i *fastly.DeleteERLInput) error { return m.DeleteERLFn(ctx, i) } // GetERL implements Interface. func (m API) GetERL(ctx context.Context, i *fastly.GetERLInput) (*fastly.ERL, error) { return m.GetERLFn(ctx, i) } // ListERLs implements Interface. func (m API) ListERLs(ctx context.Context, i *fastly.ListERLsInput) ([]*fastly.ERL, error) { return m.ListERLsFn(ctx, i) } // UpdateERL implements Interface. func (m API) UpdateERL(ctx context.Context, i *fastly.UpdateERLInput) (*fastly.ERL, error) { return m.UpdateERLFn(ctx, i) } // CreateCondition implements Interface. func (m API) CreateCondition(ctx context.Context, i *fastly.CreateConditionInput) (*fastly.Condition, error) { return m.CreateConditionFn(ctx, i) } // DeleteCondition implements Interface. func (m API) DeleteCondition(ctx context.Context, i *fastly.DeleteConditionInput) error { return m.DeleteConditionFn(ctx, i) } // GetCondition implements Interface. func (m API) GetCondition(ctx context.Context, i *fastly.GetConditionInput) (*fastly.Condition, error) { return m.GetConditionFn(ctx, i) } // ListConditions implements Interface. func (m API) ListConditions(ctx context.Context, i *fastly.ListConditionsInput) ([]*fastly.Condition, error) { return m.ListConditionsFn(ctx, i) } // UpdateCondition implements Interface. func (m API) UpdateCondition(ctx context.Context, i *fastly.UpdateConditionInput) (*fastly.Condition, error) { return m.UpdateConditionFn(ctx, i) } // ListAlertDefinitions implements Interface. func (m API) ListAlertDefinitions(ctx context.Context, i *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) { return m.ListAlertDefinitionsFn(ctx, i) } // CreateAlertDefinition implements Interface. func (m API) CreateAlertDefinition(ctx context.Context, i *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) { return m.CreateAlertDefinitionFn(ctx, i) } // GetAlertDefinition implements Interface. func (m API) GetAlertDefinition(ctx context.Context, i *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) { return m.GetAlertDefinitionFn(ctx, i) } // UpdateAlertDefinition implements Interface. func (m API) UpdateAlertDefinition(ctx context.Context, i *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) { return m.UpdateAlertDefinitionFn(ctx, i) } // DeleteAlertDefinition implements Interface. func (m API) DeleteAlertDefinition(ctx context.Context, i *fastly.DeleteAlertDefinitionInput) error { return m.DeleteAlertDefinitionFn(ctx, i) } // TestAlertDefinition implements Interface. func (m API) TestAlertDefinition(ctx context.Context, i *fastly.TestAlertDefinitionInput) error { return m.TestAlertDefinitionFn(ctx, i) } // ListAlertHistory implements Interface. func (m API) ListAlertHistory(ctx context.Context, i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) { return m.ListAlertHistoryFn(ctx, i) } // CreateObservabilityCustomDashboard implements Interface. func (m API) CreateObservabilityCustomDashboard(ctx context.Context, i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { return m.CreateObservabilityCustomDashboardFn(ctx, i) } // DeleteObservabilityCustomDashboard implements Interface. func (m API) DeleteObservabilityCustomDashboard(ctx context.Context, i *fastly.DeleteObservabilityCustomDashboardInput) error { return m.DeleteObservabilityCustomDashboardFn(ctx, i) } // GetObservabilityCustomDashboard implements Interface. func (m API) GetObservabilityCustomDashboard(ctx context.Context, i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { return m.GetObservabilityCustomDashboardFn(ctx, i) } // ListObservabilityCustomDashboards implements Interface. func (m API) ListObservabilityCustomDashboards(ctx context.Context, i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { return m.ListObservabilityCustomDashboardsFn(ctx, i) } // UpdateObservabilityCustomDashboard implements Interface. func (m API) UpdateObservabilityCustomDashboard(ctx context.Context, i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { return m.UpdateObservabilityCustomDashboardFn(ctx, i) } // GetImageOptimizerDefaultSettings implements Interface. func (m API) GetImageOptimizerDefaultSettings(ctx context.Context, i *fastly.GetImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return m.GetImageOptimizerDefaultSettingsFn(ctx, i) } // UpdateImageOptimizerDefaultSettings implements Interface. func (m API) UpdateImageOptimizerDefaultSettings(ctx context.Context, i *fastly.UpdateImageOptimizerDefaultSettingsInput) (*fastly.ImageOptimizerDefaultSettings, error) { return m.UpdateImageOptimizerDefaultSettingsFn(ctx, i) } ================================================ FILE: pkg/mock/client.go ================================================ package mock import ( "bytes" "context" "fmt" "io" "net/http" "github.com/fastly/go-fastly/v15/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, 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(_ context.Context, 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, } } ================================================ FILE: pkg/mock/config_file.go ================================================ package mock // ConfigFile is a mock implementation of the toml.ReadWriter interface that's // used for testing. type ConfigFile struct { PathFn func() string ExistsFn func() bool ReadFn func(c any) error WriteFn func(c any) error } // Path satisfies the toml.ReadWriter interface for testing purposes. func (c *ConfigFile) Path() string { return c.PathFn() } // Exists satisfies the toml.ReadWriter interface for testing purposes. func (c *ConfigFile) Exists() bool { return c.ExistsFn() } // Read satisfies the toml.ReadWriter interface for testing purposes. 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 any) error { return c.WriteFn(config) } // NewNonExistentConfigFile is a test helper function which constructs a new // non-existent config file interface. func NewNonExistentConfigFile() *ConfigFile { return &ConfigFile{ PathFn: func() string { return "" }, ExistsFn: func() bool { return false }, } } ================================================ FILE: pkg/mock/doc.go ================================================ // Package mock provides mock implementations of various interfaces. // It's designed to be used in tests. package mock ================================================ FILE: pkg/mock/versioner.go ================================================ package mock import "fmt" // AssetVersioner mocks the github.AssetVersioner interface. type AssetVersioner struct { AssetVersion string BinaryFilename string DownloadOK bool DownloadedFile string InstallFilePath string } // BinaryName implements github.Versioner interface. func (av AssetVersioner) BinaryName() string { return av.BinaryFilename } // DownloadLatest implements github.Versioner interface. func (av AssetVersioner) DownloadLatest() (string, error) { if av.DownloadOK { return av.DownloadedFile, nil } 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 } // InstallPath returns the location of where the binary should be installed. func (av AssetVersioner) InstallPath() string { return av.InstallFilePath } ================================================ FILE: pkg/revision/revision.go ================================================ // 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 ( "fmt" "runtime" "strings" ) var ( // AppVersion is the semver for this version of the client, or // "v0.0.0-unknown". Handled by goreleaser. AppVersion string // GitCommit is the short git SHA associated with this build, or // "unknown". Handled by goreleaser. GitCommit string // 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. const None = "v0.0.0-unknown" func init() { if AppVersion == "" { AppVersion = None } if GitCommit == "" { GitCommit = "unknown" } GoHostOS = runtime.GOOS GoHostArch = runtime.GOARCH if GoVersion == "" { // 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" } } // SemVer accepts the application revision version, which is prefixed with a // `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`. func SemVer(av string) string { av = strings.TrimPrefix(av, "v") seg := strings.Split(av, "-") return seg[0] } ================================================ FILE: pkg/revision/revision_test.go ================================================ package revision import "testing" func TestSemVer(t *testing.T) { got := SemVer("v1.0.0-xyz") want := "1.0.0" if got != want { t.Fatalf("want %s, got %s", want, got) } } ================================================ FILE: pkg/runtime/doc.go ================================================ // Package runtime contains variables for handling runtime information. package runtime ================================================ FILE: pkg/runtime/runtime.go ================================================ 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" ================================================ FILE: pkg/sync/doc.go ================================================ // Package sync contains abstractions for working with concurrent writers. package sync ================================================ FILE: pkg/sync/sync.go ================================================ 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) } ================================================ FILE: pkg/testutil/api.go ================================================ package testutil // Service Version Testing Guide // // This package provides standard mock functions for testing service version operations. // The version mocks follow a consistent pattern across all tests: // // Version States: // - Version 1: ACTIVE (cannot modify without --autoclone) // - Version 2: LOCKED (cannot modify without --autoclone) // - Version 3: EDITABLE (can modify directly) // - Version 4: STAGING (can modify directly) // // Test Scenario Guide: // // 1. Testing --autoclone behavior: // Use: --version 1 or --version 2 // Why: Tests that the CLI correctly clones active/locked versions // Example: "validate --autoclone on locked version" // // 2. Testing successful modifications (create/update/delete): // Use: --version 3 // Why: Version is editable, so modification succeeds directly // Example: "validate backend creation" // // 3. Testing API errors: // Use: --version 3 // Why: Avoids autoclone logic interfering with error testing // Example: "validate API error when creating backend" // // 4. Testing version activation: // Use: --version 3 // Why: Version 1 is already active, so test would fail validation // Example: "validate version activation" // // 5. Testing without --version flag: // Use: No --version flag (defaults to active or latest) // Mock: ListVersionsFn only (GetVersionFn not needed) // // 6. Testing keyword versions (--version active/latest): // Use: --version active or --version latest // Mock: ListVersionsFn only (GetVersionFn not needed) // // Required Mock Functions: // - GetVersionFn: REQUIRED when using numeric --version flags (--version 1, --version 2, etc.) // - ListVersionsFn: REQUIRED when using keyword versions or no --version flag // - Both: REQUIRED when using --autoclone (needs GetVersion for initial parse, ListVersions for clone check) // // Example Test Patterns: // // // Pattern 1: Testing --autoclone on locked version // { // Args: "--service-id 123 --version 2 --name test --autoclone", // API: &mock.API{ // GetVersionFn: testutil.GetVersion, // Parse --version 2 // ListVersionsFn: testutil.ListVersions, // Check if locked // CloneVersionFn: testutil.CloneVersionResult(4), // Clone to v4 // UpdateBackendFn: updateBackendOK, // Modify v4 // }, // WantOutput: "Updated backend (service 123 version 4)", // } // // // Pattern 2: Testing API error // { // Args: "--service-id 123 --version 3 --name test", // API: &mock.API{ // GetVersionFn: testutil.GetVersion, // Parse --version 3 // ListVersionsFn: testutil.ListVersions, // Check if editable // UpdateBackendFn: updateBackendError, // API fails // }, // WantError: "API error", // } // // // Pattern 3: Testing with keyword version // { // Args: "--service-id 123 --version active --name test", // API: &mock.API{ // ListVersionsFn: testutil.ListVersions, // Find active version // GetBackendFn: getBackendOK, // }, // WantOutput: "Backend details", // } import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "github.com/fastly/go-fastly/v15/fastly" authcmd "github.com/fastly/cli/pkg/commands/auth" "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. // // Versions are returned in descending order by version number (highest first), // matching the real Fastly API behavior. // // Version states: // - Version 4: Staged (can be modified directly) // - Version 3: Editable (can be modified directly) // - Version 2: Locked (cannot be modified without --autoclone) // - Version 1: Active (cannot be modified without --autoclone) // // Usage guide for test scenarios: // - Testing --autoclone behavior: Use version 1 or 2 (active/locked) // - Testing successful modifications: Use version 3 (editable) // - Testing API errors: Use version 3 (so autoclone logic doesn't interfere) // - Testing version activation: Use version 3 or 4 (not already active) // - Testing staging/unstaging: Use version 4 (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. // // IMPORTANT: When using numeric --version flags in tests, you must include // GetVersionFn: testutil.GetVersion in the mock API, as the CLI now calls // GetVersion for numeric versions. func ListVersions(_ context.Context, i *fastly.ListVersionsInput) ([]*fastly.Version, error) { return []*fastly.Version{ { ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-04T01: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(2), Locked: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-02T01:00:00Z"), }, { ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-01T01:00:00Z"), }, }, nil } // ListVersionsError returns a generic error message when attempting to list // service versions. func ListVersionsError(_ context.Context, _ *fastly.ListVersionsInput) ([]*fastly.Version, error) { return nil, Err } // GetVersion returns a version matching the requested version number. // // This function must be included in mock APIs when tests use numeric --version // flags (e.g., --version 1, --version 2), as the CLI now calls GetVersion for // numeric versions before processing them. // // Version states returned: // - Version 1: Active (Active=true) // - Version 2: Locked (Locked=true) // - Version 3: Editable (no Active/Locked flags) // - Version 4: Staging (Staging=true) // - Version 5: Generic editable version (commonly used after cloning version 4) // - Version 999: Returns an error (version not found - use this to test error handling) // - Other numbers: Returns a generic editable version // // This matches the versions returned by ListVersions for versions 1-4. // // Example test setup: // // API: &mock.API{ // GetVersionFn: testutil.GetVersion, // Required for numeric --version flags // ListVersionsFn: testutil.ListVersions, // Required for keyword versions (active/latest) // CloneVersionFn: testutil.CloneVersionResult(4), // } func GetVersion(_ context.Context, i *fastly.GetVersionInput) (*fastly.Version, error) { switch i.ServiceVersion { case 1: return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-01T01:00:00Z"), }, nil case 2: return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(2), Locked: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-02T01:00:00Z"), }, nil case 3: return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(3), UpdatedAt: MustParseTimeRFC3339("2000-01-03T01:00:00Z"), }, nil case 4: return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-04T01:00:00Z"), }, nil case 5: // Version 5 is commonly used in tests after cloning version 4 return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(5), UpdatedAt: MustParseTimeRFC3339("2000-01-05T01:00:00Z"), }, nil case 999: // Return an error for test cases that explicitly want to test version not found return nil, Err default: // Return a generic version for any other number to avoid breaking existing tests return &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(i.ServiceVersion), UpdatedAt: MustParseTimeRFC3339("2000-01-01T01:00:00Z"), }, nil } } // CloneVersionResult returns a function which returns a specific cloned version. func CloneVersionResult(version int) func(_ context.Context, i *fastly.CloneVersionInput) (*fastly.Version, error) { return func(_ context.Context, 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(_ context.Context, _ *fastly.CloneVersionInput) (*fastly.Version, error) { return nil, Err } // WhoamiVerifyClient is used by `whoami` and auth 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 auth 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 auth tests. type CurrentCustomerClient authcmd.CurrentCustomerResponse // Do executes the HTTP request. func (c CurrentCustomerClient) Do(*http.Request) (*http.Response, error) { rec := httptest.NewRecorder() _ = json.NewEncoder(rec).Encode(authcmd.CurrentCustomerResponse(c)) return rec.Result(), nil } // CurrentCustomerResponse is used by SSO auth tests. var CurrentCustomerResponse = authcmd.CurrentCustomerResponse{ ID: "abc", Name: "Computer Company", } // GetServiceDetails returns service details with versions matching the filter. // // This function must be included in mock APIs when the CLI calls GetServiceDetails, // which happens when using the --version active flag. // // Filters supported: // - versions.active: Returns version 1 (active) // // This matches the version states returned by ListVersions and GetVersion. // // Example test setup: // // API: &mock.API{ // GetServiceDetailsFn: testutil.GetServiceDetails, // Required for --version active // GetVersionFn: testutil.GetVersion, // Required for numeric --version flags // ListVersionsFn: testutil.ListVersions, // Required for --version latest or omitted flag // } func GetServiceDetails(_ context.Context, i *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { detail := &fastly.ServiceDetail{ ServiceID: fastly.ToPointer(i.ServiceID), Versions: []*fastly.Version{}, } // Check filters to determine which version to return for _, filter := range i.Filters { if filter.Key == "versions.active" && filter.Value { version := &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-01T01:00:00Z"), } detail.ActiveVersion = version detail.Version = version detail.Versions = append(detail.Versions, version) } if filter.Key == "versions.staged" && filter.Value { version := &fastly.Version{ ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(4), Staging: fastly.ToPointer(true), UpdatedAt: MustParseTimeRFC3339("2000-01-04T01:00:00Z"), } detail.Version = version detail.Versions = append(detail.Versions, version) } } return detail, nil } ================================================ FILE: pkg/testutil/args.go ================================================ 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{ Auth: config.Auth{ Default: "user", Tokens: config.AuthTokens{ "user": &config.AuthToken{ Type: config.AuthTokenTypeStatic, Token: "mock-token", Email: "test@example.com", }, }, }, }, ConfigPath: configPath, Env: config.Environment{}, ErrLog: errors.Log, ErrOutput: stdout, 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, } } ================================================ FILE: pkg/testutil/assert.go ================================================ package testutil import ( stderrors "errors" "fmt" "reflect" "strings" "testing" "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 any) { t.Helper() if diff := cmp.Diff(want, have); diff != "" { t.Fatal(diff) } } // AssertBool fatals a test if the parameters aren't equal. func AssertBool(t *testing.T, want, have bool) { t.Helper() if want != have { t.Fatalf("want %v, have %v", want, have) } } // AssertString fatals a test if the parameters aren't equal. func AssertString(t *testing.T, want, have string) { t.Helper() if want != have { t.Fatal(cmp.Diff(want, have)) } } // AssertStringContains fatals a test if the string doesn't contain a substring. func AssertStringContains(t *testing.T, s, substr string) { t.Helper() if !strings.Contains(s, substr) { t.Fatalf("%q doesn't contain %q", s, substr) } } // 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() if err != nil { t.Fatalf("unexpected error: %v", err) } } // AssertErrorContains fatals a test if the error's Error string doesn't contain // target. As a special case, if target is the empty string, we assume the error // should be nil. func AssertErrorContains(t *testing.T, err error, target string) { t.Helper() switch { case err == nil && target == "": return // great case err == nil && target != "": t.Fatalf("want %q, have no error", target) case err != nil && target == "": 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) } } } // AssertRemediationErrorContains fatals a test if the error's RemediationError // remediation string doesn't contain target. As a special case, if target is // the empty string, we assume the error should be nil. func AssertRemediationErrorContains(t *testing.T, err error, target string) { t.Helper() var re errors.RemediationError ok := stderrors.As(err, &re) switch { case err == nil && target == "": return // great case err == nil && target != "": t.Fatalf("want %q, have no error", target) case err != nil && target != "" && !ok: t.Fatalf("have no RemediationError in: %v", err) case err != nil && target != "": if want, have := target, re.Remediation; !strings.Contains(have, want) { t.Fatalf("want remediation containing %q, have %q", want, have) } } } // 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) } } ================================================ FILE: pkg/testutil/client.go ================================================ 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 } // MultiResponseRoundTripper implements [http.RoundTripper] for mocking multiple // sequential HTTP responses. This is useful when the code under test makes // multiple HTTP calls (e.g., GET then PATCH). // // When we perform a get and update in go-fastly operations (such as for alerts), // we need to be able to parse multiple responses back from the API. type MultiResponseRoundTripper struct { Responses []*http.Response index int } // RoundTrip executes a single HTTP transaction, returning the next Response // in sequence. func (m *MultiResponseRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { if m.index >= len(m.Responses) { return m.Responses[len(m.Responses)-1], nil } resp := m.Responses[m.index] m.index++ return resp, nil } ================================================ FILE: pkg/testutil/doc.go ================================================ // Package testutil provides helpers for unit tests. package testutil ================================================ FILE: pkg/testutil/env.go ================================================ 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) } } ================================================ FILE: pkg/testutil/file.go ================================================ package testutil import ( "io" "os" "path/filepath" "testing" ) // MakeTempFile creates a tempfile with the given contents and returns its path. func MakeTempFile(t *testing.T, contents string) string { t.Helper() tmpfile, err := os.CreateTemp("", "fastly-*") if err != nil { t.Fatal(err) } if _, err := tmpfile.Write([]byte(contents)); err != nil { t.Fatal(err) } 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) } } ================================================ FILE: pkg/testutil/json.go ================================================ 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 } ================================================ FILE: pkg/testutil/log.go ================================================ 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 } ================================================ FILE: pkg/testutil/must.go ================================================ package testutil import ( "time" ) // MustParseTimeRFC3339 is a small helper to initialize time constants. func MustParseTimeRFC3339(s string) *time.Time { tm, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return &tm } ================================================ FILE: pkg/testutil/paginator.go ================================================ package testutil import ( "github.com/fastly/go-fastly/v15/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 } ================================================ FILE: pkg/testutil/scenarios.go ================================================ package testutil import ( "fmt" "io" "net/http" "os" "slices" "strings" "testing" "time" "github.com/fastly/go-fastly/v15/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 // WantRemediation will cause the scenario to fail if the // error's RemediationError.Remediation doesn't contain this string WantRemediation 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.API == 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) } if scenario.Client != nil { 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) if scenario.WantRemediation != "" { AssertRemediationErrorContains(t, err, scenario.WantRemediation) } 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) } } ================================================ FILE: pkg/testutil/string.go ================================================ package testutil import "strings" // StripNewLines removes all newline delimiters. func StripNewLines(s string) string { return strings.ReplaceAll(s, "\n", "") } ================================================ FILE: pkg/testutil/time.go ================================================ 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) ================================================ FILE: pkg/text/accesskey.go ================================================ package text import ( "fmt" "io" "github.com/fastly/cli/pkg/time" "github.com/fastly/go-fastly/v15/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", "Permission", "Buckets", "Created At") if accessKeys == nil { tbl.Print() return } for _, accessKey := range accessKeys { // avoid gosec loop aliasing check :/ accessKey := accessKey var buckets string if len(accessKey.Buckets) == 0 { // No limitations on buckets buckets = "all" } else { buckets = fmt.Sprintf("%v", accessKey.Buckets) } tbl.AddLine(accessKey.AccessKeyID, accessKey.SecretKey, accessKey.Description, accessKey.Permission, buckets, accessKey.CreatedAt) } tbl.Print() } ================================================ FILE: pkg/text/alerts.go ================================================ package text import ( "fmt" "io" "strings" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/datadog" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/jira" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/mailinglist" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/microsoftteams" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/opsgenie" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/pagerduty" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/slack" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/alerts/webhook" ) // PrintAlert displays a single alert. // Accepts any alert type (datadog, slack, webhook, etc.) via any. func PrintAlert(out io.Writer, alert any) { var id, alertType, description, createdAt, createdBy string var config any // Extract common fields based on type switch a := alert.(type) { case *datadog.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *jira.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *mailinglist.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *microsoftteams.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *opsgenie.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *pagerduty.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *slack.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config case *webhook.Alert: id = a.ID alertType = a.Type description = a.Description createdAt = a.CreatedAt createdBy = a.CreatedBy config = a.Config default: fmt.Fprintf(out, "Unknown alert type\n") return } fmt.Fprintf(out, "ID: %s\n", id) fmt.Fprintf(out, "Type: %s\n", alertType) fmt.Fprintf(out, "Description: %s\n", description) fmt.Fprintf(out, "Created At: %s\n", createdAt) fmt.Fprintf(out, "Created By: %s\n", createdBy) printAlertConfig(out, alertType, config) } // printAlertConfig prints alert configuration based on type. func printAlertConfig(out io.Writer, alertType string, config any) { fmt.Fprint(out, "Config:\n") switch alertType { case "datadog": printDatadogConfig(out, config) case "jira": printJiraConfig(out, config) case "mailinglist": printMailingListConfig(out, config) case "microsoftteams", "slack", "webhook": printWebhookConfig(out, config) case "opsgenie", "pagerduty": printKeyConfig(out, config) default: fmt.Fprintf(out, " (unknown type: %s)\n", alertType) } } // printDatadogConfig prints Datadog-specific configuration. func printDatadogConfig(out io.Writer, config any) { if cfg, ok := config.(datadog.ResponseConfig); ok { if cfg.Key != nil { fmt.Fprintf(out, " Key: \n") } if cfg.Site != nil { fmt.Fprintf(out, " Site: %s\n", *cfg.Site) } } } // printWebhookConfig prints webhook-based configuration (slack, webhook, microsoftteams). func printWebhookConfig(out io.Writer, config any) { var hasWebhook bool switch cfg := config.(type) { case slack.ResponseConfig: hasWebhook = cfg.Webhook != nil case webhook.ResponseConfig: hasWebhook = cfg.Webhook != nil case microsoftteams.ResponseConfig: hasWebhook = cfg.Webhook != nil } if hasWebhook { fmt.Fprintf(out, " Webhook: \n") } } // printJiraConfig prints Jira-specific configuration. func printJiraConfig(out io.Writer, config any) { if cfg, ok := config.(jira.ResponseConfig); ok { if cfg.Host != nil { fmt.Fprintf(out, " Host: %s\n", *cfg.Host) } if cfg.Username != nil { fmt.Fprintf(out, " Username: %s\n", *cfg.Username) } if cfg.Project != nil { fmt.Fprintf(out, " Project: %s\n", *cfg.Project) } if cfg.IssueType != nil { fmt.Fprintf(out, " Issue Type: %s\n", *cfg.IssueType) } if cfg.Key != nil { fmt.Fprintf(out, " Key: \n") } } } // printMailingListConfig prints Mailing List-specific configuration. func printMailingListConfig(out io.Writer, config any) { if cfg, ok := config.(mailinglist.ResponseConfig); ok { if cfg.Address != nil { fmt.Fprintf(out, " Address: %s\n", *cfg.Address) } } } // printKeyConfig prints key-based configuration (opsgenie, pagerduty). func printKeyConfig(out io.Writer, config any) { var hasKey bool switch cfg := config.(type) { case opsgenie.ResponseConfig: hasKey = cfg.Key != nil case pagerduty.ResponseConfig: hasKey = cfg.Key != nil } if hasKey { fmt.Fprintf(out, " Key: \n") } } // getConfigSummary returns a string summary of the alert config with sensitive fields redacted. func getConfigSummary(alertType string, config any) string { switch alertType { case "datadog": if cfg, ok := config.(datadog.ResponseConfig); ok { parts := []string{} if cfg.Site != nil { parts = append(parts, fmt.Sprintf("Site: %s", *cfg.Site)) } if cfg.Key != nil { parts = append(parts, "Key: ") } return strings.Join(parts, ", ") } case "jira": if cfg, ok := config.(jira.ResponseConfig); ok { parts := []string{} if cfg.Host != nil { parts = append(parts, fmt.Sprintf("Host: %s", *cfg.Host)) } if cfg.IssueType != nil { parts = append(parts, fmt.Sprintf("Issue Type: %s", *cfg.IssueType)) } if cfg.Key != nil { parts = append(parts, "Key: ") } if cfg.Project != nil { parts = append(parts, fmt.Sprintf("Project: %s", *cfg.Project)) } if cfg.Username != nil { parts = append(parts, fmt.Sprintf("Username: %s", *cfg.Username)) } return strings.Join(parts, ", ") } case "mailinglist": if cfg, ok := config.(mailinglist.ResponseConfig); ok { if cfg.Address != nil { return fmt.Sprintf("Address: %s", *cfg.Address) } } case "microsoftteams", "slack", "webhook": return "Webhook: " case "opsgenie", "pagerduty": return "Key: " } return "" } // PrintAlertTbl prints a table of alerts. func PrintAlertTbl(out io.Writer, alerts any) { tbl := NewTable(out) tbl.AddHeader("ID", "Type", "Description", "Created At", "Created By", "Config") // Handle different alert type slices switch a := alerts.(type) { case []datadog.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []slack.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []webhook.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []jira.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []mailinglist.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []microsoftteams.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []opsgenie.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } case []pagerduty.Alert: for _, alert := range a { configSummary := getConfigSummary(alert.Type, alert.Config) tbl.AddLine(alert.ID, alert.Type, alert.Description, alert.CreatedAt, alert.CreatedBy, configSummary) } } tbl.Print() } ================================================ FILE: pkg/text/backend.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/fastly" ) // PrintBackend pretty prints a fastly.Backend 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 PrintBackend(out io.Writer, prefix string, b *fastly.Backend) { out = textio.NewPrefixWriter(out, prefix) 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, "Max connection use: %v\n", fastly.ToValue(b.MaxUse)) fmt.Fprintf(out, "Max connection lifetime: %v\n", fastly.ToValue(b.MaxLifetime)) 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)) } ================================================ FILE: pkg/text/color.go ================================================ package text 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() // BoldYellow is a Sprint-class function that makes the arguments bold and yellow. var BoldYellow = color.New(color.Bold, color.FgYellow).SprintFunc() // BoldGreen is a Sprint-class function that makes the arguments bold and green. 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 ================================================ FILE: pkg/text/computeacl.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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() } ================================================ FILE: pkg/text/configstore.go ================================================ package text import ( "fmt" "io" "strconv" "time" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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)) } } ================================================ FILE: pkg/text/customsignal.go ================================================ package text import ( "fmt" "io" "github.com/fastly/cli/pkg/time" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/signals" ) // PrintCustomSignal displays an NGWAF custom signal. func PrintCustomSignal(out io.Writer, customSignalToPrint *signals.Signal) { fmt.Fprintf(out, "ID: %s\n", customSignalToPrint.SignalID) fmt.Fprintf(out, "Name: %s\n", customSignalToPrint.Name) fmt.Fprintf(out, "Description: %s\n", customSignalToPrint.Description) fmt.Fprintf(out, "Scope: %s\n", customSignalToPrint.Scope.Type) fmt.Fprintf(out, "Updated (UTC): %s\n", customSignalToPrint.UpdatedAt.UTC().Format(time.Format)) fmt.Fprintf(out, "Created (UTC): %s\n", customSignalToPrint.CreatedAt.UTC().Format(time.Format)) } // PrintCustomSignalTbl displays custom signals in a table format. func PrintCustomSignalTbl(out io.Writer, customSignalsToPrint []signals.Signal) { tbl := NewTable(out) tbl.AddHeader("ID", "Name", "Description", "Scope", "Updated At", "Created At") if customSignalsToPrint == nil { tbl.Print() return } for _, customSignalToPrint := range customSignalsToPrint { tbl.AddLine( customSignalToPrint.SignalID, customSignalToPrint.Name, customSignalToPrint.Description, customSignalToPrint.Scope.Type, customSignalToPrint.UpdatedAt, customSignalToPrint.CreatedAt, ) } tbl.Print() } ================================================ FILE: pkg/text/dictionary.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/time" ) // PrintDictionary 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 PrintDictionary(out io.Writer, prefix string, d *fastly.Dictionary) { out = textio.NewPrefixWriter(out, prefix) 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(time.Format)) } } ================================================ FILE: pkg/text/dictionaryitem.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/time" ) // PrintDictionaryItem pretty prints a fastly.DictionaryInfo 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 PrintDictionaryItem(out io.Writer, prefix string, d *fastly.DictionaryItem) { out = textio.NewPrefixWriter(out, prefix) 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(time.Format)) } if d.UpdatedAt != nil { 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(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", fastly.ToValue(d.ItemKey)) fmt.Fprintf(out, "Item Value: %s\n", fastly.ToValue(d.ItemValue)) } ================================================ FILE: pkg/text/dictionaryitem_test.go ================================================ package text_test import ( "bytes" "testing" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" "github.com/fastly/go-fastly/v15/fastly" ) func TestPrintDictionaryItem(t *testing.T) { for _, testcase := range []struct { name string dictionaryItem *fastly.DictionaryItem wantOutput string }{ { name: "base", dictionaryItem: &fastly.DictionaryItem{}, wantOutput: "Dictionary ID: \nItem Key: \nItem Value: \n", }, } { t.Run(testcase.name, func(t *testing.T) { var buf bytes.Buffer text.PrintDictionaryItem(&buf, "", testcase.dictionaryItem) testutil.AssertString(t, testcase.wantOutput, buf.String()) }) } } ================================================ FILE: pkg/text/doc.go ================================================ // Package text contains functions for handling the display of text. package text ================================================ FILE: pkg/text/healthcheck.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/fastly" ) // PrintHealthCheck pretty prints a fastly.HealthCheck 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 PrintHealthCheck(out io.Writer, prefix string, h *fastly.HealthCheck) { out = textio.NewPrefixWriter(out, prefix) 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)) } ================================================ FILE: pkg/text/kvstore.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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 a 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) } ================================================ FILE: pkg/text/lines.go ================================================ 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]) } } ================================================ FILE: pkg/text/lines_test.go ================================================ 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()) }) } } ================================================ FILE: pkg/text/list.go ================================================ package text import ( "fmt" "io" "strings" "github.com/fastly/cli/pkg/time" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/lists" ) // PrintList displays an NGWAF list. func PrintList(out io.Writer, listToPrint *lists.List) { fmt.Fprintf(out, "ID: %s\n", listToPrint.ListID) fmt.Fprintf(out, "Name: %s\n", listToPrint.Name) fmt.Fprintf(out, "Description: %s\n", listToPrint.Description) fmt.Fprintf(out, "Type: %s\n", listToPrint.Type) fmt.Fprintf(out, "Entries: %s\n", strings.Join(listToPrint.Entries, ", ")) fmt.Fprintf(out, "Scope: %s\n", listToPrint.Scope.Type) fmt.Fprintf(out, "Updated (UTC): %s\n", listToPrint.UpdatedAt.UTC().Format(time.Format)) fmt.Fprintf(out, "Created (UTC): %s\n", listToPrint.CreatedAt.UTC().Format(time.Format)) } // PrintWorkspaceTbl displays workspaces in a table format. func PrintListTbl(out io.Writer, listsToPrint []lists.List) { tbl := NewTable(out) tbl.AddHeader("ID", "Name", "Description", "Type", "Scope", "Entries", "Updated At", "Created At") if listsToPrint == nil { tbl.Print() return } for _, listToPrint := range listsToPrint { tbl.AddLine( listToPrint.ListID, listToPrint.Name, listToPrint.Description, listToPrint.Type, listToPrint.Scope.Type, strings.Join(listToPrint.Entries, ", "), listToPrint.UpdatedAt, listToPrint.CreatedAt, ) } tbl.Print() } ================================================ FILE: pkg/text/redaction.go ================================================ package text import ( "fmt" "io" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/redactions" ) // PrintRedaction displays a single redaction. func PrintRedaction(out io.Writer, redactionToPrint *redactions.Redaction) { fmt.Fprintf(out, "Field: %s\n", redactionToPrint.Field) fmt.Fprintf(out, "ID: %s\n", redactionToPrint.RedactionID) fmt.Fprintf(out, "Type: %s\n", redactionToPrint.Type) fmt.Fprintf(out, "Created At: %s\n", redactionToPrint.CreatedAt) } // PrintRedactionTbl prints a table of redactions. func PrintRedactionTbl(out io.Writer, redactionsToPrint []redactions.Redaction) { tbl := NewTable(out) tbl.AddHeader("Field", "ID", "Type", "Created At") if redactionsToPrint == nil { tbl.Print() return } for _, rd := range redactionsToPrint { tbl.AddLine(rd.Field, rd.RedactionID, rd.Type, rd.CreatedAt) } tbl.Print() } ================================================ FILE: pkg/text/resource.go ================================================ package text import ( "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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)) } } ================================================ FILE: pkg/text/rule.go ================================================ package text import ( "fmt" "io" "github.com/fastly/cli/pkg/time" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/rules" ) // PrintRule displays an NGWAF rule. func PrintRule(out io.Writer, ruleToPrint *rules.Rule) { fmt.Fprintf(out, "ID: %s\n", ruleToPrint.RuleID) fmt.Fprintf(out, "Action: %s\n", ruleToPrint.Actions[0].Type) fmt.Fprintf(out, "Description: %s\n", ruleToPrint.Description) fmt.Fprintf(out, "Enabled: %v\n", ruleToPrint.Enabled) fmt.Fprintf(out, "Type: %s\n", ruleToPrint.Type) fmt.Fprintf(out, "Scope: %s\n", ruleToPrint.Scope.Type) fmt.Fprintf(out, "Updated (UTC): %s\n", ruleToPrint.UpdatedAt.UTC().Format(time.Format)) fmt.Fprintf(out, "Created (UTC): %s\n", ruleToPrint.CreatedAt.UTC().Format(time.Format)) } // PrintRuleTbl displays rules in a table format. func PrintRuleTbl(out io.Writer, rulesToPrint []rules.Rule) { tbl := NewTable(out) tbl.AddHeader("ID", "Action", "Description", "Enabled", "Type", "Scope", "Updated At", "Created At") if rulesToPrint == nil { tbl.Print() return } for _, ruleToPrint := range rulesToPrint { tbl.AddLine( ruleToPrint.RuleID, ruleToPrint.Actions[0].Type, ruleToPrint.Description, ruleToPrint.Enabled, ruleToPrint.Type, ruleToPrint.Scope.Type, ruleToPrint.UpdatedAt, ruleToPrint.CreatedAt, ) } tbl.Print() } ================================================ FILE: pkg/text/sanitize.go ================================================ package text import ( "fmt" "strings" ) // SanitizeTerminalOutput escapes control characters from untrusted content // to prevent terminal injection attacks. func SanitizeTerminalOutput(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { switch { case r == '\t', r == '\n', r == '\r': b.WriteRune(r) case r < 0x20 || r == 0x7F: fmt.Fprintf(&b, "\\x%02x", r) default: b.WriteRune(r) } } return b.String() } ================================================ FILE: pkg/text/sanitize_test.go ================================================ package text import "testing" func TestSanitizeTerminalOutput(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "normal text unchanged", input: "Hello, World!", expected: "Hello, World!", }, { name: "preserves newlines and tabs", input: "line1\nline2\ttabbed", expected: "line1\nline2\ttabbed", }, { name: "escapes color codes", input: "\x1b[31mRED\x1b[0m", expected: "\\x1b[31mRED\\x1b[0m", }, { name: "escapes bold and other SGR codes", input: "\x1b[1mbold\x1b[0m \x1b[4munderline\x1b[0m", expected: "\\x1b[1mbold\\x1b[0m \\x1b[4munderline\\x1b[0m", }, { name: "escapes cursor movement", input: "before\x1b[2Aafter", expected: "before\\x1b[2Aafter", }, { name: "escapes screen clear", input: "\x1b[2Jcleared", expected: "\\x1b[2Jcleared", }, { name: "escapes window title manipulation (OSC)", input: "\x1b]0;malicious title\x07content", expected: "\\x1b]0;malicious title\\x07content", }, { name: "escapes multiple escape sequences", input: "\x1b[31m\x1b[1mred bold\x1b[0m normal \x1b[32mgreen\x1b[0m", expected: "\\x1b[31m\\x1b[1mred bold\\x1b[0m normal \\x1b[32mgreen\\x1b[0m", }, { name: "escapes VCL content with escape sequences", input: "sub vcl_recv { # \x1b[31mRED\x1b[0m }", expected: "sub vcl_recv { # \\x1b[31mRED\\x1b[0m }", }, { name: "empty string unchanged", input: "", expected: "", }, { name: "escapes cursor position codes", input: "\x1b[10;20Htext at position", expected: "\\x1b[10;20Htext at position", }, { name: "escapes erase codes", input: "\x1b[Kerase line\x1b[Jclear below", expected: "\\x1b[Kerase line\\x1b[Jclear below", }, { name: "escapes standalone BEL", input: "before\x07after", expected: "before\\x07after", }, { name: "escapes backspace", input: "secret\x08visible", expected: "secret\\x08visible", }, { name: "escapes NUL character", input: "before\x00after", expected: "before\\x00after", }, { name: "escapes form feed", input: "page1\x0cpage2", expected: "page1\\x0cpage2", }, { name: "escapes vertical tab", input: "line1\x0bline2", expected: "line1\\x0bline2", }, { name: "escapes DEL character", input: "before\x7fafter", expected: "before\\x7fafter", }, { name: "preserves tab newline carriage return", input: "col1\tcol2\nline2\r\nline3", expected: "col1\tcol2\nline2\r\nline3", }, { name: "escapes mixed control characters", input: "\x00\x07\x08text\x0b\x0c\x1a\x7f", expected: "\\x00\\x07\\x08text\\x0b\\x0c\\x1a\\x7f", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SanitizeTerminalOutput(tt.input) if result != tt.expected { t.Errorf("SanitizeTerminalOutput(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } ================================================ FILE: pkg/text/secretstore.go ================================================ package text import ( "encoding/hex" "fmt" "io" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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)) } ================================================ FILE: pkg/text/service.go ================================================ package text import ( "fmt" "io" "regexp" "github.com/segmentio/textio" "github.com/fastly/go-fastly/v15/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 // be used as a prefix to each line, useful for indentation. func PrintService(out io.Writer, prefix string, s *fastly.Service) { out = textio.NewPrefixWriter(out, prefix) if s.ServiceID != nil { fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(s.ServiceID)) } if s.Name != nil { fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(s.Name)) } if s.Type != nil { fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(s.Type)) } if s.Comment != nil { fmt.Fprintf(out, "Comment: %s\n", fastly.ToValue(s.Comment)) } if s.CustomerID != nil { 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: %d\n", fastly.ToValue(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) } } // PrintVersion pretty prints a fastly.Version 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 PrintVersion(out io.Writer, indent string, v *fastly.Version) { out = textio.NewPrefixWriter(out, indent) 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(time.Format)) } if v.UpdatedAt != nil { 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(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)) } ================================================ FILE: pkg/text/service_test.go ================================================ package text_test import ( "bytes" "testing" "github.com/fastly/go-fastly/v15/fastly" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" ) func TestPrintService(t *testing.T) { for _, testcase := range []struct { name string prefix string service *fastly.Service wantOutput string }{ { 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{ 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) { var buf bytes.Buffer text.PrintService(&buf, testcase.prefix, testcase.service) testutil.AssertString(t, testcase.wantOutput, buf.String()) }) } } func TestPrintVersion(t *testing.T) { for _, testcase := range []struct { name string prefix string version *fastly.Version wantOutput string }{ { 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: "\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.PrintVersion(&buf, testcase.prefix, testcase.version) testutil.AssertString(t, testcase.wantOutput, buf.String()) }) } } func TestIsFastlyID(t *testing.T) { for _, testcase := range []struct { name string input string want bool }{ { 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: "invalid characters", input: "GLql1:uzgoC-tEK7bdobt5", want: false, }, } { t.Run(testcase.name, func(t *testing.T) { testutil.AssertBool(t, testcase.want, text.IsFastlyID(testcase.input)) }) } } ================================================ FILE: pkg/text/spinner.go ================================================ 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 } ================================================ FILE: pkg/text/stats.go ================================================ package text import ( "fmt" "io" "maps" "slices" "strings" "time" "github.com/fastly/go-fastly/v15/fastly" ) func PrintUsageTbl(out io.Writer, data *fastly.RegionsUsage) { tbl := NewTable(out) tbl.AddHeader("REGION", "BANDWIDTH", "REQUESTS", "COMPUTE REQUESTS") if data == nil { tbl.Print() return } for _, region := range slices.Sorted(maps.Keys(*data)) { u := (*data)[region] if u == nil { continue } tbl.AddLine(region, fastly.ToValue(u.Bandwidth), fastly.ToValue(u.Requests), fastly.ToValue(u.ComputeRequests)) } tbl.Print() } func PrintUsageByServiceTbl(out io.Writer, data *fastly.ServicesByRegionsUsage) { tbl := NewTable(out) tbl.AddHeader("REGION", "SERVICE", "BANDWIDTH", "REQUESTS", "COMPUTE REQUESTS") if data == nil { tbl.Print() return } for _, region := range slices.Sorted(maps.Keys(*data)) { services := (*data)[region] if services == nil { continue } for _, svcID := range slices.Sorted(maps.Keys(*services)) { u := (*services)[svcID] if u == nil { continue } tbl.AddLine(region, svcID, fastly.ToValue(u.Bandwidth), fastly.ToValue(u.Requests), fastly.ToValue(u.ComputeRequests)) } } tbl.Print() } func PrintDomainInspectorTbl(out io.Writer, resp *fastly.DomainInspector) { if resp.Meta != nil { if resp.Meta.Start != nil { fmt.Fprintf(out, "Start: %s\n", *resp.Meta.Start) } if resp.Meta.End != nil { fmt.Fprintf(out, "End: %s\n", *resp.Meta.End) } fmt.Fprintln(out, "---") } dimKeys := domainDimensionKeys(resp.Data) header := make([]any, 0, len(dimKeys)+5) for _, k := range dimKeys { header = append(header, strings.ToUpper(k)) } header = append(header, "TIMESTAMP", "REQUESTS", "BANDWIDTH", "EDGE REQUESTS", "EDGE HIT RATIO") tbl := NewTable(out) tbl.AddHeader(header...) for _, d := range resp.Data { for _, v := range d.Values { row := make([]any, 0, len(dimKeys)+5) for _, k := range dimKeys { row = append(row, fastly.ToValue(d.Dimensions[k])) } ts := "" if v.Timestamp != nil { ts = time.Unix(int64(*v.Timestamp), 0).UTC().String() //nolint:gosec } row = append(row, ts, fastly.ToValue(v.Requests), fastly.ToValue(v.Bandwidth), fastly.ToValue(v.EdgeRequests), fmt.Sprintf("%.4f", fastly.ToValue(v.EdgeHitRatio))) tbl.AddLine(row...) } } tbl.Print() if resp.Meta != nil && resp.Meta.NextCursor != nil { fmt.Fprintf(out, "Next cursor: %s\n", *resp.Meta.NextCursor) } } func PrintOriginInspectorTbl(out io.Writer, resp *fastly.OriginInspector) { if resp.Meta != nil { if resp.Meta.Start != nil { fmt.Fprintf(out, "Start: %s\n", *resp.Meta.Start) } if resp.Meta.End != nil { fmt.Fprintf(out, "End: %s\n", *resp.Meta.End) } fmt.Fprintln(out, "---") } dimKeys := originDimensionKeys(resp.Data) header := make([]any, 0, len(dimKeys)+5) for _, k := range dimKeys { header = append(header, strings.ToUpper(k)) } header = append(header, "TIMESTAMP", "RESPONSES", "STATUS 2XX", "STATUS 4XX", "STATUS 5XX") tbl := NewTable(out) tbl.AddHeader(header...) for _, d := range resp.Data { for _, v := range d.Values { row := make([]any, 0, len(dimKeys)+5) for _, k := range dimKeys { row = append(row, d.Dimensions[k]) } ts := "" if v.Timestamp != nil { ts = time.Unix(int64(*v.Timestamp), 0).UTC().String() //nolint:gosec } row = append(row, ts, fastly.ToValue(v.Responses), fastly.ToValue(v.Status2xx), fastly.ToValue(v.Status4xx), fastly.ToValue(v.Status5xx)) tbl.AddLine(row...) } } tbl.Print() if resp.Meta != nil && resp.Meta.NextCursor != nil { fmt.Fprintf(out, "Next cursor: %s\n", *resp.Meta.NextCursor) } } func domainDimensionKeys(data []*fastly.DomainData) []string { seen := make(map[string]struct{}) for _, d := range data { for k := range d.Dimensions { seen[k] = struct{}{} } } return slices.Sorted(maps.Keys(seen)) } func originDimensionKeys(data []*fastly.OriginData) []string { seen := make(map[string]struct{}) for _, d := range data { for k := range d.Dimensions { seen[k] = struct{}{} } } return slices.Sorted(maps.Keys(seen)) } ================================================ FILE: pkg/text/table.go ================================================ package text import ( "fmt" "io" "strings" "text/tabwriter" ) var ( lineStyle = Reset headerStyle = Bold ) // Table wraps an instance of a tabwriter and provides helper methods to easily // create a table, add a header, add rows and print to the writer. type Table struct { writer *tabwriter.Writer } // NewTable constructs a new Table. func NewTable(w io.Writer) *Table { return &Table{ writer: tabwriter.NewWriter(w, 0, 2, 2, ' ', 0), } } // AddLine writes a new row to the table. func (t *Table) AddLine(args ...any) { var b strings.Builder for i := range args { _, _ = b.WriteString(lineStyle(`%v`)) if i+1 != len(args) { _, _ = b.WriteString("\t") } } _, _ = b.WriteString("\n") fmt.Fprintf(t.writer, b.String(), args...) } // AddHeader writes a table header line. func (t *Table) AddHeader(args ...any) { var b strings.Builder for i := range args { _, _ = b.WriteString(headerStyle(`%s`)) if i+1 != len(args) { _, _ = b.WriteString("\t") } } _, _ = b.WriteString("\n") fmt.Fprintf(t.writer, b.String(), args...) } // Print writes the table to the writer. func (t *Table) Print() { _ = t.writer.Flush() } ================================================ FILE: pkg/text/tag.go ================================================ package text import ( "fmt" "io" "github.com/fastly/go-fastly/v15/fastly/apisecurity/operations" ) // PrintOperationTag displays an operation tag. func PrintOperationTag(out io.Writer, tag *operations.OperationTag) { fmt.Fprintf(out, "ID: %s\n", tag.ID) fmt.Fprintf(out, "Name: %s\n", tag.Name) if tag.Description != "" { fmt.Fprintf(out, "Description: %s\n", tag.Description) } if tag.Count > 0 { fmt.Fprintf(out, "Operation Count: %d\n", tag.Count) } if tag.CreatedAt != "" { fmt.Fprintf(out, "Created At: %s\n", tag.CreatedAt) } if tag.UpdatedAt != "" { fmt.Fprintf(out, "Updated At: %s\n", tag.UpdatedAt) } } // PrintOperationTagsTbl displays operation tags in a table format. func PrintOperationTagsTbl(out io.Writer, tags []operations.OperationTag) { tbl := NewTable(out) tbl.AddHeader("ID", "Name", "Description", "Operations", "Created At", "Updated At") if tags == nil { tbl.Print() return } for _, tag := range tags { description := tag.Description if description == "" { description = "-" } tbl.AddLine(tag.ID, tag.Name, description, fmt.Sprintf("%d", tag.Count), tag.CreatedAt, tag.UpdatedAt) } tbl.Print() } ================================================ FILE: pkg/text/text.go ================================================ package text import ( "bufio" "fmt" "io" "os" "strings" "syscall" "github.com/mitchellh/go-wordwrap" "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 = 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 // added to the block for wrapping, which means strings can be declared in the // 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) // // 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. func Wrap(text string, width uint) string { var b strings.Builder s := bufio.NewScanner(strings.NewReader(text)) for s.Scan() { line := strings.TrimSpace(s.Text()) if line == "" { continue } _, _ = 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 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 ...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 // reader, trimming writespace. The received line is passed to the validators, // and if any of them return a non-nil error, the error is printed to the // writer, and the input process happens again. Otherwise, the line is returned // to the caller. // // Input is intended to be used to take interactive input from the user. func Input(w io.Writer, prefix string, r io.Reader, validators ...func(string) error) (string, error) { s := bufio.NewScanner(r) outer: for { fmt.Fprint(w, Bold(prefix)) if ok := s.Scan(); !ok { return "", s.Err() } line := strings.TrimSpace(s.Text()) for _, validate := range validators { if err := validate(line); err != nil { fmt.Fprintln(w, err.Error()) continue outer } } return line, nil } } // 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) { if !IsStdin(r) { return Input(w, prefix, r, validators...) } read := func() (string, error) { fmt.Fprint(w, Bold(prefix)) // 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 } return string(p), nil } outer: for { line, err := read() if err != nil { return "", err } line = strings.TrimSpace(line) for _, validate := range validators { if err := validate(line); err != nil { fmt.Fprintln(w, err.Error()) continue outer } } return line, nil } } // 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. // Deprecation warnings are always written to stderr. func Deprecated(format string, args ...any) { prefix, suffix, txt := ParseBreaks(format) if suffix == 0 { suffix++ } fmt.Fprintf(os.Stderr, 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 ...any) { prefix, suffix, txt := ParseBreaks(format) if suffix == 0 { suffix++ } fmt.Fprintf(w, WrapString(BoldRed, "ERROR", txt, prefix, suffix), 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 ...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 ...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 `intro` and a `description`. Emphasis is placed on the // `description` using Bold(). For example: // // 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. // // 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, " ") } ================================================ FILE: pkg/text/text_test.go ================================================ package text_test import ( "bytes" "errors" "io" "os" "strconv" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" ) func TestInput(t *testing.T) { for _, testcase := range []struct { name string in string prefix string validators []func(string) error wantOutput string wantResult string }{ { name: "empty", in: "\n", prefix: "Press enter ", wantOutput: "Press enter ", wantResult: "", }, { name: "single letter", in: "a\n", prefix: "> ", wantOutput: "> ", wantResult: "a", }, { name: "single letter with whitespace", in: " a \n", prefix: "> ", wantOutput: "> ", wantResult: "a", }, { name: "nonempty validator", in: "\n\nFINE\n", prefix: "Tell me something: ", validators: []func(string) error{ func(s string) error { if s == "" { return errors.New("nothing isn't something") } return nil }, }, wantOutput: "Tell me something: nothing isn't something\nTell me something: nothing isn't something\nTell me something: ", wantResult: "FINE", }, } { t.Run(testcase.name, func(t *testing.T) { var buf bytes.Buffer result, err := text.Input(&buf, testcase.prefix, strings.NewReader(testcase.in), testcase.validators...) testutil.AssertNoError(t, err) testutil.AssertString(t, testcase.wantOutput, buf.String()) testutil.AssertString(t, testcase.wantResult, result) buf.Reset() result, err = text.InputSecure(&buf, testcase.prefix, strings.NewReader(testcase.in), testcase.validators...) testutil.AssertNoError(t, err) testutil.AssertString(t, testcase.wantOutput, buf.String()) testutil.AssertString(t, testcase.wantResult, result) }) } } 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, ...any) format string args []any want string }{ { name: "Error", f: text.Error, format: "Test string %d.", 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: []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) { var buf bytes.Buffer testcase.f(&buf, testcase.format, testcase.args...) if want, have := testcase.want, buf.String(); want != have { t.Error(cmp.Diff(want, have)) } }) } } 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) } }) } } func TestDeprecated(t *testing.T) { // Save original stderr and create pipe to capture output origStderr := os.Stderr r, w, _ := os.Pipe() os.Stderr = w // Call Deprecated text.Deprecated("Test warning message %d.", 123) // Restore stderr and close writer os.Stderr = origStderr w.Close() // Read captured output var buf bytes.Buffer if _, err := io.Copy(&buf, r); err != nil { t.Fatalf("failed to read from pipe: %v", err) } output := buf.String() // Verify output contains expected text want := "DEPRECATED: Test warning message 123.\n" if output != want { t.Errorf("Deprecated output mismatch:\ngot: %q\nwant: %q", output, want) } } ================================================ FILE: pkg/text/threshold.go ================================================ package text import ( "fmt" "io" "time" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/thresholds" ) // PrintThreshold displays a single threshold. func PrintThreshold(out io.Writer, thresholdToPrint *thresholds.Threshold) { fmt.Fprintf(out, "Signal: %s\n", thresholdToPrint.Signal) fmt.Fprintf(out, "Name: %s\n", thresholdToPrint.Name) fmt.Fprintf(out, "Action: %s\n", thresholdToPrint.Action) fmt.Fprintf(out, "Do Not Notify: %t\n", thresholdToPrint.DontNotify) fmt.Fprintf(out, "Duration: %d\n", thresholdToPrint.Duration) fmt.Fprintf(out, "Enabled: %t\n", thresholdToPrint.Enabled) fmt.Fprintf(out, "Interval: %d\n", thresholdToPrint.Interval) fmt.Fprintf(out, "Limit: %d\n", thresholdToPrint.Limit) } // PrintThresholdTbl prints a table of thresholds. func PrintThresholdTbl(out io.Writer, thresholdsToPrint []thresholds.Threshold) { tbl := NewTable(out) tbl.AddHeader("Signal", "Name", "ID", "Action", "Enabled", "Do Not Notify", "Limit", "Interval", "Duration", "Created At") if thresholdsToPrint == nil { tbl.Print() return } for _, ts := range thresholdsToPrint { tbl.AddLine( ts.Signal, ts.Name, ts.ThresholdID, ts.Action, ts.Enabled, ts.DontNotify, ts.Limit, ts.Interval, ts.Duration, ts.CreatedAt.UTC().Format(time.RFC3339), ) } tbl.Print() } ================================================ FILE: pkg/text/virtualpatch.go ================================================ package text import ( "fmt" "io" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/virtualpatches" ) // PrintVirtualPatch displays a single virtual patch. func PrintVirtualPatch(out io.Writer, virtualPatchToPrint *virtualpatches.VirtualPatch) { fmt.Fprintf(out, "ID: %s\n", virtualPatchToPrint.ID) fmt.Fprintf(out, "Description: %s\n", virtualPatchToPrint.Description) fmt.Fprintf(out, "Enabled: %t\n", virtualPatchToPrint.Enabled) fmt.Fprintf(out, "Mode: %s\n", virtualPatchToPrint.Mode) } // PrintVirtualPatchTbl prints a table of virtual patches. func PrintVirtualPatchTbl(out io.Writer, virtualPatchesToPrint []virtualpatches.VirtualPatch) { tbl := NewTable(out) tbl.AddHeader("ID", "Description", "Enabled", "Mode") if virtualPatchesToPrint == nil { tbl.Print() return } for _, vp := range virtualPatchesToPrint { tbl.AddLine(vp.ID, vp.Description, vp.Enabled, vp.Mode) } tbl.Print() } ================================================ FILE: pkg/text/workspace.go ================================================ package text import ( "fmt" "io" "strings" "github.com/fastly/cli/pkg/time" "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces" ) // PrintWorkspace displays a workspace. func PrintWorkspace(out io.Writer, workspaceToPrint *workspaces.Workspace) { fmt.Fprintf(out, "ID: %s\n", workspaceToPrint.WorkspaceID) fmt.Fprintf(out, "Name: %s\n", workspaceToPrint.Name) fmt.Fprintf(out, "Description: %s\n", workspaceToPrint.Description) fmt.Fprintf(out, "Mode: %s\n", workspaceToPrint.Mode) fmt.Fprint(out, "Attack Signal Thresholds:\n") fmt.Fprintf(out, " Immediate: %t\n", workspaceToPrint.AttackSignalThresholds.Immediate) fmt.Fprintf(out, " One Minute: %d\n", workspaceToPrint.AttackSignalThresholds.OneMinute) fmt.Fprintf(out, " Ten Minutes: %d\n", workspaceToPrint.AttackSignalThresholds.TenMinutes) fmt.Fprintf(out, " One Hour: %d\n", workspaceToPrint.AttackSignalThresholds.OneHour) if len(workspaceToPrint.ClientIPHeaders) != 0 { fmt.Fprintf(out, "Client IP Headers: %s\n", strings.Join(workspaceToPrint.ClientIPHeaders, ", ")) } if workspaceToPrint.DefaultBlockingResponseCode > 0 { fmt.Fprintf(out, "Default Blocking Response Code: %d\n", workspaceToPrint.DefaultBlockingResponseCode) } if workspaceToPrint.DefaultRedirectURL != "" { fmt.Fprintf(out, "Default Redirect URL: %s\n", workspaceToPrint.DefaultRedirectURL) } if workspaceToPrint.IPAnonymization != "" { fmt.Fprintf(out, "IP Anonymization: %s\n", workspaceToPrint.IPAnonymization) } fmt.Fprintf(out, "Updated (UTC): %s\n", workspaceToPrint.UpdatedAt.UTC().Format(time.Format)) } // PrintWorkspaceTbl displays workspaces in a table format. func PrintWorkspaceTbl(out io.Writer, workspacesToPrint []workspaces.Workspace) { tbl := NewTable(out) tbl.AddHeader("ID", "Name", "Description", "Mode", "Created At") if workspacesToPrint == nil { tbl.Print() return } for _, workspaceToPrint := range workspacesToPrint { tbl.AddLine(workspaceToPrint.WorkspaceID, workspaceToPrint.Name, workspaceToPrint.Description, workspaceToPrint.Mode, workspaceToPrint.CreatedAt) } tbl.Print() } ================================================ FILE: pkg/threadsafe/doc.go ================================================ // Package threadsafe contains functions and objects for handling thread-safe // code. package threadsafe ================================================ FILE: pkg/threadsafe/threadsafe.go ================================================ 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() } ================================================ FILE: pkg/time/doc.go ================================================ // Package time contains helper abstractions for working with time formats. package time ================================================ FILE: pkg/time/time.go ================================================ 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" ================================================ FILE: pkg/undo/doc.go ================================================ // Package undo contains abstractions for working with a stack of state changes. package undo ================================================ FILE: pkg/undo/undo.go ================================================ 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()) } } } ================================================ FILE: pkg/useragent/doc.go ================================================ // Package useragent contains variables for managing the User-Agent string. package useragent ================================================ FILE: pkg/useragent/useragent.go ================================================ package useragent import ( "fmt" "github.com/fastly/cli/pkg/revision" ) // Name is the user agent which we report in all HTTP requests. var Name = fmt.Sprintf("%s/%s", "FastlyCLI", revision.AppVersion) func SetExtension(extension string) { Name = fmt.Sprintf("%s, %s", Name, extension) } ================================================ FILE: scripts/config.sh ================================================ #!/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 ================================================ FILE: scripts/documentation.sh ================================================ #!/usr/bin/env bash set -e $1 help --format json > dist/usage.json ================================================ FILE: scripts/go-test-cache/go.mod ================================================ module go-test-cache go 1.20 ================================================ FILE: scripts/go-test-cache/main.go ================================================ // 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) } ================================================ FILE: scripts/scaffold-category.sh ================================================ #!/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 ================================================ FILE: scripts/scaffold-update-interfaces.sh ================================================ #!/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 ================================================ FILE: scripts/scaffold.sh ================================================ #!/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 ================================================ FILE: scripts/tags.sh ================================================ #!/usr/bin/env bash set -e # credit: https://github.com/cli/cli/blob/trunk/script/changelog function previous_tag() { current_tag="$(git describe --tags HEAD^ --abbrev=0)" start_ref="HEAD" # Find the previous release on the same branch, skipping prereleases if the # current tag is a full release previous_tag="" while [[ -z $previous_tag || ( $previous_tag == *-* && $current_tag != *-* ) ]]; do previous_tag="$(git describe --tags "$start_ref"^ --abbrev=0)" start_ref="$previous_tag" done echo $previous_tag } ================================================ FILE: tools/go.mod ================================================ module github.com/fastly/cli/tools go 1.26.2 tool ( github.com/goreleaser/goreleaser/v2 github.com/ofabry/go-callvis ) require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.2 // indirect charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/kms v1.31.0 // indirect cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect cloud.google.com/go/storage v1.62.1 // indirect code.gitea.io/sdk/gitea v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.4 // 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.21.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.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/security/keyvault/azkeys v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest v0.11.30 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect github.com/Azure/go-autorest/logger v0.2.2 // indirect github.com/Azure/go-autorest/tracing v0.6.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // 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.4.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/anchore/go-macholibre v0.1.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atc0005/go-teams-notify/v2 v2.14.0 // indirect github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2 // indirect github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.51.1 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect github.com/aws/smithy-go v1.25.1 // indirect github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/blacktop/go-dwarf v1.0.14 // indirect github.com/blacktop/go-macho v1.1.272 // 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.2.0 // indirect github.com/caarlos0/env/v11 v11.4.1 // indirect github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect github.com/caarlos0/go-version v0.2.2 // indirect github.com/caarlos0/log v0.6.0 // 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/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/fang v1.0.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // 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.2 // indirect github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.4.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.7 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flopp/go-findfont v0.1.0 // indirect github.com/fogleman/gg v1.3.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/github/smimesign v0.2.0 // 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.9.0 // indirect github.com/go-git/go-git/v5 v5.19.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.25.0 // indirect github.com/go-openapi/errors v0.22.7 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/loads v0.23.3 // indirect github.com/go-openapi/runtime v0.29.5 // indirect github.com/go-openapi/spec v0.22.4 // indirect github.com/go-openapi/strfmt v0.26.2 // indirect github.com/go-openapi/swag v0.26.0 // indirect github.com/go-openapi/swag/cmdutils v0.26.0 // indirect github.com/go-openapi/swag/conv v0.26.0 // indirect github.com/go-openapi/swag/fileutils v0.26.0 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/go-openapi/swag/jsonutils v0.26.0 // indirect github.com/go-openapi/swag/loading v0.26.0 // indirect github.com/go-openapi/swag/mangling v0.26.0 // indirect github.com/go-openapi/swag/netutils v0.26.0 // indirect github.com/go-openapi/swag/stringutils v0.26.0 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-openapi/validate v0.25.2 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-graphviz v0.2.10 // 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.3.1 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/go-containerregistry v0.21.5 // indirect github.com/google/go-github/v84 v84.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/ko v0.18.2-0.20260407063826-ae9c7272d7de // indirect github.com/google/rpmpack v0.7.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/goreleaser/chglog v0.7.4 // indirect github.com/goreleaser/fileglob v1.4.0 // indirect github.com/goreleaser/go-shellwords v1.0.13 // indirect github.com/goreleaser/goreleaser/v2 v2.15.4 // indirect github.com/goreleaser/nfpm/v2 v2.46.3 // indirect github.com/goreleaser/quill v0.0.0-20260418030907-a259ef5caf05 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // 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.8 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/ipfs/bbloom v0.1.0 // indirect github.com/ipfs/boxo v0.39.0 // indirect github.com/ipfs/go-block-format v0.2.3 // indirect github.com/ipfs/go-cid v0.6.1 // indirect github.com/ipfs/go-datastore v0.9.1 // 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-ipld-cbor v0.2.1 // indirect github.com/ipfs/go-ipld-format v0.6.3 // indirect github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-log/v2 v2.9.2 // indirect github.com/ipfs/go-metrics-interface v0.3.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-mastodon v0.0.11 // indirect github.com/mattn/go-runewidth v0.0.23 // 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/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/modelcontextprotocol/registry v1.7.9 // indirect github.com/mr-tron/base58 v1.3.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango-cobra v1.3.0 // indirect github.com/muesli/mango-pflag v0.2.0 // indirect github.com/muesli/roff v0.1.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.3.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/ofabry/go-callvis v0.7.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pjbgf/sha1cd v0.6.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.20231129105047-37766d95467a // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // 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.11.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/cosign/v3 v3.0.6 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect github.com/sigstore/rekor v1.5.1 // indirect github.com/sigstore/rekor-tiles/v2 v2.2.1 // indirect github.com/sigstore/sigstore v1.10.5 // indirect github.com/sigstore/sigstore-go v1.1.4 // indirect github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/slack-go/slack v0.23.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect github.com/transparency-dev/formats v0.1.0 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/tar-split v0.12.3 // indirect github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect github.com/whyrusleeping/cbor-gen v0.3.1 // 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 v1.46.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect gocloud.dev v0.45.0 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/image v0.40.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.279.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/grpc v1.81.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect lukechampine.com/blake3 v1.4.1 // indirect sigs.k8s.io/kind v0.31.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect ) ================================================ FILE: tools/go.sum ================================================ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= 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/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= 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.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= 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/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 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.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= 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.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 h1:Q9R3utmFg9K1B4OYtAZ7ZUUvIUdzQt7G2MN5Hi/d670= github.com/Azure/go-autorest/autorest/azure/cli v0.4.7/go.mod h1:bVrAueELJ0CKLBpUHDIvD516TwmHmzqwCpvONWRsw3s= github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= github.com/Azure/go-autorest/autorest/date v0.3.1 h1:o9Z8Jyt+VJJTCZ/UORishuHOusBwolhjokt9s5k8I4w= github.com/Azure/go-autorest/autorest/date v0.3.1/go.mod h1:Dz/RDmXlfiFFS/eW+b/xMUSFs1tboPVy6UjgADToWDM= 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 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= 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/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/logger v0.2.2 h1:hYqBsEBywrrOSW24kkOCXRcKfKhK76OzLTfF+MYDE2o= github.com/Azure/go-autorest/logger v0.2.2/go.mod h1:I5fg9K52o+iuydlWfa9T5K6WFos9XYr9dYTFzpqgibw= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsful9L/2nyR0= github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= 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.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/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/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 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/anchore/go-macholibre v0.1.0 h1:qHbdusBZNcZM/uuKf1Psa9xxAFSoyRTps8GW9gpJgsg= github.com/anchore/go-macholibre v0.1.0/go.mod h1:eu0gbwaZ+ocVFJLePdmPPDKU8MboV1MKsUCr36Ckd5s= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/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.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18 h1:9XFUd2lkr7VrbE4Qtrhm7AtNhGgZeGFI5QLZtQIflj8= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.18/go.mod h1:trImuKdWelQIJALvyGj6sKolJ1W8t628JOoTdDGVL9Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2 h1:rHEW02JFJUV2/ttjzyPIvbD0YraqpyU2w6m6DfQUmdg= github.com/aws/aws-sdk-go-v2/service/ecr v1.57.2/go.mod h1:gNS8pNht4VMzPd4UtQUL3NTUQbjEPLLmb9MqmqrqsCM= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.15 h1:nW/zPIjkAgHV1xv8NHdLQtGMoHVj2toMj8/H6SMqjVw= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.15/go.mod h1:FXDXpYy2PKdkQQr4ERMoRzVKcga0O/hmtRbMaQSpe8U= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= github.com/aws/aws-sdk-go-v2/service/kms v1.51.1 h1:zuSf4olLKZW8cF/W9Y5wvGT+/0raY/3kVp49KsGs0QY= github.com/aws/aws-sdk-go-v2/service/kms v1.51.1/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 h1:JFWXO6QPihCknDdnL6VaQE57km4ZKheHIGd9YiOGcTo= github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0/go.mod h1:046/oLyFlYdAghYQE2yHXi/E//VM5Cf3/dFmA+3CZ0c= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/blacktop/go-dwarf v1.0.14 h1:OjmzfSgg/qAKckn2tWFebcgKgJ7HOqCj7bS+CiE1lrY= github.com/blacktop/go-dwarf v1.0.14/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl9N6VQ9DSTo= github.com/blacktop/go-macho v1.1.272 h1:5FDmY3bDkGj7yCdNWke0zKQC1vXmvwV41gGQqG0wCeM= github.com/blacktop/go-macho v1.1.272/go.mod h1:Hc5E2Lvt/U1VT+jOxr1O5l/LNFJeMYK4eAmDfazTiGc= 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.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.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-version v0.2.2 h1:5r+nlrg4H2wOVwWjqRqRRIRbZ7ytRmjC9xoMIP0a5kQ= github.com/caarlos0/go-version v0.2.2/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= github.com/caarlos0/log v0.6.0 h1:iS+oZ7DB8wpJxOjA4+BHkZQtrx8sMu8AYNZVQukmEtw= github.com/caarlos0/log v0.6.0/go.mod h1:iAv3N3ZkiEQUmZ8fGdD8bMA4zq6jMSlnz9D87333Gi0= github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= 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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= 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.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/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/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= github.com/dghubble/sling v1.4.2 h1:vs1HIGBbSl2SEALyU+irpYFLZMfc49Fp+jYryFebQjM= github.com/dghubble/sling v1.4.2/go.mod h1:o0arCOz0HwfqYQJLrRtqunaWOn4X6jxE/6ORKRpVTD4= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i0Ee9EP1TlTZ9w+UncBY6HSmsFZa0= github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= 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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/distribution/v3 v3.1.0 h1:u1v788HreKTLGdNY6s7px8Exgrs9mZ9UrCDjSrpCM8g= github.com/distribution/distribution/v3 v3.1.0/go.mod h1:73BuF5/ziMHNVt7nnL1roYpH4Eg/FgUlKZm3WryIx/o= 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 v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.7 h1:jaPIxEIDz5bQeghNAdzz0ETwMMnM4vzjZlxz3pWP4JA= github.com/docker/docker-credential-helpers v0.9.7/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 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 v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 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/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= 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/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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.25.0 h1:EnjAq1yO8wEO9HbPmY8vLPEIkdZuuFhCAKBPvCB7bCs= github.com/go-openapi/analysis v0.25.0/go.mod h1:5WFTRE43WLkPG9r9OtlMfqkkvUTYLVVCIxLlEpyF8kE= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/runtime v0.29.5 h1:uc5+/TtqLIfDBTUxnF3uppoGMt+9DzonwUWsviINlrY= github.com/go-openapi/runtime v0.29.5/go.mod h1:D9IUbWccdYv+km8QwmAm90FZvDcQk47vP2Y7y5as/D8= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/strfmt v0.26.2 h1:ysjheCh4i1rmFEo2LanhELDNucNzfWTZhUDKgWWPaFM= github.com/go-openapi/strfmt v0.26.2/go.mod h1:fXh1e449cyUn2NYuz+wb3wARBUdMl7qPEZwX00nqivY= github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-openapi/testify/enable/yaml/v2 v2.5.0 h1:3hZD1fwydvCx/cc1R2uYNQirHqf2s6lqpKV3FcNTURA= github.com/go-openapi/testify/enable/yaml/v2 v2.5.0/go.mod h1:TvDZKBH7ZbMaF3EqH2AwTvNQCmzyZq8K1agRjf1B+Nk= github.com/go-openapi/testify/v2 v2.5.0 h1:UOCr63aAsMIDydZbZGqo5Ev01D4eydItRbekDuZMJLw= github.com/go-openapi/testify/v2 v2.5.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= 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-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g= github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-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/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= 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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.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.4/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.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/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-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA= github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/ko v0.18.2-0.20260407063826-ae9c7272d7de h1:vl2sUQed6LbK69Cws/LwoeSw7NYotn089jA08Li9wgQ= github.com/google/ko v0.18.2-0.20260407063826-ae9c7272d7de/go.mod h1:HjQh5NsrhK9hhGfcZiRV0Lr97Oo7s4RZnboONUapRUw= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= 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/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= 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.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= 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/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/goreleaser/chglog v0.7.4 h1:3pnNt/XCrUcAOq+KC91Azlgp5CRv4GHo1nl8Aws7OzI= github.com/goreleaser/chglog v0.7.4/go.mod h1:dTVoZZagTz7hHdWaZ9OshHntKiF44HbWIHWxYJQ/h0Y= github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs= github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8= github.com/goreleaser/go-shellwords v1.0.13 h1:ivvhC/RvUyud74c0urb1ZGYWPYGibY5QiFzcwHCege4= github.com/goreleaser/go-shellwords v1.0.13/go.mod h1:UtDFSSvW7wQL/4jmyzZbuP6HfI6R+oSm0v63cs61oDw= github.com/goreleaser/goreleaser/v2 v2.15.4 h1:nQljy2KLHhzm3aGuL7IAQ2sqIcF0t1hcYiNy1EzUiqE= github.com/goreleaser/goreleaser/v2 v2.15.4/go.mod h1:1N0vuARdSLTJX03iWLvTM8avidylXBDXIWs+hPHSdVw= github.com/goreleaser/nfpm/v2 v2.46.3 h1:sMNcGjbVnABe33avsFo++WwKubY8msP5j9ieulX09VI= github.com/goreleaser/nfpm/v2 v2.46.3/go.mod h1:wYKICOnvMALZcoGM20TcP9QuIV+Fb9fmLH364HyTvDQ= github.com/goreleaser/quill v0.0.0-20260418030907-a259ef5caf05 h1:iillcQW89rysvFhPQKQEKfQNcQi+Yu+f2DH1ZF7i6kQ= github.com/goreleaser/quill v0.0.0-20260418030907-a259ef5caf05/go.mod h1:gDef5vWJO4JWWFeWCnJ+hQnJsAEQWazvzmh39qd5Ods= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= 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.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= 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-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 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.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/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/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= 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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 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.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/ipfs/bbloom v0.1.0 h1:nIWwfIE3AaG7RCDQIsrUonGCOTp7qSXzxH7ab/ss964= github.com/ipfs/bbloom v0.1.0/go.mod h1:lDy3A3i6ndgEW2z1CaRFvDi5/ZTzgM1IxA/pkL7Wgts= github.com/ipfs/boxo v0.39.0 h1:u9jLf5pLx5SWROXjHtj8VMvv+iDlMbiTyZ/vVTQ4VhI= github.com/ipfs/boxo v0.39.0/go.mod h1:k9YCvMjytFguMHndEiGdCGMMj4b7CkdOT44vtgAxOdk= github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= github.com/ipfs/go-datastore v0.9.1 h1:67Po2epre/o0UxrmkzdS9ZTe2GFGODgTd2odx8Wh6Yo= github.com/ipfs/go-datastore v0.9.1/go.mod h1:zi07Nvrpq1bQwSkEnx3bfjz+SQZbdbWyCNvyxMh9pN0= github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 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.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= github.com/ipfs/go-ipld-format v0.6.3 h1:9/lurLDTotJpZSuL++gh3sTdmcFhVkCwsgx2+rAh4j8= github.com/ipfs/go-ipld-format v0.6.3/go.mod h1:74ilVN12NXVMIV+SrBAyC05UJRk0jVvGqdmrcYZvCBk= 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.9.2 h1:O/5BB0elpkRILvT24rCJ5976wWd7u0nJ436T3rdYdc4= github.com/ipfs/go-log/v2 v2.9.2/go.mod h1:RziRwwXWhndlk8L75RnEe0zeAYaq2heKtEMc3jqUov0= github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 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/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= 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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/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.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 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.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-mastodon v0.0.11 h1:Zcvc/8EHpf3os1mwAuUUB5es5VnfVdAeb4ed6ByJnCY= github.com/mattn/go-mastodon v0.0.11/go.mod h1:0DcwYEkqigrvknMvjmfKXLP0vYyeYm+vBdUOvoHcczg= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 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/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= 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.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 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-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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/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/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modelcontextprotocol/registry v1.7.9 h1:vpPfx2A2egjhm6YlbwfkX8NkR2N0S2eYmvYXI8bXaBs= github.com/modelcontextprotocol/registry v1.7.9/go.mod h1:y03zY98e+REsiCaj1sUKzXbk3qEp++Y3gzAnV83wrNs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= 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.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k= github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 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.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= 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.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 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/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= 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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runc v1.2.8 h1:RnEICeDReapbZ5lZEgHvj7E9Q3Eex9toYmaGBsbvU5Q= github.com/opencontainers/runc v1.2.8/go.mod h1:cC0YkmZcuvr+rtBZ6T7NBoVbMGNAdLa/21vIElJDOzI= 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/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 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.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 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/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= 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/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/polydawn/refmt v0.89.1-0.20231129105047-37766d95467a h1:cgqrm0F3zwf9IPzca7xN4w+Zy6MC9ZkPvAC8QEWa/iQ= github.com/polydawn/refmt v0.89.1-0.20231129105047-37766d95467a/go.mod h1:ocZfO/tLSHqfScRDNTJbAJR1by4D1lewauX9OwTaPuY= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= 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/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= 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/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/sigstore/cosign/v3 v3.0.6 h1:k8XaUd9pmLknHBst/v0rUGHVdB4D9cfaBmWUaMAOocE= github.com/sigstore/cosign/v3 v3.0.6/go.mod h1:ckLRkVecfUCYxL8isHODY9lwyKmDaRCPn00p6yFxHg0= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= github.com/sigstore/rekor v1.5.1 h1:Ca1egHRWRuDvXV4tZu9aXEXc3Gej9FG+HKeapV9OAMQ= github.com/sigstore/rekor v1.5.1/go.mod h1:gTLDuZuo3SyQCuZvKqwRPA79Qo/2rw39/WtLP/rZjUQ= github.com/sigstore/rekor-tiles/v2 v2.2.1 h1:UmV1CBQ3SjxxPGpFmwDoOhoIwiKpM2Qm1pU5tPGmvNk= github.com/sigstore/rekor-tiles/v2 v2.2.1/go.mod h1:z8n6l6oidpaLjjE6rJERuQqY9X38ulnHZCXyL+DEL7U= github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5 h1:+9C6CUkv+J4iT67Lx+H1EGBfAdoAHqXumHadeIj9jA4= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5 h1:BpQx6AhjwIN9LmlO4ypkcMcHiWiepgZQGSw5U69frHU= github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5 h1:OFwQZgWkB/6J6W5sy3SkXE4pJnhNRnE2cJd8ySXmHpo= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 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.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= 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/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= 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-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY= github.com/transparency-dev/formats v0.1.0 h1:oL0zUFuYUjg8AbtjPMnIRDmjbaHo5jCjEWU5yaNuz0g= github.com/transparency-dev/formats v0.1.0/go.mod h1:d2FibUOHfCMdCe/+/rbKt1IPLBbPTDfwj46kt541/mU= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 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/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 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/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/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 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 v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.digitalxero.dev/go-msix v0.3.1 h1:V5E8PuFkA3Fr3VFYX6pTUutriogYC9sgxIWhzf9sSKw= go.digitalxero.dev/go-msix v0.3.1/go.mod h1:QbUpFs0AUd1zk7e9fy17suiqEAF90TR3jZY+LCI2K+c= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= 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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY= gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/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-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/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.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/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-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= 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.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/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-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/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-20210616094352-59db8d763f22/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-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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/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.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-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-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 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.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= 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.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.62.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.3/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= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=