diff --git a/.github/scripts/gotest.sh b/.github/scripts/gotest.sh new file mode 100755 index 0000000..507fa9f --- /dev/null +++ b/.github/scripts/gotest.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -e + +DEBUG="${DEBUG:-false}" +GOCMD="${GOCMD:-go}" +OUTFILE="${OUTFILE:-}" +XUNIT_OUTFILE="${XUNIT_OUTFILE:-}" +JSON_OUTFILE="${JSON_OUTFILE:-}" +COVERAGE_OUTFILE="${COVERAGE_OUTFILE:-}" + +function echoDebug { + if [[ "${DEBUG}" == true ]]; then + echo "[gotest.sh] $@" + fi +} + +if [[ -n "${OUTFILE}" ]]; then + mkdir -p "$(dirname "${OUTFILE}")" +else + OUTFILE="$(mktemp)" +fi +if [[ -n "${XUNIT_OUTFILE}" ]]; then + mkdir -p "$(dirname "${XUNIT_OUTFILE}")" +fi +if [[ -n "${JSON_OUTFILE}" ]]; then + mkdir -p "$(dirname "${JSON_OUTFILE}")" +fi +if [[ -n "${COVERAGE_OUTFILE}" ]]; then + mkdir -p "$(dirname "${COVERAGE_OUTFILE}")" +fi + +echoDebug "GOCMD: ${GOCMD}" +echoDebug "Raw output file: ${OUTFILE}" +echoDebug "JSON output file: ${JSON_OUTFILE}" +echoDebug "xUnit output file: ${XUNIT_OUTFILE}" +echoDebug "Coverage output file: ${COVERAGE_OUTFILE}" + +exitCodeFile="$(mktemp)" +echo "0" > "${exitCodeFile}" +declare -a modargs +GORACE="-race" +for value in "$@"; do + if [ "$value" = "-norace" ]; then + GORACE="" + elif [ "$value" != "-race" ]; then + modargs+=("$value") + fi +done +modargs+=("$GORACE") + +if [[ -n "${COVERAGE_OUTFILE}" ]]; then + echoDebug "Collecting packages for coverage report..." + coverpkg="" + for pkg in $(go list ./...); do + if [[ -n "${coverpkg}" ]]; then + coverpkg="${coverpkg}," + fi + coverpkg="${coverpkg}${pkg}" + done + modargs+=("-coverpkg=${coverpkg}") + modargs+=("-coverprofile=${COVERAGE_OUTFILE}") +fi + +if [[ -n "${XUNIT_OUTFILE}" ]]; then + # jstemmer/go-junit-report requires verbose output + modargs+=("-v") +fi + +echoDebug "Running ${GOCMD} test ${modargs[*]}" +# Disable log coloring (ANSI codes are invalid xml characters) +(2>&1 DEV_DISABLE_LOG_COLORS=true ${GOCMD} test ${modargs[*]} || echo "$?" > "${exitCodeFile}") | tee "${OUTFILE}" +exitCode="$(cat "${exitCodeFile}")" +echoDebug "Tests Exit Code: $exitCode" + +if [[ -n "${JSON_OUTFILE}" ]]; then + echoDebug "Gernerating JSON test report at: ${JSON_OUTFILE}" + go tool test2json < "${OUTFILE}" > "${JSON_OUTFILE}" +fi + +if [[ -n "${XUNIT_OUTFILE}" ]]; then + echoDebug "Ensuring jstemmer/go-junit-report is installed" + ${GOCMD} install github.com/jstemmer/go-junit-report@v1.0.0 + echoDebug "Generating xUnit test report at: ${XUNIT_OUTFILE}" + go-junit-report < "${OUTFILE}" > "${XUNIT_OUTFILE}" +fi + +echoDebug "Done" +exit "$exitCode" \ No newline at end of file diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml new file mode 100644 index 0000000..959b2d9 --- /dev/null +++ b/.github/workflows/analysis.yml @@ -0,0 +1,41 @@ +name: "Static Analysis" +on: + push: + branches: + - '**' + tags-ignore: + - '**' + pull_request: +jobs: + Static-Check: + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Static Code Analysis + uses: golangci/golangci-lint-action@v5 + with: + version: latest + + + Go-Sec: + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run Gosec Security Scanner + uses: securego/gosec@v2.18.0 + with: + args: -exclude G204,G301,G302,G304,G306,G601,G101 -tests -exclude-dir \.*test\.* ./... \ No newline at end of file diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..c94326e --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,35 @@ +name: "CLA Assistant" +on: + # issue_comment triggers this action on each comment on issues and pull requests + issue_comment: + types: [ created ] + pull_request_target: + types: [ opened, synchronize ] + +jobs: + CLAssistant: + runs-on: ubuntu-latest + steps: + - uses: actions-ecosystem/action-regex-match@v2 + id: sign-or-recheck + with: + text: ${{ github.event.comment.body }} + regex: '\s*(I have read the CLA Document and I hereby sign the CLA)|(recheck)\s*' + + - name: "CLA Assistant" + if: ${{ steps.sign-or-recheck.outputs.match != '' || github.event_name == 'pull_request_target' }} + # Alpha Release + uses: cla-assistant/github-action@v2.3.0 + env: + # Generated and maintained by GitHub + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # JFrog organization secret + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGN_TOKEN }} + with: + path-to-signatures: "signed_clas.json" + path-to-document: "https://jfrog.com/cla/" + remote-organization-name: "jfrog" + remote-repository-name: "jfrog-signed-clas" + # branch should not be protected + branch: "master" + allowlist: bot* \ No newline at end of file diff --git a/.github/workflows/frogbot-scan-pull-request.yml b/.github/workflows/frogbot-scan-pull-request.yml new file mode 100644 index 0000000..1463f72 --- /dev/null +++ b/.github/workflows/frogbot-scan-pull-request.yml @@ -0,0 +1,47 @@ +name: "Frogbot Scan Pull Request" +on: + pull_request_target: + types: [ opened, synchronize ] +permissions: + pull-requests: write + contents: read +jobs: + scan-pull-request: + runs-on: ubuntu-latest + # A pull request needs to be approved before Frogbot scans it. Any GitHub user who is associated with the + # "frogbot" GitHub environment can approve the pull request to be scanned. + environment: frogbot + steps: + - uses: jfrog/frogbot@v2 + env: + JFROG_CLI_LOG_LEVEL: "DEBUG" + # [Mandatory] + # JFrog platform URL (This functionality requires version 3.29.0 or above of Xray) + JF_URL: ${{ secrets.FROGBOT_URL }} + + # [Mandatory if JF_USER and JF_PASSWORD are not provided] + # JFrog access token with 'read' permissions on Xray service + JF_ACCESS_TOKEN: ${{ secrets.FROGBOT_ACCESS_TOKEN }} + + # [Mandatory] + # The GitHub token is automatically generated for the job + JF_GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # [Optional] + # Configure the SMTP server to enable Frogbot to send emails with detected secrets in pull request scans. + # SMTP server URL including should the relevant port: (Example: smtp.server.com:8080) + # JF_SMTP_SERVER: ${{ secrets.JF_SMTP_SERVER }} + + # [Mandatory if JF_SMTP_SERVER is set] + # The username required for authenticating with the SMTP server. + #JF_SMTP_USER: ${{ secrets.JF_SMTP_USER }} + + # [Mandatory if JF_SMTP_SERVER is set] + # The password associated with the username required for authentication with the SMTP server. + # JF_SMTP_PASSWORD: ${{ secrets.JF_SMTP_PASSWORD }} + + # [Optional] + # List of comma separated email addresses to receive email notifications about secrets + # detected during pull request scanning. The notification is also sent to the email set + # in the committer git profile regardless of whether this variable is set or not. + JF_EMAIL_RECEIVERS: "eco-system@jfrog.com" \ No newline at end of file diff --git a/.github/workflows/frogbot-scan-repository.yml b/.github/workflows/frogbot-scan-repository.yml new file mode 100644 index 0000000..6bbc9aa --- /dev/null +++ b/.github/workflows/frogbot-scan-repository.yml @@ -0,0 +1,36 @@ +name: "Frogbot Scan Repository" +on: + workflow_dispatch: + schedule: + # The repository will be scanned once a day at 00:00 GMT. + - cron: "0 0 * * *" +permissions: + contents: write + pull-requests: write + security-events: write +jobs: + scan-repository: + runs-on: ubuntu-latest + strategy: + matrix: + # The repository scanning will be triggered periodically on the following branches. + branch: [ "main" ] + steps: + - uses: jfrog/frogbot@v2 + env: + JFROG_CLI_LOG_LEVEL: "DEBUG" + # [Mandatory] + # JFrog platform URL (This functionality requires version 3.29.0 or above of Xray) + JF_URL: ${{ secrets.FROGBOT_URL }} + + # [Mandatory if JF_USER and JF_PASSWORD are not provided] + # JFrog access token with 'read' permissions on Xray service + JF_ACCESS_TOKEN: ${{ secrets.FROGBOT_ACCESS_TOKEN }} + + # [Mandatory] + # The GitHub token is automatically generated for the job + JF_GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # [Mandatory] + # The name of the branch on which Frogbot will perform the scan + JF_GIT_BASE_BRANCH: ${{ matrix.branch }} \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..beda836 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,41 @@ +name: Go Tests +on: + push: + branches: + - '**' + tags-ignore: + - '**' + # Triggers the workflow on labeled PRs only. + pull_request_target: + types: [labeled] +# Ensures that only the latest commit is running for each PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.ref }} + cancel-in-progress: true +jobs: + GO-tests: + # Go modules doesn't allow passing credentials to a private registry using an HTTP URL. Therefore, the Go tests run against a remote Artifactory server. + if: contains(github.event.pull_request.labels.*.name, 'safe to test') || github.event_name == 'push' + name: Unit tests + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - name: Go Cache + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-${{ hashFiles('**/go.sum') }} + restore-keys: go- + + - name: Run Go tests + run: | + ${GITHUB_WORKSPACE}/.github/scripts/gotest.sh ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff8084b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.tools +bin +*-nogit* \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..082b9bc --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,45 @@ +# Options for analysis running +# More info could be found at https://golangci-lint.run/usage/configuration/ +run: + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 5m + modules-download-mode: readonly + +# List of useful linters could be found at https://github.com/golangci/awesome-go-linters +linters: + disable-all: true + enable: + - errcheck + - exportloopref + # - depguard + # - gci + - gofumpt + - goimports + - gosimple + - govet + - ineffassign + - makezero + - megacheck + - misspell + - noctx + - nolintlint + # - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + # - stylecheck + - unconvert + - unused + - wastedassign + +linters-settings: + staticcheck: + # https://staticcheck.io/docs/options#checks + checks: [ "all","-SA1019","-SA1029" ] + +issues: + exclude-use-default: false + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e470744 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Contribution Guide + +Welcome to the contribution guide for our project! We appreciate your interest in contributing to the development of this project. Below, you will find essential information on local development, running tests, and guidelines for submitting pull requests. + +## Table of Contents + +- [🏠🏗️ Local development](#%EF%B8%8F-local-development) +- [🚦 Running Tests](#-running-tests) +- [📖 Submitting PR Guidelines](#-submitting-pr-guidelines) + + +## 🏠🏗️ Local Development + +To run a command locally, use the following command template: + +```sh +go run github.com/jfrog/workers-cli command [options] [arguments...] +``` + +--- + +This project heavily depends on the following modules: + +- [github.com/jfrog/jfrog-client-go](https://github.com/jfrog/jfrog-client-go) +- [github.com/jfrog/jfrog-cli-core](github.com/jfrog/jfrog-cli-core) + +During local development, if you come across code that needs to be modified in one of the mentioned modules, it is advisable to replace the dependency with a local clone of the module. + +
+Replacing a dependency with a local clone + +--- + +To include this local dependency, For instance, let's assume you wish to modify files from `jfrog-cli-core`, modify the `go.mod` file as follows: + +``` +replace github.com/jfrog/jfrog-cli-core/v2 => /local/path/in/your/machine/jfrog-cli-core +``` + +Afterward, execute `go mod tidy` to ensure the Go module files are updated. Note that Go will automatically adjust the version in the `go.mod` file. + +--- + +
+ + +## 🚦 Running Tests + +To run tests, use the following command: + +``` +./.github/scripts/gotest.sh ./... +``` + +## 📖 Submitting PR Guidelines + +Once you have completed your coding changes, it is recommended to push the modifications made to the other modules first. Once these changes are pushed, you can update this project to resolve dependencies from your GitHub fork or branch. + +
+ +Resolve dependencies from GitHub fork or branch + +--- + +To achieve this, modify the `go.mod` file to point the dependency to your repository and branch, as shown in the example below: + +``` +replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 dev +``` + +Finally, execute `go mod tidy` to update the Go module files. Please note that Go will automatically update the version in the `go.mod` file. + +--- + +
+ +### Before submitting the pull request, ensure: + +- Your changes are covered by `unit` and `integration` tests. If not, please add new tests. +- The code compiles, by running `go vet ./...`. +- To format the code, by running `go fmt ./...`. +- The documentation covers the changes, if not please add and make changes at [The documentation repository](https://github.com/jfrog/documentation) + +### When creating the pull request, ensure: + +- The pull request is on the `main` branch. +- The pull request description describes the changes made. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c009a6c --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +SHELL := /bin/bash +.DEFAULT_GOAL = build +GOCMD = go +export PROJECT_DIR ?= $(CURDIR) +BINARY_CLI = bin +WORKSPACE_ROOT = $(shell cd "${PROJECT_DIR}" && pwd) +TOOLS_DIR := $(CURDIR)/.tools +SCRIPTS_DIR = ${PROJECT_DIR}/.github/scripts +LINKERFLAGS = -s -w +COMPILERFLAGS = all=-trimpath=$(WORKSPACE_ROOT) +GOOS = $(shell go env GOOS) +GOARCH = $(shell go env GOARCH) +GO_SOURCES = $(eval GO_SOURCES := $$(shell find . -type f -name "*.go" | grep -v ".*_mock\.go"))$(GO_SOURCES) + +########## FORMAT ########## + +format: GOFUMPT GOIMPORTS + @${GOFUMPT} -w $(GO_SOURCES) + @${GOIMPORTS} -w -local jfrog.com $(GO_SOURCES) + +GOFUMPT = ${TOOLS_DIR}/gofumpt +GOFUMPT_VERSION = 0.5.0 + +GOFUMPT: + ${GOFUMPT} --version 2>/dev/null | grep ${GOFUMPT_VERSION} || GOBIN=${TOOLS_DIR} $(GOCMD) install mvdan.cc/gofumpt@v${GOFUMPT_VERSION} + +GOIMPORTS = ${TOOLS_DIR}/goimports +GOIMPORTS_VERSION = 0.16.1 + +GOIMPORTS: + GOBIN=${TOOLS_DIR} $(GOCMD) install golang.org/x/tools/cmd/goimports@v${GOIMPORTS_VERSION} + +########## ANALYSE ########## + +GOLANGCI_LINT = ${TOOLS_DIR}/golangci-lint +GOLANGCI_LINT_VERSION = 1.55.2 + +verify: GOLANGCI_LINT + echo $(GO_SOURCES) + $(GOLANGCI_LINT) run --out-format tab --config "${WORKSPACE_ROOT}/.golangci.yml" + +GOLANGCI_LINT: + ${GOLANGCI_LINT} --version 2>/dev/null | grep ${GOLANGCI_LINT_VERSION} || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ${TOOLS_DIR} v${GOLANGCI_LINT_VERSION} + +########## BUILD ########## +prereq:: + ${GOCMD} install github.com/jstemmer/go-junit-report@v1.0.0 + +build:: + go env GOOS GOARCH + go build -ldflags="${LINKERFLAGS}" -gcflags ${COMPILERFLAGS} -o ${BINARY_CLI}/worker-cli-plugin main.go + + +build-install:: build + mkdir -p "${HOME}/.jfrog/plugins/worker/bin" + mv ${BINARY_CLI}/worker-cli-plugin "${HOME}/.jfrog/plugins/worker/bin/worker" + chmod +x "${HOME}/.jfrog/plugins/worker/bin/worker" + +########## TEST ########## + +test: prereq + $(SCRIPTS_DIR)/gotest.sh ./... ${TEST_ARGS} + +.PHONY: $(MAKECMDGOALS) diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..545cf8b --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +- [ ] The pull request is targeting the `main` branch. +- [ ] The code has been validated to compile successfully by running `go vet ./...`. +- [ ] The code has been formatted properly using `go fmt ./...`. +- [ ] All [static analysis checks](https://github.com/jfrog/workers-cli/actions/workflows/analysis.yml) passed. +- [ ] All [tests](https://github.com/jfrog/workers-cli/actions/workflows/unit-tests.yml) have passed. If this feature is not already covered by the tests, new tests have been added. +- [ ] All changes are detailed at the description. if not already covered at [JFrog Documentation](https://github.com/jfrog/documentation), new documentation have been added. + +----- \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b36f61 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +
+ +# jfrog-workers-cli +[![Scanned by Frogbot](https://raw.github.com/jfrog/frogbot/master/images/frogbot-badge.svg)](https://github.com/jfrog/frogbot#readme) + +
+ +| Branch | Status | +|:------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| main | [![Test](https://github.com/jfrog/workers-cli/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/jfrog/workers-cli/actions/workflows/unit-tests.yml?query=branch%main) [![Static Analysis](https://github.com/jfrog/workers-cli/actions/workflows/analysis.yml/badge.svg?branch=main)](https://github.com/jfrog/workers-cli/actions/workflows/analysis.yml) | + +## General + +**jfrog-workers-cli** is a Go module that enable jfrog workers commands in the [JFrog CLI](https://docs.jfrog-applications.jfrog.io/jfrog-applications/jfrog-cli). This module is an Embedded JFrog CLI Plugin and is referenced as a Go module within the [JFrog CLI codebase](https://github.com/jfrog/jfrog-cli). + +## 🫱🏻‍🫲🏼 Contributions + +We welcome contributions from the community through pull requests. To assist in enhancing this project, please review our [Contribution](CONTRIBUTING.md) guide. \ No newline at end of file diff --git a/commands/add_secret_cmd.go b/commands/add_secret_cmd.go new file mode 100644 index 0000000..e9d096b --- /dev/null +++ b/commands/add_secret_cmd.go @@ -0,0 +1,126 @@ +package commands + +import ( + "fmt" + "os" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/workers-cli/model" +) + +type addSecretCommand struct { + ctx *components.Context +} + +func GetAddSecretCommand() components.Command { + return components.Command{ + Name: "add-secret", + Description: "Add a secret to a worker", + Aliases: []string{"as"}, + Flags: []components.Flag{ + components.NewBoolFlag(model.FlagEdit, "Whether to update an existing secret.", components.WithBoolDefaultValue(false)), + }, + Arguments: []components.Argument{ + { + Name: "secret-name", + Description: "The secret name.", + }, + }, + Action: func(c *components.Context) error { + cmd := &addSecretCommand{c} + return cmd.run() + }, + } +} + +func (c *addSecretCommand) run() error { + manifest, err := model.ReadManifest() + if err != nil { + return err + } + + if err = manifest.Validate(); err != nil { + return err + } + + secretName, err := c.getSecretName() + if err != nil { + return err + } + + err = c.checkUpdate(manifest, secretName) + if err != nil { + return err + } + + encryptionKey, err := model.ReadSecretPassword() + if err != nil { + return err + } + + secretValue, err := c.readSecretValue() + if err != nil { + return err + } + + encryptedValue, err := model.EncryptSecret(encryptionKey, secretValue) + if err != nil { + return err + } + + // We back the secrets up so that we do not have to encrypt them again + existingEncryptedSecrets := model.Secrets{} + for k, v := range manifest.Secrets { + existingEncryptedSecrets[k] = v + } + + if err = manifest.DecryptSecrets(encryptionKey); err != nil { + log.Debug("Cannot decrypt existing secrets: %+v", err) + return fmt.Errorf("others secrets are encrypted with a different password, please use the same one") + } else { + manifest.Secrets = existingEncryptedSecrets + } + + if manifest.Secrets == nil { + manifest.Secrets = model.Secrets{secretName: encryptedValue} + } else { + manifest.Secrets[secretName] = encryptedValue + } + + err = manifest.Save() + if err != nil { + return err + } + + log.Info(fmt.Sprintf("Secret '%s' saved", secretName)) + + return nil +} + +func (c *addSecretCommand) getSecretName() (string, error) { + if len(c.ctx.Arguments) < 1 { + return "", plugins_common.WrongNumberOfArgumentsHandler(c.ctx) + } + return c.ctx.Arguments[0], nil +} + +func (c *addSecretCommand) checkUpdate(mf *model.Manifest, secretName string) error { + _, exists := mf.Secrets[secretName] + if exists && !c.ctx.GetBoolFlagValue(model.FlagEdit) { + return fmt.Errorf("%s already exists, use --%s to overwrite", secretName, model.FlagEdit) + } + return nil +} + +func (c *addSecretCommand) readSecretValue() (string, error) { + secretValue, valueInEnv := os.LookupEnv(model.EnvKeyAddSecretValue) + if valueInEnv { + return secretValue, nil + } + + return ioutils.ScanPasswordFromConsole("Value: ") +} diff --git a/commands/add_secret_cmd_test.go b/commands/add_secret_cmd_test.go new file mode 100644 index 0000000..c38513e --- /dev/null +++ b/commands/add_secret_cmd_test.go @@ -0,0 +1,154 @@ +package commands + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +type addSecretAssertFunc func(t *testing.T, manifestBefore, manifestAfter *model.Manifest) + +func TestAddSecretCmd(t *testing.T) { + tests := []struct { + name string + commandArgs []string + secretName string + secretValue string + secretPassword string + wantErr string + assert addSecretAssertFunc + patchManifest func(mf *model.Manifest) + }{ + { + name: "add", + secretName: "sec-1", + secretValue: "val-1", + secretPassword: secretPassword, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{ + "sec-2": mustEncryptSecret(t, "val-2"), + } + }, + assert: assertSecrets(model.Secrets{ + "sec-1": "val-1", + "sec-2": "val-2", + }), + }, + { + name: "add with nil manifest", + secretName: "sec-1", + secretValue: "val-1", + secretPassword: secretPassword, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = nil + }, + assert: assertSecrets(model.Secrets{ + "sec-1": "val-1", + }), + }, + { + name: "add with different password", + secretName: "sec-1", + secretValue: "val-1", + secretPassword: secretPassword, + patchManifest: func(mf *model.Manifest) { + mf.Secrets["sec-2"] = mustEncryptSecret(t, "val-2", "other-password") + }, + wantErr: "others secrets are encrypted with a different password, please use the same one", + }, + { + name: "edit secret", + secretName: "sec-1", + secretValue: "val-1", + secretPassword: secretPassword, + commandArgs: []string{fmt.Sprintf("--%s", model.FlagEdit)}, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{ + "sec-1": mustEncryptSecret(t, "val-1-before"), + } + }, + assert: assertSecrets(model.Secrets{"sec-1": "val-1"}), + }, + { + name: "fails if the secret exists", + secretName: "sec-1", + secretValue: "val-1", + secretPassword: secretPassword, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{ + "sec-1": mustEncryptSecret(t, "val-1-before"), + } + }, + wantErr: "sec-1 already exists, use --edit to overwrite", + }, + { + name: "fails if missing name", + wantErr: "Wrong number of arguments (0).", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workerDir, workerName := prepareWorkerDirForTest(t) + + runCmd := createCliRunner(t, GetInitCommand(), GetAddSecretCommand()) + + err := runCmd("worker", "init", "GENERIC_EVENT", workerName) + require.NoError(t, err) + + if tt.patchManifest != nil { + patchManifest(t, tt.patchManifest) + } + + if tt.secretPassword != "" { + err = os.Setenv(model.EnvKeySecretsPassword, tt.secretPassword) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeySecretsPassword) + }) + } + + if tt.secretValue != "" { + err = os.Setenv(model.EnvKeyAddSecretValue, tt.secretValue) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAddSecretValue) + }) + } + + manifestBefore, err := model.ReadManifest(workerDir) + require.NoError(t, err) + + cmd := []string{"worker", "add-secret"} + cmd = append(cmd, tt.commandArgs...) + + if tt.secretName != "" { + cmd = append(cmd, tt.secretName) + } + + err = runCmd(cmd...) + + if tt.wantErr == "" { + require.NoError(t, err) + manifestAfter, err := model.ReadManifest(workerDir) + assert.NoError(t, err) + tt.assert(t, manifestBefore, manifestAfter) + } else { + assert.EqualError(t, err, tt.wantErr) + } + }) + } +} + +func assertSecrets(wantSecrets model.Secrets) addSecretAssertFunc { + return func(t *testing.T, manifestBefore, manifestAfter *model.Manifest) { + require.Equalf(t, len(wantSecrets), len(manifestAfter.Secrets), "Invalid secrets length") + require.NoError(t, manifestAfter.DecryptSecrets()) + assert.Equalf(t, wantSecrets, manifestAfter.Secrets, "Secrets mismatch") + } +} diff --git a/commands/cmd_input_reader.go b/commands/cmd_input_reader.go new file mode 100644 index 0000000..a23cc9f --- /dev/null +++ b/commands/cmd_input_reader.go @@ -0,0 +1,71 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" +) + +type cmdInputReader struct { + ctx *components.Context +} + +func (c *cmdInputReader) readData() (map[string]any, error) { + if len(c.ctx.Arguments) == 0 { + return nil, fmt.Errorf("missing json payload argument") + } + + // The input should always be the last argument + jsonPayload := c.ctx.Arguments[len(c.ctx.Arguments)-1] + + if jsonPayload == "-" { + return c.readDataFromStdin() + } + + if strings.HasPrefix(jsonPayload, "@") { + return c.readDataFromFile(jsonPayload[1:]) + } + + return c.unmarshalData([]byte(jsonPayload)) +} + +func (c *cmdInputReader) readDataFromStdin() (map[string]any, error) { + data := map[string]any{} + + decoder := json.NewDecoder(cliIn) + + err := decoder.Decode(&data) + if err != nil { + return nil, err + } + + return data, err +} + +func (c *cmdInputReader) readDataFromFile(filePath string) (map[string]any, error) { + if filePath == "" { + return nil, errors.New("missing file path") + } + + dataBytes, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + return c.unmarshalData(dataBytes) +} + +func (c *cmdInputReader) unmarshalData(dataBytes []byte) (map[string]any, error) { + data := map[string]any{} + + err := json.Unmarshal(dataBytes, &data) + if err != nil { + return nil, fmt.Errorf("invalid json payload: %+v", err) + } + + return data, nil +} diff --git a/commands/commands_commons.go b/commands/commands_commons.go new file mode 100644 index 0000000..690e226 --- /dev/null +++ b/commands/commands_commons.go @@ -0,0 +1,224 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/workers-cli/model" +) + +// Useful to capture output in tests +var ( + cliOut io.Writer = os.Stdout + cliIn io.Reader = os.Stdin + importPattern = regexp.MustCompile(`(?ms)^\s*(import\s+[^;]+;\s*)(.*)$`) +) + +func prettifyJson(in []byte) []byte { + var out bytes.Buffer + if err := json.Indent(&out, in, "", " "); err != nil { + return in + } + return out.Bytes() +} + +func outputApiResponse(res *http.Response, okStatus int) error { + return processApiResponse(res, func(responseBytes []byte, statusCode int) error { + var err error + + if res.StatusCode != okStatus { + err = fmt.Errorf("command failed with status %d", res.StatusCode) + } + + if err == nil { + _, err = cliOut.Write(prettifyJson(responseBytes)) + } else if len(responseBytes) > 0 { + // We will report the previous error, but we still want to display the response body + if _, writeErr := cliOut.Write(prettifyJson(responseBytes)); writeErr != nil { + log.Debug(fmt.Sprintf("Write error: %+v", writeErr)) + } + } + + return err + }) +} + +func discardApiResponse(res *http.Response, okStatus int) error { + return processApiResponse(res, func(content []byte, statusCode int) error { + var err error + if res.StatusCode != okStatus { + err = fmt.Errorf("command failed with status %d", res.StatusCode) + } + return err + }) +} + +func processApiResponse(res *http.Response, doWithContent func(content []byte, statusCode int) error) error { + var err error + var responseBytes []byte + + defer func() { + if err = res.Body.Close(); err != nil { + log.Debug(fmt.Sprintf("Error closing response body: %+v", err)) + } + }() + + if res.ContentLength > 0 { + responseBytes, err = io.ReadAll(res.Body) + if err != nil { + return err + } + } else { + _, _ = io.Copy(io.Discard, res.Body) + } + + if doWithContent == nil { + return nil + } + + return doWithContent(responseBytes, res.StatusCode) +} + +func callWorkerApi(c *components.Context, serverUrl string, serverToken string, method string, body []byte, api ...string) (*http.Response, func(), error) { + timeout, err := model.GetTimeoutParameter(c) + if err != nil { + return nil, nil, err + } + + url := fmt.Sprintf("%sworker/api/v1/%s", utils.AddTrailingSlashIfNeeded(serverUrl), strings.Join(api, "/")) + + reqCtx, cancelReq := context.WithTimeout(context.Background(), timeout) + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewBuffer(body) + } + + req, err := http.NewRequestWithContext(reqCtx, method, url, bodyReader) + if err != nil { + return nil, cancelReq, err + } + + req.Header.Add("Authorization", "Bearer "+strings.TrimSpace(serverToken)) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", coreutils.GetCliUserAgent()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, cancelReq, fmt.Errorf("request timed out after %s", timeout) + } + return nil, cancelReq, err + } + + return res, cancelReq, nil +} + +func callWorkerApiWithOutput(c *components.Context, serverUrl string, serverToken string, method string, body []byte, okStatus int, api ...string) error { + res, discardReq, err := callWorkerApi(c, serverUrl, serverToken, method, body, api...) + if discardReq != nil { + defer discardReq() + } + if err != nil { + return err + } + return outputApiResponse(res, okStatus) +} + +func callWorkerApiSilent(c *components.Context, serverUrl string, serverToken string, method string, body []byte, okStatus int, api ...string) error { + res, discardReq, err := callWorkerApi(c, serverUrl, serverToken, method, body, api...) + if discardReq != nil { + defer discardReq() + } + if err != nil { + return err + } + return discardApiResponse(res, okStatus) +} + +// fetchWorkerDetails Fetch a worker by its name. Returns nil if the worker does not exist (statusCode=404). Any other statusCode other than 200 will result as an error. +func fetchWorkerDetails(c *components.Context, serverUrl string, accessToken string, workerKey string) (*model.WorkerDetails, error) { + res, discardReq, err := callWorkerApi(c, serverUrl, accessToken, http.MethodGet, nil, "workers", workerKey) + if discardReq != nil { + defer discardReq() + } + if err != nil { + return nil, err + } + + var details *model.WorkerDetails + + err = processApiResponse(res, func(content []byte, statusCode int) error { + if statusCode == http.StatusOK { + unmarshalled := new(model.WorkerDetails) + err := json.Unmarshal(content, unmarshalled) + if err == nil { + details = unmarshalled + return nil + } + return err + } + if statusCode != http.StatusNotFound { + return fmt.Errorf("fetch worker '%s' failed with status %d", workerKey, statusCode) + } + return nil + }) + if err != nil { + return nil, err + } + + return details, nil +} + +func prepareSecretsUpdate(mf *model.Manifest, existingWorker *model.WorkerDetails) []*model.Secret { + // We will detect removed secrets + removedSecrets := map[string]any{} + if existingWorker != nil { + for _, existingSecret := range existingWorker.Secrets { + removedSecrets[existingSecret.Key] = struct{}{} + } + } + + var secrets []*model.Secret + + // Secrets should have already been decoded + for secretName, secretValue := range mf.Secrets { + _, secretExists := removedSecrets[secretName] + if secretExists { + // To take into account the local value of a secret + secrets = append(secrets, &model.Secret{Key: secretName, MarkedForRemoval: true}) + } + delete(removedSecrets, secretName) + secrets = append(secrets, &model.Secret{Key: secretName, Value: secretValue}) + } + + for removedSecret := range removedSecrets { + secrets = append(secrets, &model.Secret{Key: removedSecret, MarkedForRemoval: true}) + } + + return secrets +} + +func cleanImports(source string) string { + out := source + match := importPattern.FindAllStringSubmatch(out, -1) + for len(match) == 1 && len(match[0]) == 3 { + out = match[0][2] + match = importPattern.FindAllStringSubmatch(out, -1) + } + return out +} diff --git a/commands/commands_commons_for_itest.go b/commands/commands_commons_for_itest.go new file mode 100644 index 0000000..ed3e0b8 --- /dev/null +++ b/commands/commands_commons_for_itest.go @@ -0,0 +1,13 @@ +//go:build itest + +package commands + +import "io" + +func SetCliIn(reader io.Reader) { + cliIn = reader +} + +func SetCliOut(writer io.Writer) { + cliOut = writer +} diff --git a/commands/commands_commons_test.go b/commands/commands_commons_test.go new file mode 100644 index 0000000..da827b0 --- /dev/null +++ b/commands/commands_commons_test.go @@ -0,0 +1,154 @@ +package commands + +import ( + "bytes" + "encoding/json" + "os" + "path" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" + + "github.com/jfrog/jfrog-cli-core/v2/plugins" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +const secretPassword = "P@ssw0rd!" + +func Test_cleanImports(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + { + name: "case 1", + source: `import { a } from 'b'; export default async (context: a) => ({ status: 200 })`, + want: "export default async (context: a) => ({ status: 200 })", + }, + { + name: "case 2", + source: ` + import { a } from 'b'; + import { c, d } from 'e'; + + export default async (context: a) => ({ status: 200 })`, + want: "export default async (context: a) => ({ status: 200 })", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanImports(tt.source) + assert.Equal(t, tt.want, got) + }) + } +} + +func prepareWorkerDirForTest(t *testing.T) (string, string) { + dir, err := os.MkdirTemp("", "worker-*-init") + require.NoError(t, err) + + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + + oldPwd, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldPwd)) + }) + + workerName := path.Base(dir) + + return dir, workerName +} + +func generateForTest(t require.TestingT, action string, workerName string, templateName string, skipTests ...bool) string { + tpl, err := template.New(templateName).ParseFS(templates, "templates/"+templateName) + require.NoErrorf(t, err, "cannot initialize the template for %s", action) + + var out bytes.Buffer + err = tpl.Execute(&out, map[string]any{ + "Action": action, + "WorkerName": workerName, + "HasCriteria": model.ActionNeedsCriteria(action), + "HasTests": len(skipTests) == 0 || !skipTests[0], + }) + require.NoError(t, err) + + return out.String() +} + +func mustJsonMarshal(t *testing.T, data any) string { + out, err := json.Marshal(data) + require.NoError(t, err) + return string(out) +} + +func createTempFileWithContent(t *testing.T, content string) string { + file, err := os.CreateTemp("", "wks-cli-*.test") + require.NoError(t, err) + + t.Cleanup(func() { + // We do not care about this error + _ = os.Remove(file.Name()) + }) + + _, err = file.Write([]byte(content)) + require.NoError(t, err) + + return file.Name() +} + +func createCliRunner(t *testing.T, commands ...components.Command) func(args ...string) error { + app := components.App{} + app.Name = "worker" + app.Commands = commands + + runCli := plugins.RunCliWithPlugin(app) + + return func(args ...string) error { + oldArgs := os.Args + t.Cleanup(func() { + os.Args = oldArgs + }) + os.Args = args + return runCli() + } +} + +func patchManifest(t require.TestingT, applyPatch func(mf *model.Manifest), dir ...string) { + mf, err := model.ReadManifest(dir...) + require.NoError(t, err) + + applyPatch(mf) + + require.NoError(t, mf.Save(dir...)) +} + +func getActionSourceCode(t require.TestingT, actionName string) string { + templateName := actionName + ".ts_template" + content, err := templates.ReadFile("templates/" + templateName) + require.NoError(t, err) + return string(content) +} + +func mustEncryptSecret(t require.TestingT, secretValue string, password ...string) string { + key := secretPassword + if len(password) > 0 { + key = password[0] + } + encryptedValue, err := model.EncryptSecret(key, secretValue) + require.NoError(t, err) + return encryptedValue +} diff --git a/commands/deploy_cmd.go b/commands/deploy_cmd.go new file mode 100644 index 0000000..a9a20b2 --- /dev/null +++ b/commands/deploy_cmd.go @@ -0,0 +1,119 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/workers-cli/model" +) + +type deployRequest struct { + Key string `json:"key"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + SourceCode string `json:"sourceCode"` + Action string `json:"action"` + FilterCriteria model.FilterCriteria `json:"filterCriteria,omitempty"` + Secrets []*model.Secret `json:"secrets"` +} + +func GetDeployCommand() components.Command { + return components.Command{ + Name: "deploy", + Description: "Deploy a worker", + Aliases: []string{"d"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetTimeoutFlag(), + model.GetNoSecretsFlag(), + }, + Action: func(c *components.Context) error { + manifest, err := model.ReadManifest() + if err != nil { + return err + } + + if err = manifest.Validate(); err != nil { + return err + } + + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + + if !c.GetBoolFlagValue(model.FlagNoSecrets) { + if err = manifest.DecryptSecrets(); err != nil { + return err + } + } + + return runDeployCommand(c, manifest, server.GetUrl(), server.GetAccessToken()) + }, + } +} + +func runDeployCommand(ctx *components.Context, manifest *model.Manifest, serverUrl string, token string) error { + existingWorker, err := fetchWorkerDetails(ctx, serverUrl, token, manifest.Name) + if err != nil { + return err + } + + body, err := prepareDeployRequest(ctx, manifest, existingWorker) + if err != nil { + return err + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + if existingWorker == nil { + log.Info(fmt.Sprintf("Deploying worker '%s'", manifest.Name)) + err = callWorkerApiWithOutput(ctx, serverUrl, token, http.MethodPost, bodyBytes, http.StatusCreated, "workers") + if err == nil { + log.Info(fmt.Sprintf("Worker '%s' deployed", manifest.Name)) + } + return err + } + + log.Info(fmt.Sprintf("Updating worker '%s'", manifest.Name)) + err = callWorkerApiWithOutput(ctx, serverUrl, token, http.MethodPut, bodyBytes, http.StatusNoContent, "workers") + if err == nil { + log.Info(fmt.Sprintf("Worker '%s' updated", manifest.Name)) + } + + return err +} + +func prepareDeployRequest(ctx *components.Context, manifest *model.Manifest, existingWorker *model.WorkerDetails) (*deployRequest, error) { + sourceCode, err := manifest.ReadSourceCode() + if err != nil { + return nil, err + } + sourceCode = cleanImports(sourceCode) + + var secrets []*model.Secret + + if !ctx.GetBoolFlagValue(model.FlagNoSecrets) { + secrets = prepareSecretsUpdate(manifest, existingWorker) + } + + payload := &deployRequest{ + Key: manifest.Name, + Action: manifest.Action, + Description: manifest.Description, + Enabled: manifest.Enabled, + FilterCriteria: manifest.FilterCriteria, + SourceCode: sourceCode, + Secrets: secrets, + } + + return payload, nil +} diff --git a/commands/deploy_cmd_test.go b/commands/deploy_cmd_test.go new file mode 100644 index 0000000..a8bddec --- /dev/null +++ b/commands/deploy_cmd_test.go @@ -0,0 +1,335 @@ +package commands + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +func TestDeployCommand(t *testing.T) { + tests := []struct { + name string + commandArgs []string + token string + workerAction string + workerName string + serverBehavior deployServerStubBehavior + wantErr error + patchManifest func(mf *model.Manifest) + }{ + { + name: "create", + workerAction: "BEFORE_UPLOAD", + workerName: "wk-0", + serverBehavior: deployServerStubBehavior{ + wantMethods: []string{http.MethodGet, http.MethodPost}, + wantRequestBody: getExpectedDeployRequestForAction( + t, + "wk-0", + "BEFORE_UPLOAD", + &model.Secret{Key: "sec-1", Value: "val-1"}, + &model.Secret{Key: "sec-2", Value: "val-2"}, + ), + }, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{ + "sec-1": mustEncryptSecret(t, "val-1"), + "sec-2": mustEncryptSecret(t, "val-2"), + } + }, + }, + { + name: "update", + workerAction: "GENERIC_EVENT", + workerName: "wk-1", + serverBehavior: deployServerStubBehavior{ + wantMethods: []string{http.MethodGet, http.MethodPut}, + wantRequestBody: getExpectedDeployRequestForAction(t, "wk-1", "GENERIC_EVENT"), + existingWorkers: map[string]*model.WorkerDetails{ + "wk-1": {}, + }, + }, + }, + { + name: "update with removed secrets", + workerAction: "AFTER_MOVE", + workerName: "wk-2", + serverBehavior: deployServerStubBehavior{ + wantMethods: []string{http.MethodGet, http.MethodPut}, + wantRequestBody: getExpectedDeployRequestForAction( + t, + "wk-2", + "AFTER_MOVE", + &model.Secret{Key: "sec-1", MarkedForRemoval: true}, + &model.Secret{Key: "sec-1", Value: "val-1"}, + &model.Secret{Key: "sec-2", MarkedForRemoval: true}, + ), + existingWorkers: map[string]*model.WorkerDetails{ + "wk-2": { + Secrets: []*model.Secret{ + {Key: "sec-1"}, {Key: "sec-2"}, + }, + }, + }, + }, + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{ + "sec-1": mustEncryptSecret(t, "val-1"), + } + }, + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500"}, + serverBehavior: deployServerStubBehavior{ + waitFor: 5 * time.Second, + }, + wantErr: errors.New("request timed out after 500ms"), + }, + { + name: "fails if invalid timeout", + commandArgs: []string{"--" + model.FlagTimeout, "abc"}, + wantErr: errors.New("invalid timeout provided"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetInitCommand(), GetDeployCommand()) + + _, workerName := prepareWorkerDirForTest(t) + if tt.workerName != "" { + workerName = tt.workerName + } + + workerAction := tt.workerAction + if workerAction == "" { + workerAction = "BEFORE_DOWNLOAD" + } + + err := runCmd("worker", "init", workerAction, workerName) + require.NoError(t, err) + + if tt.patchManifest != nil { + patchManifest(t, tt.patchManifest) + } + + err = os.Setenv(model.EnvKeyServerUrl, newDeployServerStub(t, ctx, &tt.serverBehavior)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + err = os.Setenv(model.EnvKeySecretsPassword, secretPassword) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeySecretsPassword) + }) + + if tt.token == "" && tt.serverBehavior.wantBearerToken == "" { + tt.token = t.Name() + tt.serverBehavior.wantBearerToken = t.Name() + } + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + cmd := append([]string{"worker", "deploy"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + if tt.wantErr == nil { + assert.NoError(t, err) + } else { + assert.EqualError(t, tt.wantErr, err.Error()) + } + }) + } +} + +var deployUrlPattern = regexp.MustCompile(`^/worker/api/v1/workers(/[\S/]+)?$`) + +type deployServerStubBehavior struct { + waitFor time.Duration + responseStatus int + wantBearerToken string + wantRequestBody *deployRequest + wantMethods []string + existingWorkers map[string]*model.WorkerDetails +} + +type deployServerStub struct { + t *testing.T + ctx context.Context + behavior *deployServerStubBehavior +} + +func newDeployServerStub(t *testing.T, ctx context.Context, behavior *deployServerStubBehavior) string { + stub := deployServerStub{t: t, behavior: behavior, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *deployServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + urlMatch := deployUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(urlMatch) == 0 { + res.WriteHeader(http.StatusNotFound) + return + } + + if s.behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(s.behavior.waitFor): + } + } + + // Validate method + var methodValid bool + for _, wantMethod := range s.behavior.wantMethods { + if methodValid = wantMethod == req.Method; methodValid { + break + } + } + + if !methodValid { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+s.behavior.wantBearerToken { + res.WriteHeader(http.StatusForbidden) + return + } + + if s.behavior.responseStatus > 0 { + res.WriteHeader(s.behavior.responseStatus) + return + } + + if http.MethodGet != req.Method { + if req.Header.Get("content-type") != "application/json" { + res.WriteHeader(http.StatusBadRequest) + return + } + + // Validate body if requested + if s.behavior.wantRequestBody != nil { + gotData, err := io.ReadAll(req.Body) + if err != nil { + s.t.Logf("Read request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + gotRequestBody := deployRequest{} + err = json.Unmarshal(gotData, &gotRequestBody) + if err != nil { + s.t.Logf("Unmarshall request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + assertDeployRequestEquals(s.t, s.behavior.wantRequestBody, &gotRequestBody) + } + } + + if http.MethodGet == req.Method { + var workerKey string + + if len(urlMatch[0]) < 1 { + res.WriteHeader(http.StatusNotFound) + return + } else { + workerKey = urlMatch[0][1][1:] + } + + workerDetails, workerExists := s.behavior.existingWorkers[workerKey] + if !workerExists { + res.WriteHeader(http.StatusNotFound) + return + } + + res.WriteHeader(http.StatusOK) + _, err := res.Write([]byte(mustJsonMarshal(s.t, workerDetails))) + require.NoError(s.t, err) + return + } + + // Assume updated or created + if http.MethodPut == req.Method { + res.WriteHeader(http.StatusNoContent) + return + } else if http.MethodPost == req.Method { + res.WriteHeader(http.StatusCreated) + return + } + + res.WriteHeader(http.StatusMethodNotAllowed) +} + +func assertDeployRequestEquals(t require.TestingT, want, got *deployRequest) { + assert.Equalf(t, want.Key, got.Key, "Key mismatch") + assert.Equalf(t, want.Description, got.Description, "Description mismatch") + assert.Equalf(t, want.Enabled, got.Enabled, "Enabled mismatch") + assert.Equalf(t, want.SourceCode, got.SourceCode, "SourceCode mismatch") + assert.Equalf(t, want.Action, got.Action, "Action mismatch") + assert.Equalf(t, want.FilterCriteria, got.FilterCriteria, "FilterCriteria mismatch") + + // Equals does not work for secrets as they are proto objects + assert.Equalf(t, len(want.Secrets), len(got.Secrets), "Secrets length mismatch") + var wantSecrets, gotSecrets []string + for _, s := range want.Secrets { + wantSecrets = append(wantSecrets, fmt.Sprintf("%s:%s:%v", s.Key, s.Value, s.MarkedForRemoval)) + } + for _, s := range got.Secrets { + gotSecrets = append(gotSecrets, fmt.Sprintf("%s:%s:%v", s.Key, s.Value, s.MarkedForRemoval)) + } + assert.ElementsMatchf(t, wantSecrets, gotSecrets, "Secrets mismatch") +} + +func getExpectedDeployRequestForAction(t require.TestingT, workerName, actionName string, secrets ...*model.Secret) *deployRequest { + r := &deployRequest{ + Key: workerName, + Description: "Run a script on " + actionName, + Enabled: false, + SourceCode: cleanImports(getActionSourceCode(t, actionName)), + Action: actionName, + Secrets: secrets, + } + + if model.ActionNeedsCriteria(actionName) { + r.FilterCriteria = model.FilterCriteria{ + ArtifactFilterCriteria: model.ArtifactFilterCriteria{ + RepoKeys: []string{"example-repo-local"}, + }, + } + } + + return r +} diff --git a/commands/dry_run_cmd.go b/commands/dry_run_cmd.go new file mode 100644 index 0000000..ec82a65 --- /dev/null +++ b/commands/dry_run_cmd.go @@ -0,0 +1,103 @@ +package commands + +import ( + "encoding/json" + "net/http" + + "github.com/jfrog/jfrog-client-go/utils/log" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + + "github.com/jfrog/workers-cli/model" +) + +type dryRunHandler struct { + ctx *components.Context +} + +type dryRunRequest struct { + Code string `json:"code"` + Action string `json:"action"` + StagedSecrets []*model.Secret `json:"stagedSecrets,omitempty"` + Data map[string]any `json:"data"` +} + +func GetDryRunCommand() components.Command { + return components.Command{ + Name: "dry-run", + Description: "Dry run a worker", + Aliases: []string{"dr"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetTimeoutFlag(), + model.GetNoSecretsFlag(), + }, + Arguments: []components.Argument{ + model.GetJsonPayloadArgument(), + }, + Action: func(c *components.Context) error { + h := &dryRunHandler{c} + + manifest, err := model.ReadManifest() + if err != nil { + return err + } + + if err = manifest.Validate(); err != nil { + return err + } + + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + + inputReader := &cmdInputReader{c} + + data, err := inputReader.readData() + if err != nil { + return err + } + + if !c.GetBoolFlagValue(model.FlagNoSecrets) { + if err = manifest.DecryptSecrets(); err != nil { + return err + } + } + + return h.run(manifest, server.GetUrl(), server.GetAccessToken(), data) + }, + } +} + +func (c *dryRunHandler) run(manifest *model.Manifest, serverUrl string, token string, data map[string]any) error { + body, err := c.preparePayload(manifest, serverUrl, token, data) + if err != nil { + return err + } + return callWorkerApiWithOutput(c.ctx, serverUrl, token, http.MethodPost, body, http.StatusOK, "test", manifest.Name) +} + +func (c *dryRunHandler) preparePayload(manifest *model.Manifest, serverUrl string, token string, data map[string]any) ([]byte, error) { + payload := &dryRunRequest{Action: manifest.Action, Data: data} + + var err error + + payload.Code, err = manifest.ReadSourceCode() + if err != nil { + return nil, err + } + payload.Code = cleanImports(payload.Code) + + existingWorker, err := fetchWorkerDetails(c.ctx, serverUrl, token, manifest.Name) + if err != nil { + log.Warn(err.Error()) + } + + if !c.ctx.GetBoolFlagValue(model.FlagNoSecrets) { + payload.StagedSecrets = prepareSecretsUpdate(manifest, existingWorker) + } + + return json.Marshal(&payload) +} diff --git a/commands/dry_run_cmd_test.go b/commands/dry_run_cmd_test.go new file mode 100644 index 0000000..6048c71 --- /dev/null +++ b/commands/dry_run_cmd_test.go @@ -0,0 +1,307 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +type dryRunAssertFunc func(t *testing.T, stdOutput []byte, err error, serverBehavior *dryRunServerStubBehavior) + +func TestDryRun(t *testing.T) { + tests := []struct { + name string + commandArgs []string + assert dryRunAssertFunc + // Token to be sent in the request + token string + // The server behavior + serverBehavior *dryRunServerStubBehavior + // If provided the cliIn will be filled with this content + stdInput string + // If provided a temp file will be generated with this content and the file path will be added at the end of the command + fileInput string + }{ + { + name: "nominal case", + token: "my-token", + serverBehavior: &dryRunServerStubBehavior{ + responseStatus: http.StatusOK, + responseBody: map[string]any{ + "my": "payload", + }, + requestToken: "my-token", + }, + commandArgs: []string{mustJsonMarshal(t, map[string]any{"my": "payload"})}, + assert: assertDryRunSucceed, + }, + { + name: "fails if not OK status", + token: "invalid-token", + serverBehavior: &dryRunServerStubBehavior{ + requestToken: "valid-token", + }, + commandArgs: []string{`{}`}, + assert: assertDryRunFail("command failed with status %d", http.StatusForbidden), + }, + { + name: "reads from stdin", + token: "valid-token", + stdInput: mustJsonMarshal(t, map[string]any{"my": "request"}), + serverBehavior: &dryRunServerStubBehavior{ + requestToken: "valid-token", + requestBody: map[string]any{"my": "request"}, + responseBody: map[string]any{"valid": "response"}, + responseStatus: http.StatusOK, + }, + commandArgs: []string{"-"}, + assert: assertDryRunSucceed, + }, + { + name: "reads from file", + token: "valid-token", + fileInput: mustJsonMarshal(t, map[string]any{"my": "file-content"}), + serverBehavior: &dryRunServerStubBehavior{ + requestToken: "valid-token", + requestBody: map[string]any{"my": "file-content"}, + responseBody: map[string]any{"valid": "response"}, + responseStatus: http.StatusOK, + }, + assert: assertDryRunSucceed, + }, + { + name: "fails if invalid json from argument", + commandArgs: []string{`{"my":`}, + assert: assertDryRunFail("invalid json payload: unexpected end of JSON input"), + }, + { + name: "fails if invalid json from file argument", + fileInput: `{"my":`, + assert: assertDryRunFail("invalid json payload: unexpected end of JSON input"), + }, + { + name: "fails if invalid json from standard input", + commandArgs: []string{"-"}, + stdInput: `{"my":`, + assert: assertDryRunFail("unexpected EOF"), + }, + { + name: "fails if missing file", + commandArgs: []string{"@non-existing-file.json"}, + assert: assertDryRunFail("open non-existing-file.json: no such file or directory"), + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500", `{}`}, + serverBehavior: &dryRunServerStubBehavior{ + waitFor: 5 * time.Second, + }, + assert: assertDryRunFail("request timed out after 500ms"), + }, + { + name: "fails if invalid timeout", + commandArgs: []string{"--" + model.FlagTimeout, "abc", `{}`}, + assert: assertDryRunFail("invalid timeout provided"), + }, + { + name: "fails if empty file path", + commandArgs: []string{"@"}, + assert: assertDryRunFail("missing file path"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetInitCommand(), GetDryRunCommand()) + + _, workerName := prepareWorkerDirForTest(t) + + err := runCmd("worker", "init", "BEFORE_DOWNLOAD", workerName) + require.NoError(t, err) + + serverResponseStubs := map[string]*dryRunServerStubBehavior{} + if tt.serverBehavior != nil { + serverResponseStubs[workerName] = tt.serverBehavior + } + + err = os.Setenv(model.EnvKeyServerUrl, newDryRunServerStub(t, ctx, serverResponseStubs)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + err = os.Setenv(model.EnvKeySecretsPassword, secretPassword) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeySecretsPassword) + }) + + if tt.stdInput != "" { + cliIn = bytes.NewReader([]byte(tt.stdInput)) + t.Cleanup(func() { + cliIn = os.Stdin + }) + } + + if tt.fileInput != "" { + tt.commandArgs = append(tt.commandArgs, "@"+createTempFileWithContent(t, tt.fileInput)) + } + + var output bytes.Buffer + + cliOut = &output + t.Cleanup(func() { + cliOut = os.Stdout + }) + + cmd := append([]string{"worker", "dry-run"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + tt.assert(t, output.Bytes(), err, tt.serverBehavior) + }) + } +} + +func assertDryRunSucceed(t *testing.T, output []byte, err error, serverBehavior *dryRunServerStubBehavior) { + require.NoError(t, err) + + outputData := map[string]any{} + + err = json.Unmarshal(output, &outputData) + require.NoError(t, err) + + assert.Equal(t, serverBehavior.responseBody, outputData) +} + +func assertDryRunFail(errorMessage string, errorMessageArgs ...any) dryRunAssertFunc { + return func(t *testing.T, stdOutput []byte, err error, serverResponse *dryRunServerStubBehavior) { + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf(errorMessage, errorMessageArgs...)) + } +} + +var dryRunUrlPattern = regexp.MustCompile(`^/worker/api/v1/test/([\S/]+)$`) + +type dryRunServerStubBehavior struct { + waitFor time.Duration + responseStatus int + responseBody map[string]any + requestToken string + requestBody map[string]any +} + +type dryRunServerStub struct { + t *testing.T + ctx context.Context + stubs map[string]*dryRunServerStubBehavior +} + +func newDryRunServerStub(t *testing.T, ctx context.Context, responseStubs map[string]*dryRunServerStubBehavior) string { + stub := dryRunServerStub{stubs: responseStubs, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *dryRunServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + matches := dryRunUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(matches) == 0 || len(matches[0][1]) < 1 { + res.WriteHeader(http.StatusNotFound) + return + } + + if req.Header.Get("content-type") != "application/json" { + res.WriteHeader(http.StatusBadRequest) + return + } + + workerName := matches[0][1] + + behavior, exists := s.stubs[workerName] + if !exists { + res.WriteHeader(http.StatusNotFound) + return + } + + if behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(behavior.waitFor): + } + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+behavior.requestToken { + res.WriteHeader(http.StatusForbidden) + return + } + + // Validate body if requested + if behavior.requestBody != nil { + wantData, checkRequestData := behavior.responseBody["data"] + + if checkRequestData { + gotData, err := io.ReadAll(req.Body) + if err != nil { + s.t.Logf("Read request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + decodedData := map[string]any{} + err = json.Unmarshal(gotData, &decodedData) + if err != nil { + s.t.Logf("Unmarshall request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(wantData, decodedData) { + res.WriteHeader(http.StatusBadRequest) + return + } + } + } + + bodyBytes, err := json.Marshal(behavior.responseBody) + if err != nil { + s.t.Logf("Marshall error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + res.WriteHeader(behavior.responseStatus) + _, err = res.Write(bodyBytes) + if err != nil { + s.t.Logf("Write error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + } +} diff --git a/commands/execute_cmd.go b/commands/execute_cmd.go new file mode 100644 index 0000000..d453d33 --- /dev/null +++ b/commands/execute_cmd.go @@ -0,0 +1,75 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/jfrog/jfrog-client-go/utils/log" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + + "github.com/jfrog/workers-cli/model" +) + +func GetExecuteCommand() components.Command { + return components.Command{ + Name: "execute", + Description: "Execute a GENERIC_EVENT worker", + Aliases: []string{"exec", "e"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetTimeoutFlag(), + }, + Arguments: []components.Argument{ + model.GetWorkerKeyArgument(), + model.GetJsonPayloadArgument(), + }, + Action: func(c *components.Context) error { + var workerKey string + + if len(c.Arguments) > 1 { + workerKey = c.Arguments[0] + } else if len(c.Arguments) > 0 { + log.Info("No worker name provided, it will be taken from the manifest. Last argument is considered as a json payload.") + } + + if workerKey == "" { + manifest, err := model.ReadManifest() + if err != nil { + return err + } + + if err = manifest.Validate(); err != nil { + return err + } + + if manifest.Action != "GENERIC_EVENT" { + return fmt.Errorf("only the GENERIC_EVENT actions are executable. Got %s", manifest.Action) + } + + workerKey = manifest.Name + } + + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + + inputReader := &cmdInputReader{c} + + data, err := inputReader.readData() + if err != nil { + return err + } + + body, err := json.Marshal(data) + if err != nil { + return err + } + + return callWorkerApiWithOutput(c, server.GetUrl(), server.GetAccessToken(), http.MethodPost, body, http.StatusOK, "execute", workerKey) + }, + } +} diff --git a/commands/execute_cmd_test.go b/commands/execute_cmd_test.go new file mode 100644 index 0000000..397b56d --- /dev/null +++ b/commands/execute_cmd_test.go @@ -0,0 +1,332 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +type executeAssertFunc func(t *testing.T, stdOutput []byte, err error, serverBehavior *executeServerStubBehavior) + +func TestExecute(t *testing.T) { + tests := []struct { + name string + commandArgs []string + assert executeAssertFunc + action string + workerKey string + // Token to be sent in the request + token string + // The server behavior + serverBehavior *executeServerStubBehavior + // If provided the cliIn will be filled with this content + stdInput string + // If provided a temp file will be generated with this content and the file path will be added at the end of the command + fileInput string + }{ + { + name: "execute from manifest", + serverBehavior: &executeServerStubBehavior{ + responseStatus: http.StatusOK, + responseBody: map[string]any{ + "my": "payload", + }, + }, + commandArgs: []string{mustJsonMarshal(t, map[string]any{"my": "payload"})}, + assert: assertExecuteSucceed, + }, + { + name: "execute with workerKey", + workerKey: "my-worker", + serverBehavior: &executeServerStubBehavior{ + responseStatus: http.StatusOK, + responseBody: map[string]any{ + "my": "payload", + }, + }, + commandArgs: []string{"my-worker", mustJsonMarshal(t, map[string]any{"my": "payload"})}, + assert: assertExecuteSucceed, + }, + { + name: "fails if not a GENERIC_EVENT", + action: "BEFORE_DOWNLOAD", + serverBehavior: &executeServerStubBehavior{}, + commandArgs: []string{`{}`}, + assert: assertExecuteFail("only the GENERIC_EVENT actions are executable. Got BEFORE_DOWNLOAD"), + }, + { + name: "fails if not OK status", + token: "invalid-token", + serverBehavior: &executeServerStubBehavior{ + requestToken: "valid-token", + }, + commandArgs: []string{`{}`}, + assert: assertExecuteFail("command failed with status %d", http.StatusForbidden), + }, + { + name: "reads from stdin", + stdInput: mustJsonMarshal(t, map[string]any{"my": "request"}), + serverBehavior: &executeServerStubBehavior{ + requestBody: map[string]any{"my": "request"}, + responseBody: map[string]any{"valid": "response"}, + responseStatus: http.StatusOK, + }, + commandArgs: []string{"-"}, + assert: assertExecuteSucceed, + }, + { + name: "reads from file", + fileInput: mustJsonMarshal(t, map[string]any{"my": "file-content"}), + serverBehavior: &executeServerStubBehavior{ + requestBody: map[string]any{"my": "file-content"}, + responseBody: map[string]any{"valid": "response"}, + responseStatus: http.StatusOK, + }, + assert: assertExecuteSucceed, + }, + { + name: "fails if invalid json from argument", + commandArgs: []string{`{"my":`}, + assert: assertExecuteFail("invalid json payload: unexpected end of JSON input"), + }, + { + name: "fails if invalid json from file argument", + fileInput: `{"my":`, + assert: assertExecuteFail("invalid json payload: unexpected end of JSON input"), + }, + { + name: "fails if invalid json from standard input", + commandArgs: []string{"-"}, + stdInput: `{"my":`, + assert: assertExecuteFail("unexpected EOF"), + }, + { + name: "fails if missing file", + commandArgs: []string{"@non-existing-file.json"}, + assert: assertExecuteFail("open non-existing-file.json: no such file or directory"), + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500", `{}`}, + serverBehavior: &executeServerStubBehavior{ + waitFor: 5 * time.Second, + }, + assert: assertExecuteFail("request timed out after 500ms"), + }, + { + name: "fails if invalid timeout", + commandArgs: []string{"--" + model.FlagTimeout, "abc", `{}`}, + assert: assertExecuteFail("invalid timeout provided"), + }, + { + name: "fails if empty file path", + commandArgs: []string{"@"}, + assert: assertExecuteFail("missing file path"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetInitCommand(), GetExecuteCommand()) + + _, workerName := prepareWorkerDirForTest(t) + + if tt.workerKey != "" { + workerName = tt.workerKey + } + + action := "GENERIC_EVENT" + if tt.action != "" { + action = tt.action + } + + err := runCmd("worker", "init", action, workerName) + require.NoError(t, err) + + serverResponseStubs := map[string]*executeServerStubBehavior{} + if tt.serverBehavior != nil { + serverResponseStubs[workerName] = tt.serverBehavior + } + + if tt.token == "" { + tt.token = t.Name() + if tt.serverBehavior != nil && tt.serverBehavior.requestToken == "" { + tt.serverBehavior.requestToken = t.Name() + } + } + + err = os.Setenv(model.EnvKeyServerUrl, newExecuteServerStub(t, ctx, serverResponseStubs)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + if tt.stdInput != "" { + cliIn = bytes.NewReader([]byte(tt.stdInput)) + t.Cleanup(func() { + cliIn = os.Stdin + }) + } + + if tt.fileInput != "" { + tt.commandArgs = append(tt.commandArgs, "@"+createTempFileWithContent(t, tt.fileInput)) + } + + var output bytes.Buffer + + cliOut = &output + t.Cleanup(func() { + cliOut = os.Stdout + }) + + cmd := append([]string{"worker", "execute"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + tt.assert(t, output.Bytes(), err, tt.serverBehavior) + }) + } +} + +func assertExecuteSucceed(t *testing.T, output []byte, err error, serverBehavior *executeServerStubBehavior) { + require.NoError(t, err) + + outputData := map[string]any{} + + err = json.Unmarshal(output, &outputData) + require.NoError(t, err) + + assert.Equal(t, serverBehavior.responseBody, outputData) +} + +func assertExecuteFail(errorMessage string, errorMessageArgs ...any) executeAssertFunc { + return func(t *testing.T, stdOutput []byte, err error, serverResponse *executeServerStubBehavior) { + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf(errorMessage, errorMessageArgs...)) + } +} + +var executeUrlPattern = regexp.MustCompile(`^/worker/api/v1/execute/([\S/]+)$`) + +type executeServerStubBehavior struct { + waitFor time.Duration + responseStatus int + responseBody map[string]any + requestToken string + requestBody map[string]any +} + +type executeServerStub struct { + t *testing.T + ctx context.Context + stubs map[string]*executeServerStubBehavior +} + +func newExecuteServerStub(t *testing.T, ctx context.Context, responseStubs map[string]*executeServerStubBehavior) string { + stub := executeServerStub{stubs: responseStubs, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *executeServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + matches := executeUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(matches) == 0 || len(matches[0][1]) < 1 { + res.WriteHeader(http.StatusNotFound) + return + } + + if req.Header.Get("content-type") != "application/json" { + res.WriteHeader(http.StatusBadRequest) + return + } + + workerName := matches[0][1] + + behavior, exists := s.stubs[workerName] + if !exists { + res.WriteHeader(http.StatusNotFound) + return + } + + if behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(behavior.waitFor): + } + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+behavior.requestToken { + res.WriteHeader(http.StatusForbidden) + return + } + + // Validate body if requested + if behavior.requestBody != nil { + wantData, checkRequestData := behavior.responseBody["data"] + + if checkRequestData { + gotData, err := io.ReadAll(req.Body) + if err != nil { + s.t.Logf("Read request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + decodedData := map[string]any{} + err = json.Unmarshal(gotData, &decodedData) + if err != nil { + s.t.Logf("Unmarshall request body error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(wantData, decodedData) { + res.WriteHeader(http.StatusBadRequest) + return + } + } + } + + bodyBytes, err := json.Marshal(behavior.responseBody) + if err != nil { + s.t.Logf("Marshall error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + return + } + + res.WriteHeader(behavior.responseStatus) + _, err = res.Write(bodyBytes) + if err != nil { + s.t.Logf("Write error: %+v", err) + res.WriteHeader(http.StatusInternalServerError) + } +} diff --git a/commands/init_cmd.go b/commands/init_cmd.go new file mode 100644 index 0000000..6a19f27 --- /dev/null +++ b/commands/init_cmd.go @@ -0,0 +1,121 @@ +package commands + +import ( + "embed" + "errors" + "fmt" + "os" + "path" + "strings" + "text/template" + + "github.com/jfrog/workers-cli/model" + + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +//go:embed templates/* +var templates embed.FS + +func GetInitCommand() components.Command { + return components.Command{ + Name: "init", + Description: "Initialize a worker", + Aliases: []string{"i"}, + Flags: []components.Flag{ + components.NewBoolFlag(model.FlagForce, "Whether or not to overwrite existing files"), + model.GetNoTestFlag(), + }, + Arguments: []components.Argument{ + {Name: "action", Description: fmt.Sprintf("The action that will trigger the worker (%s)", strings.Join(strings.Split(model.ActionNames(), "|"), ", "))}, + {Name: "worker-name", Description: "The name of the worker"}, + }, + Action: func(c *components.Context) error { + if len(c.Arguments) < 2 { + return fmt.Errorf("the action or worker name is missing, please see 'jf worker init --help'") + } + action := c.Arguments[0] + workerName := c.Arguments[1] + workingDir, err := os.Getwd() + if err != nil { + return err + } + if err := initWorker(workingDir, action, workerName, c.GetBoolFlagValue(model.FlagForce), c.GetBoolFlagValue(model.FlagNoTest)); err != nil { + return err + } + log.Info(fmt.Sprintf("Worker %s initialized", workerName)) + return nil + }, + } +} + +func initWorker(targetDir string, action string, workerName string, force bool, skipTests bool) error { + if !model.ActionIsValid(action) { + return fmt.Errorf("invalid action '%s' action should be one of: %s", action, strings.Split(model.ActionNames(), "|")) + } + + generate := initGenerator(targetDir, action, workerName, force, skipTests) + + if err := generate("package.json_template", "package.json"); err != nil { + return err + } + + if err := generate("tsconfig.json_template", "tsconfig.json"); err != nil { + return err + } + + if err := generate("manifest.json_template", "manifest.json"); err != nil { + return err + } + + if err := generate(action+".ts_template", "worker.ts"); err != nil { + return err + } + + if !skipTests { + if err := generate(action+".spec.ts_template", "worker.spec.ts"); err != nil { + return err + } + } + + return nil +} + +func checkFileBeforeGenerate(filePath string, failIfExists bool) error { + if _, err := os.Stat(filePath); err == nil || !errors.Is(err, os.ErrNotExist) { + if failIfExists { + return fmt.Errorf("%s already exists in %s, please use '--force' to overwrite if you know what you are doing", path.Base(filePath), path.Dir(filePath)) + } + log.Warn(fmt.Sprintf("%s exists in %s. It will be overwritten", path.Base(filePath), path.Dir(filePath))) + } + return nil +} + +func initGenerator(targetDir string, action string, workerName string, force bool, skipTests bool) func(string, string) error { + return func(templateName, outputFilename string) error { + tpl, err := template.New(templateName).ParseFS(templates, "templates/"+templateName) + if err != nil { + return err + } + + filePath := path.Join(targetDir, outputFilename) + + err = checkFileBeforeGenerate(filePath, !force) + if err != nil { + return err + } + + out, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm) + if err != nil { + return err + } + + return tpl.Execute(out, map[string]any{ + "Action": action, + "WorkerName": workerName, + "HasCriteria": model.ActionNeedsCriteria(action), + "HasTests": !skipTests, + }) + } +} diff --git a/commands/init_cmd_test.go b/commands/init_cmd_test.go new file mode 100644 index 0000000..47b05bd --- /dev/null +++ b/commands/init_cmd_test.go @@ -0,0 +1,216 @@ +package commands + +import ( + "fmt" + "os" + "path" + "regexp" + "strings" + "testing" + + "github.com/jfrog/workers-cli/model" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type runCommandFunc func(args ...string) error + +func TestGetCommand(t *testing.T) { + cmd := GetInitCommand() + + assert.Equalf(t, "init", cmd.Name, "Invalid command name") + assert.NotEmptyf(t, cmd.Description, "No description") + + require.Lenf(t, cmd.Aliases, 1, "No alias") + assert.Equal(t, "i", cmd.Aliases[0], "Invalid alias") + + require.Lenf(t, cmd.Arguments, 2, "Invalid number of argument provided") + assert.Equalf(t, "action", cmd.Arguments[0].Name, "Invalid first argument") + assert.NotEmptyf(t, cmd.Arguments[0].Description, "Action argument should be described") + assert.Equalf(t, "worker-name", cmd.Arguments[1].Name, "Invalid second argument") + assert.NotEmptyf(t, cmd.Arguments[1].Description, "Name argument should be described") + + assert.NotNilf(t, cmd.Action, "An action should be provided") +} + +func TestInitWorker(t *testing.T) { + tests := []struct { + name string + test func(t *testing.T, runCommand runCommandFunc) + }{ + { + name: "missing action and name", + test: func(t *testing.T, runCommand runCommandFunc) { + err := runCommand("worker", "init") + assert.EqualError(t, err, "the action or worker name is missing, please see 'jf worker init --help'") + }, + }, + { + name: "missing name", + test: func(t *testing.T, runCommand runCommandFunc) { + err := runCommand("worker", "init", "BEFORE_DOWNLOAD") + assert.EqualError(t, err, "the action or worker name is missing, please see 'jf worker init --help'") + }, + }, + { + name: "invalid action", + test: func(t *testing.T, runCommand runCommandFunc) { + err := runCommand("worker", "init", "HACK_SYSTEM", "root") + assert.EqualError(t, err, fmt.Sprintf("invalid action '%s' action should be one of: %s", "HACK_SYSTEM", strings.Split(model.ActionNames(), "|"))) + }, + }, + { + name: "generate", + test: testGenerateAllActions, + }, + { + name: "overwrite manifest with force", + test: testGenerateWithOverwrite("manifest.json", true), + }, + { + name: "dont overwrite manifest without force", + test: testGenerateWithOverwrite("manifest.json", false), + }, + { + name: "overwrite sourceCode with force", + test: testGenerateWithOverwrite("worker.ts", true), + }, + { + name: "dont overwrite sourceCode without force", + test: testGenerateWithOverwrite("worker.ts", false), + }, + { + name: "overwrite testSourceCode with force", + test: testGenerateWithOverwrite("worker.spec.ts", true), + }, + { + name: "dont overwrite testSourceCode without force", + test: testGenerateWithOverwrite("worker.spec.ts", false), + }, + { + name: "overwrite package.json with force", + test: testGenerateWithOverwrite("package.json", true), + }, + { + name: "dont overwrite package.json without force", + test: testGenerateWithOverwrite("package.json", false), + }, + { + name: "overwrite tsconfig.json with force", + test: testGenerateWithOverwrite("tsconfig.json", true), + }, + { + name: "dont overwrite tsconfig.json without force", + test: testGenerateWithOverwrite("tsconfig.json", false), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.test(t, createCliRunner(t, GetInitCommand())) + }) + } +} + +func testGenerateWithOverwrite(fileName string, overwrite bool) func(t *testing.T, runCommand runCommandFunc) { + return func(t *testing.T, runCommand runCommandFunc) { + dir, err := os.MkdirTemp("", "worker-*.init") + require.NoError(t, err) + + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + + // Simulate an existing file + f, err := os.OpenFile(path.Join(dir, fileName), os.O_CREATE|os.O_WRONLY, os.ModePerm) + require.NoError(t, err) + _, err = f.WriteString("dummy content") + require.NoError(t, err) + require.NoError(t, f.Close()) + + oldPwd, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldPwd)) + }) + + workerName := path.Base(dir) + + cmd := []string{"worker", "init"} + if overwrite { + cmd = append(cmd, "--force") + } + cmd = append(cmd, "BEFORE_DOWNLOAD", workerName) + + err = runCommand(cmd...) + + if overwrite { + assert.NoError(t, err) + } else { + require.NotNilf(t, err, "an error was expected") + errMatched, err := regexp.MatchString(fmt.Sprintf(`%s already exists in \S+/%s, please use '--force' to overwrite if you know what you are doing`, fileName, workerName), err.Error()) + require.NoError(t, err) + assert.True(t, errMatched) + } + } +} + +func testGenerateAction(actionName string, withTests bool, runCommand runCommandFunc) func(t *testing.T) { + return func(t *testing.T) { + dir, workerName := prepareWorkerDirForTest(t) + + manifestPath := path.Join(dir, "manifest.json") + workerSourcePath := path.Join(dir, "worker.ts") + workerTestSourcePath := path.Join(dir, "worker.spec.ts") + packageJsonPath := path.Join(dir, "package.json") + tsconfigJsonPath := path.Join(dir, "tsconfig.json") + + wantManifest := generateForTest(t, actionName, workerName, "manifest.json_template", !withTests) + wantPackageJson := generateForTest(t, actionName, workerName, "package.json_template", !withTests) + wantWorkerSource := generateForTest(t, actionName, workerName, actionName+".ts_template", !withTests) + wantWorkerTestSource := generateForTest(t, actionName, workerName, actionName+".spec.ts_template", !withTests) + wantTsconfig := generateForTest(t, actionName, workerName, "tsconfig.json_template", !withTests) + + commandArgs := []string{"worker", "init"} + if !withTests { + commandArgs = append(commandArgs, "--"+model.FlagNoTest) + } + commandArgs = append(commandArgs, actionName, workerName) + + err := runCommand(commandArgs...) + require.NoError(t, err) + + gotManifest, err := os.ReadFile(manifestPath) + require.NoErrorf(t, err, "Cannot get manifest content") + assert.Equalf(t, wantManifest, string(gotManifest), "Invalid manifest content") + + gotSource, err := os.ReadFile(workerSourcePath) + require.NoErrorf(t, err, "Cannot get worker source code") + assert.Equalf(t, wantWorkerSource, string(gotSource), "Invalid worker source code") + + if withTests { + gotTestSource, err := os.ReadFile(workerTestSourcePath) + require.NoErrorf(t, err, "Cannot get worker test source code") + assert.Equalf(t, wantWorkerTestSource, string(gotTestSource), "Invalid worker test source code") + } + + gotPackageJson, err := os.ReadFile(packageJsonPath) + require.NoErrorf(t, err, "Cannot get worker package.json") + assert.Equalf(t, wantPackageJson, string(gotPackageJson), "Invalid worker package.json") + + gotTsconfigJson, err := os.ReadFile(tsconfigJsonPath) + require.NoErrorf(t, err, "Cannot get worker tsconfig.json") + assert.Equalf(t, wantTsconfig, string(gotTsconfigJson), "Invalid worker tsconfig.json") + } +} + +func testGenerateAllActions(t *testing.T, runCommand runCommandFunc) { + for _, actionName := range strings.Split(model.ActionNames(), "|") { + t.Run(actionName, testGenerateAction(actionName, true, runCommand)) + t.Run(actionName+" without tests", testGenerateAction(actionName, false, runCommand)) + } +} diff --git a/commands/list_cmd.go b/commands/list_cmd.go new file mode 100644 index 0000000..2825025 --- /dev/null +++ b/commands/list_cmd.go @@ -0,0 +1,110 @@ +package commands + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/workers-cli/model" +) + +type getAllResponse struct { + Workers []*model.WorkerDetails `json:"workers"` +} + +func GetListCommand() components.Command { + return components.Command{ + Name: "list", + Description: "List workers. The default output is a CSV format with columns ,,,.", + Aliases: []string{"ls"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetJsonOutputFlag("Use JSON instead of CSV as output"), + model.GetTimeoutFlag(), + }, + Arguments: []components.Argument{ + { + Name: "action", + Description: fmt.Sprintf("Only show workers of this type.\n\t\tShould be one of (%s).", strings.Join(strings.Split(model.ActionNames(), "|"), ", ")), + Optional: true, + }, + }, + Action: func(c *components.Context) error { + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + return runListCommand(c, server.GetUrl(), server.GetAccessToken()) + }, + } +} + +func runListCommand(ctx *components.Context, serverUrl string, token string) error { + api := "workers" + + if len(ctx.Arguments) > 0 { + api = fmt.Sprintf("%s?action=%s", api, url.QueryEscape(ctx.Arguments[0])) + } + + res, discardReq, err := callWorkerApi(ctx, serverUrl, token, http.MethodGet, nil, api) + if discardReq != nil { + defer discardReq() + } + if err != nil { + return err + } + + if ctx.GetBoolFlagValue(model.FlagJsonOutput) { + return outputApiResponse(res, http.StatusOK) + } + + return formatListResponseAsCsv(res, http.StatusOK) +} + +func formatListResponseAsCsv(res *http.Response, okStatus int) error { + return processApiResponse(res, func(responseBytes []byte, statusCode int) error { + var err error + + if res.StatusCode != okStatus { + err = fmt.Errorf("command failed with status %d", res.StatusCode) + } + + if err == nil { + allWorkers := getAllResponse{} + + err = json.Unmarshal(responseBytes, &allWorkers) + if err != nil { + return nil + } + + writer := csv.NewWriter(cliOut) + + for _, wk := range allWorkers.Workers { + err = writer.Write([]string{ + wk.Key, wk.Action, wk.Description, fmt.Sprint(wk.Enabled), + }) + if err != nil { + return err + } + } + + writer.Flush() + + return writer.Error() + } else if len(responseBytes) > 0 { + // We will report the previous error, but we still want to display the response body + if _, writeErr := cliOut.Write(prettifyJson(responseBytes)); writeErr != nil { + log.Debug(fmt.Sprintf("Write error: %+v", writeErr)) + } + } + + return err + }) +} diff --git a/commands/list_cmd_test.go b/commands/list_cmd_test.go new file mode 100644 index 0000000..ca724cb --- /dev/null +++ b/commands/list_cmd_test.go @@ -0,0 +1,234 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +func TestListCommand(t *testing.T) { + tests := []struct { + name string + commandArgs []string + token string + serverBehavior listServerStubBehavior + wantErr error + assertOutput func(t *testing.T, content []byte) + }{ + { + name: "list", + serverBehavior: listServerStubBehavior{ + existingWorkers: []*model.WorkerDetails{ + { + Key: "wk-0", + Action: model.ActionAfterCreate, + Description: "run wk-0", + Enabled: true, + SourceCode: "export default async () => ({ 'S': 'OK'})", + }, + }, + }, + assertOutput: func(t *testing.T, content []byte) { + assert.Equalf(t, "wk-0,AFTER_CREATE,run wk-0,true", strings.TrimSpace(string(content)), "invalid csv received") + }, + }, + { + name: "list worker of type", + commandArgs: []string{"AFTER_CREATE"}, + serverBehavior: listServerStubBehavior{ + wantAction: "AFTER_CREATE", + existingWorkers: []*model.WorkerDetails{ + { + Key: "wk-0", + Action: model.ActionAfterCreate, + Description: "run wk-0", + Enabled: true, + SourceCode: "export default async () => ({ 'S': 'OK'})", + }, + { + Key: "wk-1", + Action: model.ActionBeforeDownload, + Description: "run wk-1", + Enabled: true, + SourceCode: "export default async () => ({ 'S': 'OK'})", + }, + }, + }, + assertOutput: func(t *testing.T, content []byte) { + assert.Equalf(t, "wk-0,AFTER_CREATE,run wk-0,true", strings.TrimSpace(string(content)), "invalid csv received") + }, + }, + { + name: "list for JSON", + commandArgs: []string{"--" + model.FlagJsonOutput}, + serverBehavior: listServerStubBehavior{ + existingWorkers: []*model.WorkerDetails{ + { + Key: "wk-1", + Action: model.ActionGenericEvent, + Description: "run wk-1", + Enabled: false, + SourceCode: "export default async () => ({ 'S': 'OK'})", + }, + }, + }, + assertOutput: func(t *testing.T, content []byte) { + workers := getAllResponse{} + require.NoError(t, json.Unmarshal(content, &workers)) + assert.Len(t, workers.Workers, 1) + assert.Equalf(t, "wk-1", workers.Workers[0].Key, "Key mismatch") + assert.Equalf(t, model.ActionGenericEvent, workers.Workers[0].Action, "Action mismatch") + assert.Equalf(t, "run wk-1", workers.Workers[0].Description, "Descritption mismatch") + assert.Equalf(t, false, workers.Workers[0].Enabled, "Enabled mismatch") + assert.Equalf(t, "export default async () => ({ 'S': 'OK'})", workers.Workers[0].SourceCode, "Source Code mismatch") + }, + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500"}, + serverBehavior: listServerStubBehavior{ + waitFor: 5 * time.Second, + }, + wantErr: errors.New("request timed out after 500ms"), + }, + { + name: "fails if invalid timeout", + commandArgs: []string{"--" + model.FlagTimeout, "abc"}, + wantErr: errors.New("invalid timeout provided"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetListCommand()) + + err := os.Setenv(model.EnvKeyServerUrl, newListServerStub(t, ctx, &tt.serverBehavior)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + if tt.token == "" && tt.serverBehavior.wantBearerToken == "" { + tt.token = t.Name() + tt.serverBehavior.wantBearerToken = t.Name() + } + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + var output bytes.Buffer + cliOut = &output + t.Cleanup(func() { + cliOut = os.Stdout + }) + + cmd := append([]string{"worker", "list"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + if tt.wantErr == nil { + assert.NoError(t, err) + } else { + assert.EqualError(t, tt.wantErr, err.Error()) + } + + if tt.assertOutput != nil { + tt.assertOutput(t, output.Bytes()) + } + }) + } +} + +var listUrlPattern = regexp.MustCompile(`^/worker/api/v1/workers(/[\S/]+)?$`) + +type listServerStubBehavior struct { + waitFor time.Duration + responseStatus int + wantBearerToken string + wantAction string + existingWorkers []*model.WorkerDetails +} + +type listServerStub struct { + t *testing.T + ctx context.Context + behavior *listServerStubBehavior +} + +func newListServerStub(t *testing.T, ctx context.Context, behavior *listServerStubBehavior) string { + stub := listServerStub{t: t, behavior: behavior, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *listServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + urlMatch := listUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(urlMatch) == 0 { + res.WriteHeader(http.StatusNotFound) + return + } + + if s.behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(s.behavior.waitFor): + } + } + + // Validate method + if http.MethodGet != req.Method { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+s.behavior.wantBearerToken { + res.WriteHeader(http.StatusForbidden) + return + } + + if s.behavior.responseStatus > 0 { + res.WriteHeader(s.behavior.responseStatus) + return + } + + var workers []*model.WorkerDetails + + if s.behavior.wantAction == "" { + workers = s.behavior.existingWorkers + } else { + for _, wk := range s.behavior.existingWorkers { + if wk.Action == s.behavior.wantAction { + workers = append(workers, wk) + } + } + } + + res.WriteHeader(http.StatusOK) + _, err := res.Write([]byte(mustJsonMarshal(s.t, getAllResponse{Workers: workers}))) + require.NoError(s.t, err) +} diff --git a/commands/list_event_cmd.go b/commands/list_event_cmd.go new file mode 100644 index 0000000..e308ee4 --- /dev/null +++ b/commands/list_event_cmd.go @@ -0,0 +1,29 @@ +package commands + +import ( + "net/http" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + + "github.com/jfrog/workers-cli/model" +) + +func GetListEventsCommand() components.Command { + return components.Command{ + Name: "list-event", + Description: "List available events.", + Aliases: []string{"le"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetTimeoutFlag(), + }, + Action: func(c *components.Context) error { + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + return callWorkerApiWithOutput(c, server.GetUrl(), server.GetAccessToken(), http.MethodGet, nil, http.StatusOK, "actions") + }, + } +} diff --git a/commands/list_event_cmd_test.go b/commands/list_event_cmd_test.go new file mode 100644 index 0000000..71cfab3 --- /dev/null +++ b/commands/list_event_cmd_test.go @@ -0,0 +1,162 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +func TestListEventCommand(t *testing.T) { + tests := []struct { + name string + commandArgs []string + token string + serverBehavior listEventServerStubBehavior + wantErr error + assertOutput func(t *testing.T, content []byte) + }{ + { + name: "list", + serverBehavior: listEventServerStubBehavior{ + events: []string{"A", "B", "C"}, + }, + assertOutput: func(t *testing.T, content []byte) { + var events []string + require.NoError(t, json.Unmarshal(content, &events)) + assert.ElementsMatch(t, []string{"A", "B", "C"}, events) + }, + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500"}, + serverBehavior: listEventServerStubBehavior{ + waitFor: 5 * time.Second, + }, + wantErr: errors.New("request timed out after 500ms"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetListEventsCommand()) + + err := os.Setenv(model.EnvKeyServerUrl, newListEventServerStub(t, ctx, &tt.serverBehavior)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + if tt.token == "" && tt.serverBehavior.wantBearerToken == "" { + tt.token = t.Name() + tt.serverBehavior.wantBearerToken = t.Name() + } + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + var output bytes.Buffer + cliOut = &output + t.Cleanup(func() { + cliOut = os.Stdout + }) + + cmd := append([]string{"worker", "list-event"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + if tt.wantErr == nil { + assert.NoError(t, err) + } else { + assert.EqualError(t, tt.wantErr, err.Error()) + } + + if tt.assertOutput != nil { + tt.assertOutput(t, output.Bytes()) + } + }) + } +} + +var listEventUrlPattern = regexp.MustCompile(`^/worker/api/v1/actions$`) + +type listEventServerStubBehavior struct { + waitFor time.Duration + responseStatus int + wantBearerToken string + events []string +} + +type listEventServerStub struct { + t *testing.T + ctx context.Context + behavior *listEventServerStubBehavior +} + +func newListEventServerStub(t *testing.T, ctx context.Context, behavior *listEventServerStubBehavior) string { + stub := listEventServerStub{t: t, behavior: behavior, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *listEventServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + urlMatch := listEventUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(urlMatch) == 0 { + res.WriteHeader(http.StatusNotFound) + return + } + + if s.behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(s.behavior.waitFor): + } + } + + // Validate method + if http.MethodGet != req.Method { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+s.behavior.wantBearerToken { + res.WriteHeader(http.StatusForbidden) + return + } + + if s.behavior.responseStatus > 0 { + res.WriteHeader(s.behavior.responseStatus) + return + } + + res.WriteHeader(http.StatusOK) + + resBytes, err := json.Marshal(s.behavior.events) + require.NoError(s.t, err) + + _, err = res.Write(resBytes) + require.NoError(s.t, err) +} diff --git a/commands/remove_cmd.go b/commands/remove_cmd.go new file mode 100644 index 0000000..0f5ab63 --- /dev/null +++ b/commands/remove_cmd.go @@ -0,0 +1,61 @@ +package commands + +import ( + "fmt" + "net/http" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/workers-cli/model" +) + +func GetRemoveCommand() components.Command { + return components.Command{ + Name: "undeploy", + Description: "Undeploy a worker", + Aliases: []string{"rm"}, + Flags: []components.Flag{ + plugins_common.GetServerIdFlag(), + model.GetTimeoutFlag(), + }, + Arguments: []components.Argument{ + model.GetWorkerKeyArgument(), + }, + Action: func(c *components.Context) error { + var workerKey string + + if len(c.Arguments) > 0 { + workerKey = c.Arguments[0] + } + + if workerKey == "" { + manifest, err := model.ReadManifest() + if err != nil { + return err + } + + if err = manifest.Validate(); err != nil { + return err + } + + workerKey = manifest.Name + } + + server, err := model.GetServerDetails(c) + if err != nil { + return err + } + + log.Info(fmt.Sprintf("Removing worker '%s' ...", workerKey)) + + err = callWorkerApiSilent(c, server.GetUrl(), server.GetAccessToken(), http.MethodDelete, nil, http.StatusNoContent, "workers", workerKey) + if err == nil { + log.Info(fmt.Sprintf("Worker '%s' removed", workerKey)) + } + + return err + }, + } +} diff --git a/commands/remove_cmd_test.go b/commands/remove_cmd_test.go new file mode 100644 index 0000000..058f8de --- /dev/null +++ b/commands/remove_cmd_test.go @@ -0,0 +1,176 @@ +package commands + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jfrog/workers-cli/model" +) + +func TestRemoveCommand(t *testing.T) { + tests := []struct { + name string + commandArgs []string + token string + workerAction string + workerName string + skipInit bool + serverBehavior removeServerStubBehavior + wantErr error + }{ + { + name: "undeploy from manifest", + workerAction: "BEFORE_UPLOAD", + workerName: "wk-0", + serverBehavior: removeServerStubBehavior{ + wantWorkerKey: "wk-0", + }, + }, + { + name: "undeploy from key", + workerName: "wk-1", + skipInit: true, + commandArgs: []string{"wk-1"}, + serverBehavior: removeServerStubBehavior{ + wantWorkerKey: "wk-1", + }, + }, + { + name: "fails if timeout exceeds", + commandArgs: []string{"--" + model.FlagTimeout, "500"}, + serverBehavior: removeServerStubBehavior{ + waitFor: 5 * time.Second, + }, + wantErr: errors.New("request timed out after 500ms"), + }, + { + name: "fails if invalid timeout", + commandArgs: []string{"--" + model.FlagTimeout, "abc"}, + wantErr: errors.New("invalid timeout provided"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelCtx) + + runCmd := createCliRunner(t, GetInitCommand(), GetRemoveCommand()) + + _, workerName := prepareWorkerDirForTest(t) + if tt.workerName != "" { + workerName = tt.workerName + } + + if !tt.skipInit { + workerAction := tt.workerAction + if workerAction == "" { + workerAction = "BEFORE_DOWNLOAD" + } + + err := runCmd("worker", "init", workerAction, workerName) + require.NoError(t, err) + } + + err := os.Setenv(model.EnvKeyServerUrl, newRemoveServerStub(t, ctx, &tt.serverBehavior)) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyServerUrl) + }) + + if tt.token == "" && tt.serverBehavior.wantBearerToken == "" { + tt.token = t.Name() + tt.serverBehavior.wantBearerToken = t.Name() + } + + err = os.Setenv(model.EnvKeyAccessToken, tt.token) + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(model.EnvKeyAccessToken) + }) + + cmd := append([]string{"worker", "undeploy"}, tt.commandArgs...) + + err = runCmd(cmd...) + + cancelCtx() + + if tt.wantErr == nil { + assert.NoError(t, err) + } else { + assert.EqualError(t, tt.wantErr, err.Error()) + } + }) + } +} + +var removeUrlPattern = regexp.MustCompile(`^/worker/api/v1/workers/([\S/]+)$`) + +type removeServerStubBehavior struct { + waitFor time.Duration + responseStatus int + wantBearerToken string + wantWorkerKey string +} + +type removeServerStub struct { + t *testing.T + ctx context.Context + behavior *removeServerStubBehavior +} + +func newRemoveServerStub(t *testing.T, ctx context.Context, behavior *removeServerStubBehavior) string { + stub := removeServerStub{t: t, behavior: behavior, ctx: ctx} + server := httptest.NewUnstartedServer(&stub) + t.Cleanup(server.Close) + server.Start() + return "http:" + "//" + server.Listener.Addr().String() +} + +func (s *removeServerStub) ServeHTTP(res http.ResponseWriter, req *http.Request) { + urlMatch := removeUrlPattern.FindAllStringSubmatch(req.URL.Path, -1) + if len(urlMatch) == 0 && len(urlMatch[0]) < 2 { + res.WriteHeader(http.StatusNotFound) + return + } + + if s.behavior.wantWorkerKey != "" && s.behavior.wantWorkerKey != urlMatch[0][1] { + res.WriteHeader(http.StatusBadRequest) + return + } + + if s.behavior.waitFor > 0 { + select { + case <-s.ctx.Done(): + return + case <-time.After(s.behavior.waitFor): + } + } + + if req.Method != http.MethodDelete { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Validate token + if req.Header.Get("authorization") != "Bearer "+s.behavior.wantBearerToken { + res.WriteHeader(http.StatusForbidden) + return + } + + if s.behavior.responseStatus > 0 { + res.WriteHeader(s.behavior.responseStatus) + return + } + + res.WriteHeader(http.StatusNoContent) +} diff --git a/commands/templates/AFTER_BUILD_INFO_SAVE.spec.ts_template b/commands/templates/AFTER_BUILD_INFO_SAVE.spec.ts_template new file mode 100644 index 0000000..d69488b --- /dev/null +++ b/commands/templates/AFTER_BUILD_INFO_SAVE.spec.ts_template @@ -0,0 +1,26 @@ +import { PlatformContext, AfterBuildInfoSaveRequest, PlatformClients, PlatformHttpClient, Status } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock(); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'proceed', + executionStatus: Status.STATUS_SUCCESS + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/AFTER_BUILD_INFO_SAVE.ts_template b/commands/templates/AFTER_BUILD_INFO_SAVE.ts_template new file mode 100644 index 0000000..87670ba --- /dev/null +++ b/commands/templates/AFTER_BUILD_INFO_SAVE.ts_template @@ -0,0 +1,24 @@ +import { PlatformContext, AfterBuildInfoSaveRequest, AfterBuildInfoSaveResponse, Status } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: AfterBuildInfoSaveRequest): Promise => { + try { + // The HTTP client facilitates calls to the JFrog Platform REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get("/artifactory/api/v1/system/readiness"); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + console.log("Artifactory ping success"); + } else { + console.warn(`Request was successful and returned status code : ${res.status}`); + } + } catch (error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + console.error(`Request failed with status code ${error.status || ""} caused by : ${error.message}`); + } + + return { + message: "proceed", + executionStatus: Status.STATUS_SUCCESS, + }; +}; diff --git a/commands/templates/AFTER_CREATE.spec.ts_template b/commands/templates/AFTER_CREATE.spec.ts_template new file mode 100644 index 0000000..f48a34a --- /dev/null +++ b/commands/templates/AFTER_CREATE.spec.ts_template @@ -0,0 +1,25 @@ +import { PlatformContext, AfterCreateRequest, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock(); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'proceed' + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/AFTER_CREATE.ts_template b/commands/templates/AFTER_CREATE.ts_template new file mode 100644 index 0000000..0890d78 --- /dev/null +++ b/commands/templates/AFTER_CREATE.ts_template @@ -0,0 +1,24 @@ +import { PlatformContext, AfterCreateRequest, AfterCreateResponse } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: AfterCreateRequest): Promise => { + + try { + // The HTTP client facilitates calls to the JFrog Platform REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get('/artifactory/api/v1/system/readiness'); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + console.log("Artifactory ping success"); + } else { + console.warn(`Request was successful and returned status code : ${ res.status }`); + } + } catch(error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + console.error(`Request failed with status code ${ error.status || '' } caused by : ${ error.message }`) + } + + return { + message: 'proceed', + } +} diff --git a/commands/templates/AFTER_DOWNLOAD.spec.ts_template b/commands/templates/AFTER_DOWNLOAD.spec.ts_template new file mode 100644 index 0000000..07da10c --- /dev/null +++ b/commands/templates/AFTER_DOWNLOAD.spec.ts_template @@ -0,0 +1,25 @@ +import { PlatformContext, AfterDownloadRequest, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock(); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'proceed' + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/AFTER_DOWNLOAD.ts_template b/commands/templates/AFTER_DOWNLOAD.ts_template new file mode 100644 index 0000000..dea36d4 --- /dev/null +++ b/commands/templates/AFTER_DOWNLOAD.ts_template @@ -0,0 +1,24 @@ +import { PlatformContext, AfterDownloadRequest, AfterDownloadResponse } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: AfterDownloadRequest): Promise => { + + try { + // The in-browser HTTP client facilitates making calls to the JFrog REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get('/artifactory/api/v1/system/readiness'); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + console.log("Artifactory ping success"); + } else { + console.warn(`Request was successful and returned status code : ${ res.status }`); + } + } catch(error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + console.error(`Request failed with status code ${ error.status || '' } caused by : ${ error.message }`) + } + + return { + message: 'proceed', + } +} diff --git a/commands/templates/AFTER_MOVE.spec.ts_template b/commands/templates/AFTER_MOVE.spec.ts_template new file mode 100644 index 0000000..b36b0e1 --- /dev/null +++ b/commands/templates/AFTER_MOVE.spec.ts_template @@ -0,0 +1,25 @@ +import { PlatformContext, AfterMoveRequest, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock(); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'proceed' + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/AFTER_MOVE.ts_template b/commands/templates/AFTER_MOVE.ts_template new file mode 100644 index 0000000..b20458b --- /dev/null +++ b/commands/templates/AFTER_MOVE.ts_template @@ -0,0 +1,23 @@ +import { PlatformContext, AfterMoveRequest, AfterMoveResponse } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: AfterMoveRequest): Promise => { + try { + // The in-browser HTTP client facilitates making calls to the JFrog REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get("/artifactory/api/v1/system/readiness"); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + console.log("Artifactory ping success"); + } else { + console.warn(`Request was successful and returned status code : ${res.status}`); + } + } catch (error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + console.error(`Request failed with status code ${error.status || ""} caused by : ${error.message}`); + } + + return { + message: "proceed", + }; +}; diff --git a/commands/templates/BEFORE_DOWNLOAD.spec.ts_template b/commands/templates/BEFORE_DOWNLOAD.spec.ts_template new file mode 100644 index 0000000..3b0ffe5 --- /dev/null +++ b/commands/templates/BEFORE_DOWNLOAD.spec.ts_template @@ -0,0 +1,26 @@ +import { PlatformContext, BeforeDownloadRequest, PlatformClients, PlatformHttpClient, DownloadStatus } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock(); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'Overwritten by worker-service if an error occurs.', + status: DownloadStatus.DOWNLOAD_PROCEED + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/BEFORE_DOWNLOAD.ts_template b/commands/templates/BEFORE_DOWNLOAD.ts_template new file mode 100644 index 0000000..15d237a --- /dev/null +++ b/commands/templates/BEFORE_DOWNLOAD.ts_template @@ -0,0 +1,30 @@ +import { PlatformContext, BeforeDownloadRequest, BeforeDownloadResponse, DownloadStatus } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: BeforeDownloadRequest): Promise => { + + let status: DownloadStatus = DownloadStatus.DOWNLOAD_UNSPECIFIED; + + try { + // The in-browser HTTP client facilitates making calls to the JFrog REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get('/artifactory/api/v1/system/readiness'); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + status = DownloadStatus.DOWNLOAD_PROCEED; + console.log("Artifactory ping success"); + } else { + status = DownloadStatus.DOWNLOAD_WARN; + console.warn(`Request is successful but returned status other than 200. Status code : ${ res.status }`); + } + } catch(error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + status = DownloadStatus.DOWNLOAD_STOP; + console.error(`Request failed with status code ${ error.status || '' } caused by : ${ error.message }`) + } + + return { + status, + message: 'Overwritten by worker-service if an error occurs.', + } +} diff --git a/commands/templates/BEFORE_UPLOAD.spec.ts_template b/commands/templates/BEFORE_UPLOAD.spec.ts_template new file mode 100644 index 0000000..ba62040 --- /dev/null +++ b/commands/templates/BEFORE_UPLOAD.spec.ts_template @@ -0,0 +1,29 @@ +import { PlatformContext, BeforeUploadRequest, PlatformClients, PlatformHttpClient, UploadStatus } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ status: 200 }) + }) + }) + }); + request = createMock({ + metadata: { repoPath: { key: 'my-repo', path: 'artifact.txt' } } + }); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + message: 'Overwritten by worker-service if an error occurs.', + status: UploadStatus.UPLOAD_PROCEED, + modifiedRepoPath: { key: 'my-repo', path: 'artifact.txt' } + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/BEFORE_UPLOAD.ts_template b/commands/templates/BEFORE_UPLOAD.ts_template new file mode 100644 index 0000000..9fe9e3c --- /dev/null +++ b/commands/templates/BEFORE_UPLOAD.ts_template @@ -0,0 +1,30 @@ +import { PlatformContext, BeforeUploadRequest, BeforeUploadResponse, UploadStatus } from 'jfrog-workers'; + +export default async (context: PlatformContext, data: BeforeUploadRequest): Promise => { + let status: UploadStatus = UploadStatus.UPLOAD_UNSPECIFIED; + + try { + // The in-browser HTTP client facilitates making calls to the JFrog REST APIs + //To call an external endpoint, use 'await context.clients.axios.get("https://foo.com")' + const res = await context.clients.platformHttp.get('/artifactory/api/v1/system/readiness'); + + // You should reach this part if the HTTP request status is successful (HTTP Status 399 or lower) + if (res.status === 200) { + status = UploadStatus.UPLOAD_PROCEED; + console.log("Artifactory ping success"); + } else { + status = UploadStatus.UPLOAD_WARN; + console.warn(`Request was successful but returned status other than 200. Status code : ${ res.status }`); + } + } catch(error) { + // The platformHttp client throws PlatformHttpClientError if the HTTP request status is 400 or higher + status = UploadStatus.UPLOAD_STOP; + console.error(`Request failed with status code ${ error.status || '' } caused by : ${ error.message }`) + } + + return { + status, + message: 'Overwritten by worker-service if an error occurs.', + modifiedRepoPath: data.metadata.repoPath + } +} diff --git a/commands/templates/GENERIC_EVENT.spec.ts_template b/commands/templates/GENERIC_EVENT.spec.ts_template new file mode 100644 index 0000000..6152a5f --- /dev/null +++ b/commands/templates/GENERIC_EVENT.spec.ts_template @@ -0,0 +1,34 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import runWorker from './worker'; + +describe("{{.WorkerName}} tests", () => { + let context: DeepMocked; + const request: void = undefined; + + beforeEach(() => { + context = createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue({ + status: 200, + data: [ + { type: 'a' }, + { type: 'a' }, + { type: 'b' }, + { type: 'c' } + ], + }) + }) + }) + }); + }) + + it('should run', async () => { + await expect(runWorker(context, request)).resolves.toEqual(expect.objectContaining({ + repositories: expect.objectContaining({ + a: 2, b: 1, c: 1 + }) + })) + }) +}); \ No newline at end of file diff --git a/commands/templates/GENERIC_EVENT.ts_template b/commands/templates/GENERIC_EVENT.ts_template new file mode 100644 index 0000000..7df0467 --- /dev/null +++ b/commands/templates/GENERIC_EVENT.ts_template @@ -0,0 +1,50 @@ +import { PlatformContext } from 'jfrog-workers'; + +type CustomPayload = void; +type CustomResponse = { + error: string | undefined, // Valued with the cause in case of error + repositories: Record, // A list that contains the number of repositories per repository type +}; +type RepoData = { + "key": string, + "type": string, + "description": string, + "url": string, + "packageType": string +}; + +// This worker returns the number of repositories for each repository type. +export default async (context: PlatformContext, data: CustomPayload): Promise => { + + const response = { + error: undefined, + repositories: {}, + }; + + try { + // Ref: https://jfrog.com/help/r/jfrog-rest-apis/get-repositories + const res = await context.clients.platformHttp.get('/artifactory/api/repositories'); + if (res.status === 200) { + const repositories: RepoData[] = res.data; + + // The number of repositories mapped by repository type + const repoCountRecord: Record = {}; + + repositories.forEach(repository => { + let count = repoCountRecord[repository.type] || 0; + repoCountRecord[repository.type] = ++count; + }); + + response.repositories = repoCountRecord; + console.log("Repository count success"); + } else { + response.error = `Request is successful but returned an unexpected status : ${ res.status }`; + console.warn(response.error); + } + } catch(error) { + response.error = `Request failed with status code ${ error.status || '' } caused by : ${ error.message }`; + console.error(response.error); + } + + return response; +} diff --git a/commands/templates/manifest.json_template b/commands/templates/manifest.json_template new file mode 100644 index 0000000..fcda59f --- /dev/null +++ b/commands/templates/manifest.json_template @@ -0,0 +1,15 @@ +{ + "name": "{{.WorkerName}}", + "description": "Run a script on {{.Action}}", +{{- if .HasCriteria }} + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": ["example-repo-local"] + } + }, +{{ end}} + "secrets": {}, + "sourceCodePath": "./worker.ts", + "action": "{{.Action}}", + "enabled": false +} \ No newline at end of file diff --git a/commands/templates/package.json_template b/commands/templates/package.json_template new file mode 100644 index 0000000..72a6790 --- /dev/null +++ b/commands/templates/package.json_template @@ -0,0 +1,45 @@ +{ + "name": "{{.WorkerName}}", + "description": "Run a script on {{.Action}}", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"{{.WorkerName}}\"" +{{- if .HasTests }}, + "test": "jest" +{{- end }} + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.4.0" +{{- if .HasTests }}, + "@golevelup/ts-jest": "^0.4.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-jasmine2": "^29.7.0", + "ts-jest": "^29.1.2" +{{- end }} + }{{- if .HasTests }}, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "rootDir": ".", + "testEnvironment": "node", + "clearMocks": true, + "maxConcurrency": 1, + "testRegex": "\\.spec\\.ts$", + "moduleDirectories": ["node_modules"], + "collectCoverageFrom": [ + "**/*.ts" + ], + "coverageDirectory": "../coverage", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRunner": "jest-jasmine2", + "verbose": true + } + {{- end }} +} diff --git a/commands/templates/tsconfig.json_template b/commands/templates/tsconfig.json_template new file mode 100644 index 0000000..8005cfb --- /dev/null +++ b/commands/templates/tsconfig.json_template @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "target": "es2017", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "allowJs": true + }, + "include": [ + "**/*.ts", + "node_modules/@types/**/*.d.ts" + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2a6231 --- /dev/null +++ b/go.mod @@ -0,0 +1,92 @@ +module github.com/jfrog/workers-cli + +require ( + github.com/jfrog/jfrog-cli-core/v2 v2.51.0 + github.com/jfrog/jfrog-client-go v1.40.1 + github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.22.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/CycloneDX/cyclonedx-go v0.8.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/c-bata/go-prompt v0.2.6 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // 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/emirpasic/gods v1.18.1 // indirect + github.com/forPelevin/gomoji v1.2.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-git/v5 v5.12.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jedib0t/go-pretty/v6 v6.5.7 // indirect + github.com/jfrog/archiver/v3 v3.6.0 // indirect + github.com/jfrog/build-info-go v1.9.26 // indirect + github.com/jfrog/gofrog v1.7.1 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-tty v0.0.5 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nwaples/rardecode v1.1.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/term v1.2.0-beta.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.2.2 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.18.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/ulikunitz/xz v0.5.12 // indirect + github.com/urfave/cli v1.22.14 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.20.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +go 1.22.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b307874 --- /dev/null +++ b/go.sum @@ -0,0 +1,317 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/CycloneDX/cyclonedx-go v0.8.0 h1:FyWVj6x6hoJrui5uRQdYZcSievw3Z32Z88uYzG/0D6M= +github.com/CycloneDX/cyclonedx-go v0.8.0/go.mod h1:K2bA+324+Og0X84fA8HhN2X066K7Bxz4rpMQ4ZhjtSk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +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/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +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/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/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= +github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +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/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/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/forPelevin/gomoji v1.2.0 h1:9k4WVSSkE1ARO/BWywxgEUBvR/jMnao6EZzrql5nxJ8= +github.com/forPelevin/gomoji v1.2.0/go.mod h1:8+Z3KNGkdslmeGZBC3tCrwMrcPy5GRzAD+gL9NAwMXg= +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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= +github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +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.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +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.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/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/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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/jedib0t/go-pretty/v6 v6.5.7 h1:28Z6UxnNyKCVISGdItMiCCc7A0mbDF+SYvgo3U8ZKuQ= +github.com/jedib0t/go-pretty/v6 v6.5.7/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/jfrog/archiver/v3 v3.6.0 h1:OVZ50vudkIQmKMgA8mmFF9S0gA47lcag22N13iV3F1w= +github.com/jfrog/archiver/v3 v3.6.0/go.mod h1:fCAof46C3rAXgZurS8kNRNdSVMKBbZs+bNNhPYxLldI= +github.com/jfrog/build-info-go v1.9.26 h1:1Ddc6+Ecvhc+UMnKhRVG1jGM6fYNwA49207azTBGBc8= +github.com/jfrog/build-info-go v1.9.26/go.mod h1:8T7/ajM9aGshvgpwCtXwIFpyF/R6CEn4W+/FLryNXWw= +github.com/jfrog/gofrog v1.7.1 h1:ME1Meg4hukAT/7X6HUQCVSe4DNjMZACCP8aCY37EW/w= +github.com/jfrog/gofrog v1.7.1/go.mod h1:X7bjfWoQDN0Z4FQGbE91j3gbPP7Urwzm4Z8tkvrlbRI= +github.com/jfrog/jfrog-cli-core/v2 v2.51.0 h1:nESbCpSTPZx1av0W9tdmWLxKaPSL1SaZinbZGtYNeFI= +github.com/jfrog/jfrog-cli-core/v2 v2.51.0/go.mod h1:064wSSHVI3ZIVi/a94yJqzs+ACM+9JK/u9tQ1sfTK6A= +github.com/jfrog/jfrog-client-go v1.40.1 h1:ISSSV7/IUS8R+QCPfH2lVKLburbv2Xn07fvNyDc17rI= +github.com/jfrog/jfrog-client-go v1.40.1/go.mod h1:FprEW0Sqhj6ZSFTFk9NCni+ovFAYMA3zCBmNX4hGXgQ= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= +github.com/mattn/go-tty v0.0.5 h1:s09uXI7yDbXzzTTfw3zonKFzwGkyYlgU3OMjqA0ddz4= +github.com/mattn/go-tty v0.0.5/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= +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/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= +github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= +github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= +github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uRtcvYAPLCF15qguo= +github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/vbauerster/mpb/v7 v7.5.3 h1:BkGfmb6nMrrBQDFECR/Q7RkKCw7ylMetCb4079CGs4w= +github.com/vbauerster/mpb/v7 v7.5.3/go.mod h1:i+h4QY6lmLvBNK2ah1fSreiw3ajskRlBp9AhY/PnuOE= +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-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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-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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +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.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.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.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +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.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= +golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +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-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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4b71128 --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "github.com/jfrog/jfrog-cli-core/v2/plugins" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + + "github.com/jfrog/workers-cli/commands" +) + +func main() { + plugins.PluginMain(getApp()) +} + +func getApp() components.App { + app := components.App{} + app.Name = "worker" + app.Description = "Tools for managing workers" + app.Version = "v1.0.0" + app.Commands = getCommands() + return app +} + +func getCommands() []components.Command { + return []components.Command{ + commands.GetInitCommand(), + commands.GetDryRunCommand(), + commands.GetDeployCommand(), + commands.GetExecuteCommand(), + commands.GetRemoveCommand(), + commands.GetListCommand(), + commands.GetAddSecretCommand(), + commands.GetListEventsCommand(), + } +} diff --git a/model/actions.go b/model/actions.go new file mode 100644 index 0000000..86d2f88 --- /dev/null +++ b/model/actions.go @@ -0,0 +1,40 @@ +package model + +import ( + "fmt" + "regexp" +) + +const ( + ActionUnspecified = "ACTION_UNSPECIFIED" + ActionBeforeDownload = "BEFORE_DOWNLOAD" + ActionAfterDownload = "AFTER_DOWNLOAD" + ActionBeforeUpload = "BEFORE_UPLOAD" + ActionAfterCreate = "AFTER_CREATE" + ActionAfterBuildInfoSave = "AFTER_BUILD_INFO_SAVE" + ActionAfterMove = "AFTER_MOVE" + ActionGenericEvent = "GENERIC_EVENT" +) + +var ( + actionsNames = fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s", ActionBeforeDownload, ActionAfterDownload, ActionBeforeUpload, ActionAfterCreate, ActionAfterBuildInfoSave, ActionAfterMove, ActionGenericEvent) + actionsNamesPattern = regexp.MustCompile("(" + actionsNames + ")") +) + +var actionsWithoutCriteria = map[string]any{ + ActionAfterBuildInfoSave: struct{}{}, + ActionGenericEvent: struct{}{}, +} + +func ActionNames() string { + return actionsNames +} + +func ActionNeedsCriteria(actionName string) bool { + _, doNotNeedCriteria := actionsWithoutCriteria[actionName] + return !doNotNeedCriteria +} + +func ActionIsValid(actionName string) bool { + return actionsNamesPattern.MatchString(actionName) +} diff --git a/model/actions_test.go b/model/actions_test.go new file mode 100644 index 0000000..78dd792 --- /dev/null +++ b/model/actions_test.go @@ -0,0 +1,37 @@ +package model + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionNames(t *testing.T) { + names := ActionNames() + require.NotEmpty(t, names) + matched, err := regexp.MatchString(`[A-B_|]+`, names) + require.NoError(t, err) + assert.True(t, matched) +} + +func TestActionNeedsCriteria(t *testing.T) { + for _, action := range strings.Split(ActionNames(), "|") { + t.Run(action, func(t *testing.T) { + assert.Equalf(t, action != "AFTER_BUILD_INFO_SAVE" && action != "GENERIC_EVENT", ActionNeedsCriteria(action), "ActionNeedsCriteria(%v)", action) + }) + } +} + +func TestActionIsValid(t *testing.T) { + t.Run("HACK_ME", func(t *testing.T) { + assert.Equalf(t, false, ActionIsValid("HACK_ME"), "ActionIsValid(%v)", "HACK_ME") + }) + for _, action := range strings.Split(ActionNames(), "|") { + t.Run(action, func(t *testing.T) { + assert.Equalf(t, true, ActionIsValid(action), "ActionIsValid(%v)", action) + }) + } +} diff --git a/model/flags.go b/model/flags.go new file mode 100644 index 0000000..cc376b1 --- /dev/null +++ b/model/flags.go @@ -0,0 +1,102 @@ +package model + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/jfrog/jfrog-client-go/utils/log" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" +) + +const ( + FlagForce = "force" + FlagNoTest = "no-test" + FlagEdit = "edit" + FlagNoSecrets = "no-secrets" + FlagJsonOutput = "json" + FlagTimeout = "timeout-ms" + defaultTimeoutMillis = 5000 +) + +var ( + EnvKeyServerUrl = "JFROG_WORKER_CLI_DEV_SERVER_URL" + EnvKeyAccessToken = "JFROG_WORKER_CLI_DEV_ACCESS_TOKEN" + EnvKeySecretsPassword = "JFROG_WORKER_CLI_DEV_SECRETS_PASSWORD" + EnvKeyAddSecretValue = "JFROG_WORKER_CLI_DEV_ADD_SECRET_VALUE" +) + +type intFlagProvider interface { + IsFlagSet(name string) bool + GetIntFlagValue(name string) (int, error) +} + +func GetJsonOutputFlag(description ...string) components.BoolFlag { + f := components.NewBoolFlag(FlagJsonOutput, "Whether to use a json output.", components.WithBoolDefaultValue(false)) + if len(description) > 0 && description[0] != "" { + f.Description = description[0] + } + return f +} + +func GetTimeoutFlag() components.StringFlag { + return components.NewStringFlag(FlagTimeout, "The request timeout in milliseconds", components.WithIntDefaultValue(defaultTimeoutMillis)) +} + +func GetNoSecretsFlag(description ...string) components.BoolFlag { + f := components.NewBoolFlag(FlagNoSecrets, "Do not use registered secrets.", components.WithBoolDefaultValue(false)) + if len(description) > 0 && description[0] != "" { + f.Description = description[0] + } + return f +} + +func GetNoTestFlag(description ...string) components.BoolFlag { + f := components.NewBoolFlag(FlagNoTest, "Do not generate tests.", components.WithBoolDefaultValue(false)) + if len(description) > 0 && description[0] != "" { + f.Description = description[0] + } + return f +} + +func GetWorkerKeyArgument() components.Argument { + return components.Argument{ + Name: "worker-key", + Optional: true, + Description: "The worker key. If not provided it will be read from the `manifest.json` in the current directory.", + } +} + +func GetJsonPayloadArgument() components.Argument { + return components.Argument{ + Name: "json-payload", + Description: "The json payload expected by the worker.\n\t\tUse '-' to read from standard input.\n\t\tUse '@' to read from a file located at .", + } +} + +func GetTimeoutParameter(c intFlagProvider) (time.Duration, error) { + if !c.IsFlagSet(FlagTimeout) { + return defaultTimeoutMillis * time.Millisecond, nil + } + value, err := c.GetIntFlagValue(FlagTimeout) + if err != nil { + log.Debug(fmt.Sprintf("Invalid timeout: %+v", err)) + return 0, errors.New("invalid timeout provided") + } + return time.Duration(value) * time.Millisecond, nil +} + +func GetServerDetails(c *components.Context) (*config.ServerDetails, error) { + serverUrlFromEnv, envHasServerUrl := os.LookupEnv(EnvKeyServerUrl) + accessTokenFromEnv, envHasAccessToken := os.LookupEnv(EnvKeyAccessToken) + + if envHasServerUrl && envHasAccessToken { + return &config.ServerDetails{Url: serverUrlFromEnv, AccessToken: accessTokenFromEnv}, nil + } + + return plugins_common.GetServerDetails(c) +} diff --git a/model/flags_test.go b/model/flags_test.go new file mode 100644 index 0000000..e46e0b1 --- /dev/null +++ b/model/flags_test.go @@ -0,0 +1,66 @@ +package model + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTimeoutParameter(t *testing.T) { + tests := []struct { + name string + flagProvider intFlagProvider + want time.Duration + wantErr string + }{ + { + name: "default", + flagProvider: intFlagProviderStub{}, + want: defaultTimeoutMillis * time.Millisecond, + }, + { + name: "valid", + flagProvider: intFlagProviderStub{FlagTimeout: {val: 150}}, + want: 150 * time.Millisecond, + }, + { + name: "invalid", + flagProvider: intFlagProviderStub{FlagTimeout: {err: errors.New("parse error")}}, + wantErr: "invalid timeout provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetTimeoutParameter(tt.flagProvider) + if tt.wantErr == "" { + require.NoError(t, err) + assert.Equalf(t, tt.want, got, "timeout mismatch") + } else { + assert.EqualError(t, err, tt.wantErr) + } + }) + } +} + +type intFlagProviderStub map[string]struct { + val int + err error +} + +func (p intFlagProviderStub) IsFlagSet(name string) bool { + _, isSet := p[name] + return isSet +} + +func (p intFlagProviderStub) GetIntFlagValue(name string) (int, error) { + f, isSet := p[name] + if isSet { + return f.val, f.err + } + return 0, fmt.Errorf("flag %s used but not provided", name) +} diff --git a/model/manifest.go b/model/manifest.go new file mode 100644 index 0000000..27dbdeb --- /dev/null +++ b/model/manifest.go @@ -0,0 +1,164 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type ArtifactFilterCriteria struct { + RepoKeys []string `json:"repoKeys,omitempty"` +} + +type FilterCriteria struct { + ArtifactFilterCriteria ArtifactFilterCriteria `json:"artifactFilterCriteria,omitempty"` +} + +type Secrets map[string]string + +type Manifest struct { + Name string `json:"name"` + Description string `json:"description"` + SourceCodePath string `json:"sourceCodePath"` + Action string `json:"action"` + Enabled bool `json:"enabled"` + Secrets Secrets `json:"secrets"` + FilterCriteria FilterCriteria `json:"filterCriteria,omitempty"` +} + +// ReadManifest reads a manifest from the working directory or from the directory provided as argument. +func ReadManifest(dir ...string) (*Manifest, error) { + manifestFile, err := getManifestFile(dir...) + if err != nil { + return nil, err + } + + log.Debug(fmt.Sprintf("Reading manifest from %s", manifestFile)) + + manifestBytes, err := os.ReadFile(manifestFile) + if err != nil { + return nil, err + } + + manifest := Manifest{} + + err = json.Unmarshal(manifestBytes, &manifest) + if err != nil { + return nil, err + } + + return &manifest, nil +} + +func getManifestFile(dir ...string) (string, error) { + var manifestFolder string + + if len(dir) > 0 { + manifestFolder = dir[0] + } else { + var err error + if manifestFolder, err = os.Getwd(); err != nil { + return "", err + } + } + + manifestFile := filepath.Join(manifestFolder, "manifest.json") + + return manifestFile, nil +} + +func (mf *Manifest) Save(dir ...string) error { + manifestFile, err := getManifestFile(dir...) + if err != nil { + return err + } + + writer, err := os.OpenFile(manifestFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm) + if err != nil { + return err + } + + defer func() { + closeErr := writer.Close() + if closeErr != nil { + if err == nil { + err = errors.Join(err, closeErr) + } else { + err = closeErr + } + } + }() + + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + err = encoder.Encode(mf) + + return err +} + +// ReadSourceCode reads the content of the file pointed by SourceCodePath +func (mf *Manifest) ReadSourceCode() (string, error) { + log.Debug(fmt.Sprintf("Reading source code from %s", mf.SourceCodePath)) + sourceBytes, err := os.ReadFile(mf.SourceCodePath) + if err != nil { + return "", err + } + return string(sourceBytes), nil +} + +func (mf *Manifest) Validate() error { + if mf.Name == "" { + return invalidManifestErr("missing name") + } + + if mf.SourceCodePath == "" { + return invalidManifestErr("missing source code path") + } + + if mf.Action == "" { + return invalidManifestErr("missing action") + } + + if !ActionIsValid(mf.Action) { + return invalidManifestErr(fmt.Sprintf("unknown action '%s' expecting one of %v", mf.Action, strings.Split(ActionNames(), "|"))) + } + + return nil +} + +func (mf *Manifest) DecryptSecrets(withPassword ...string) error { + if len(mf.Secrets) == 0 { + return nil + } + + var password string + if len(withPassword) > 0 { + password = withPassword[0] + } else { + var err error + password, err = ReadSecretPassword("Secrets Password: ") + if err != nil { + return err + } + } + + for name, value := range mf.Secrets { + clearValue, err := DecryptSecret(password, value) + if err != nil { + log.Debug(fmt.Sprintf("cannot decrypt secret '%s': %+v", name, err)) + return fmt.Errorf("cannot decrypt secret '%s', please check the manifest", name) + } + mf.Secrets[name] = clearValue + } + + return nil +} + +func invalidManifestErr(reason string) error { + return fmt.Errorf("invalid manifest: %s", reason) +} diff --git a/model/manifest_test.go b/model/manifest_test.go new file mode 100644 index 0000000..be295e4 --- /dev/null +++ b/model/manifest_test.go @@ -0,0 +1,275 @@ +package model + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var manifestSample = &Manifest{ + Name: "my-worker", + Description: "my worker description", + SourceCodePath: "./my-worker.ts", + Action: "BEFORE_DOWNLOAD", + Enabled: true, + Secrets: Secrets{ + "hidden1": "hidden1.value", + "hidden2": "hidden2.value", + }, + FilterCriteria: FilterCriteria{ + ArtifactFilterCriteria: ArtifactFilterCriteria{ + RepoKeys: []string{ + "my-repo-local", + }, + }, + }, +} + +func TestReadManifest(t *testing.T) { + tests := []struct { + name string + dirAsArg bool + sample *Manifest + assert func(t *testing.T, mf *Manifest, readErr error) + }{ + { + name: "in current dir", + sample: manifestSample, + assert: func(t *testing.T, mf *Manifest, readErr error) { + require.NoError(t, readErr) + assert.Equal(t, manifestSample, mf) + }, + }, + { + name: "with dir as argument", + sample: manifestSample, + dirAsArg: true, + assert: func(t *testing.T, mf *Manifest, readErr error) { + require.NoError(t, readErr) + assert.Equal(t, manifestSample, mf) + }, + }, + { + name: "with missing manifest", + assert: func(t *testing.T, mf *Manifest, readErr error) { + require.Error(t, readErr) + require.True(t, os.IsNotExist(readErr)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manifestFolder, err := os.MkdirTemp("", "wks-cli-*.manifest") + require.NoError(t, err) + + t.Cleanup(func() { + // We do not care about this error + _ = os.RemoveAll(manifestFolder) + }) + + if tt.sample != nil { + manifestBytes, err := json.Marshal(tt.sample) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(manifestFolder, "manifest.json"), manifestBytes, os.ModePerm) + require.NoError(t, err) + } + + var dirParams []string + if tt.dirAsArg { + dirParams = append(dirParams, manifestFolder) + } else { + err = os.Chdir(manifestFolder) + require.NoError(t, err) + } + + mf, err := ReadManifest(dirParams...) + + tt.assert(t, mf, err) + }) + } +} + +func TestManifest_ReadSourceCode(t *testing.T) { + tests := []struct { + name string + sourceCode string + manifest *Manifest + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "nominal case", + manifest: manifestSample, + sourceCode: "export async () => ({ status: 'SUCCESS' })", + want: "export async () => ({ status: 'SUCCESS' })", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.NoError(t, err) + return err == nil + }, + }, + { + name: "missing source file", + manifest: manifestSample, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + return false + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manifestFolder, err := os.MkdirTemp("", "wks-cli-*.source") + require.NoError(t, err) + + t.Cleanup(func() { + // We do not care about this error + _ = os.RemoveAll(manifestFolder) + }) + + if tt.sourceCode != "" { + err = os.WriteFile(filepath.Join(manifestFolder, tt.manifest.SourceCodePath), []byte(tt.sourceCode), os.ModePerm) + require.NoError(t, err) + } + + err = os.Chdir(manifestFolder) + require.NoError(t, err) + + got, err := manifestSample.ReadSourceCode() + if !tt.wantErr(t, err, "ReadSourceCode()") { + return + } + + assert.Equalf(t, tt.want, got, "ReadSourceCode()") + }) + } +} + +func TestManifest_Validate(t *testing.T) { + tests := []struct { + name string + manifest *Manifest + assert func(t *testing.T, err error) + }{ + { + name: "valid", + manifest: manifestSample, + assert: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "missing name", + manifest: patchedManifestSample(func(mf *Manifest) { + mf.Name = "" + }), + assert: func(t *testing.T, err error) { + assert.EqualError(t, err, invalidManifestErr("missing name").Error()) + }, + }, + { + name: "missing source code path", + manifest: patchedManifestSample(func(mf *Manifest) { + mf.SourceCodePath = "" + }), + assert: func(t *testing.T, err error) { + assert.EqualError(t, err, invalidManifestErr("missing source code path").Error()) + }, + }, + { + name: "missing action", + manifest: patchedManifestSample(func(mf *Manifest) { + mf.Action = "" + }), + assert: func(t *testing.T, err error) { + assert.EqualError(t, err, invalidManifestErr("missing action").Error()) + }, + }, + { + name: "invalid action", + manifest: patchedManifestSample(func(mf *Manifest) { + mf.Action = "HACK_ME" + }), + assert: func(t *testing.T, err error) { + assert.EqualError(t, err, invalidManifestErr(fmt.Sprintf("unknown action 'HACK_ME' expecting one of %v", strings.Split(ActionNames(), "|"))).Error()) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.assert(t, tt.manifest.Validate()) + }) + } +} + +func TestManifest_DecryptSecrets(t *testing.T) { + tests := []struct { + name string + encryptSecrets Secrets + verbatimSecrets Secrets + assert func(t *testing.T, mf *Manifest, err error) + }{ + { + name: "ok", + encryptSecrets: Secrets{ + "s1": "v1", + "s2": "v2", + }, + assert: func(t *testing.T, mf *Manifest, err error) { + require.NoError(t, err) + assert.Equal(t, Secrets{ + "s1": "v1", + "s2": "v2", + }, mf.Secrets) + }, + }, + { + name: "with cleartext secrets", + verbatimSecrets: Secrets{ + "s1": "v1", + }, + assert: func(t *testing.T, mf *Manifest, err error) { + assert.EqualError(t, err, "cannot decrypt secret 's1', please check the manifest") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := os.Setenv(EnvKeySecretsPassword, "P@ssw0rd!") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Unsetenv(EnvKeySecretsPassword) + }) + + mf := patchedManifestSample(func(mf *Manifest) { + mf.Secrets = Secrets{} + + var err error + for key, val := range tt.encryptSecrets { + mf.Secrets[key], err = EncryptSecret("P@ssw0rd!", val) + require.NoError(t, err) + } + + for key, val := range tt.verbatimSecrets { + mf.Secrets[key] = val + } + }) + + tt.assert(t, mf, mf.DecryptSecrets()) + }) + } +} + +func patchedManifestSample(patch func(mf *Manifest)) *Manifest { + patched := *manifestSample + patch(&patched) + return &patched +} diff --git a/model/secrets.go b/model/secrets.go new file mode 100644 index 0000000..f0b7fab --- /dev/null +++ b/model/secrets.go @@ -0,0 +1,138 @@ +package model + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + + "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" + "golang.org/x/crypto/scrypt" +) + +const ( + minPasswordLength = 12 + encryptionKeyLength = 32 +) + +type Secret struct { + Key string `json:"key"` + Value string `json:"value"` + MarkedForRemoval bool `json:"markedForRemoval"` +} + +func ReadSecretPassword(prompt ...string) (string, error) { + passwordFromEnv, passwordInEnv := os.LookupEnv(EnvKeySecretsPassword) + if passwordInEnv { + return passwordFromEnv, nil + } + + message := "Password: " + if len(prompt) > 0 { + message = prompt[0] + } + + password, err := ioutils.ScanPasswordFromConsole(message) + if err != nil { + return "", err + } + + err = validateSecretPassword(password) + if err == nil { + return password, nil + } + + return "", err +} + +func EncryptSecret(password string, secretValue string) (string, error) { + encryptionKey, salt, err := deriveKey([]byte(password), nil) + if err != nil { + return "", err + } + + blockCipher, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return "", err + } + + cipherBytes := gcm.Seal(nonce, nonce, []byte(secretValue), nil) + + cipherBytes = append(cipherBytes, salt...) + + return base64.StdEncoding.EncodeToString(cipherBytes), nil +} + +func DecryptSecret(password string, encryptedValue string) (string, error) { + encryptedBytes, err := base64.StdEncoding.DecodeString(encryptedValue) + if err != nil { + return "", err + } + + if len(encryptedBytes) < encryptionKeyLength { + return "", errors.New("invalid encrypted secret length") + } + + salt, data := encryptedBytes[len(encryptedBytes)-encryptionKeyLength:], encryptedBytes[:len(encryptedBytes)-encryptionKeyLength] + + encryptionKey, _, err := deriveKey([]byte(password), salt) + if err != nil { + return "", err + } + + blockCipher, err := aes.NewCipher(encryptionKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return "", err + } + + nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] + + clearTextBytes, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(clearTextBytes), nil +} + +// deriveKey Create a 32-bit key from any password. Needed to use AES +func deriveKey(password, salt []byte) ([]byte, []byte, error) { + if salt == nil { + salt = make([]byte, encryptionKeyLength) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + } + + key, err := scrypt.Key(password, salt, 16384, 8, 1, encryptionKeyLength) + if err != nil { + return nil, nil, err + } + + return key, salt, nil +} + +func validateSecretPassword(key string) error { + if len(key) < minPasswordLength { + return fmt.Errorf("a secret should have a minimum length of %d, got %d", minPasswordLength, len(key)) + } + return nil +} diff --git a/model/secrets_test.go b/model/secrets_test.go new file mode 100644 index 0000000..53f5c7c --- /dev/null +++ b/model/secrets_test.go @@ -0,0 +1,52 @@ +package model + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncryption(t *testing.T) { + const secretValue = "my-secret-value" + const password = "my-password" + + encrypted, err := EncryptSecret(password, secretValue) + require.NoError(t, err) + require.NotEqual(t, secretValue, encrypted) + + decrypted, err := DecryptSecret(password, encrypted) + require.NoError(t, err) + + assert.Equal(t, secretValue, decrypted) +} + +func Test_validateSecretPassword(t *testing.T) { + tests := []struct { + name string + password string + validationError string + }{ + { + name: "valid", + password: "/abcdef123456!", + }, + { + name: "too short", + password: "abcdef", + validationError: fmt.Sprintf("a secret should have a minimum length of %d, got 6", minPasswordLength), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSecretPassword(tt.password) + if tt.validationError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.validationError) + } + }) + } +} diff --git a/model/worker.go b/model/worker.go new file mode 100644 index 0000000..0a75def --- /dev/null +++ b/model/worker.go @@ -0,0 +1,11 @@ +package model + +type WorkerDetails struct { + Key string `json:"key"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + SourceCode string `json:"sourceCode"` + Action string `json:"action"` + FilterCriteria FilterCriteria `json:"filterCriteria,omitempty"` + Secrets []*Secret `json:"secrets"` +}