├── .github ├── CODEOWNERS └── workflows │ ├── ci.yaml │ ├── goreleaser.yml │ ├── trigger-docs.yml │ └── trigger-publish.yml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── temporal │ └── main.go ├── go.mod ├── go.sum ├── install.sh ├── install.test.sh └── temporalcli ├── client.go ├── commands.activity.go ├── commands.activity_test.go ├── commands.batch.go ├── commands.batch_test.go ├── commands.config.go ├── commands.config_test.go ├── commands.env.go ├── commands.env_test.go ├── commands.gen.go ├── commands.go ├── commands.operator_cluster.go ├── commands.operator_cluster_test.go ├── commands.operator_namespace.go ├── commands.operator_namespace_test.go ├── commands.operator_nexus.go ├── commands.operator_nexus_test.go ├── commands.operator_search_attribute.go ├── commands.operator_search_attribute_test.go ├── commands.schedule.go ├── commands.schedule_test.go ├── commands.server.go ├── commands.server_test.go ├── commands.taskqueue.go ├── commands.taskqueue_build_id_test.go ├── commands.taskqueue_get_build_id.go ├── commands.taskqueue_test.go ├── commands.taskqueue_update_build_ids.go ├── commands.taskqueue_versioning_rules.go ├── commands.taskqueue_versioning_rules_test.go ├── commands.worker.deployment.go ├── commands.worker.deployment_test.go ├── commands.workflow.go ├── commands.workflow_exec.go ├── commands.workflow_exec_test.go ├── commands.workflow_fix.go ├── commands.workflow_reset.go ├── commands.workflow_reset_test.go ├── commands.workflow_test.go ├── commands.workflow_trace.go ├── commands.workflow_trace_test.go ├── commands.workflow_view.go ├── commands.workflow_view_test.go ├── commands_test.go ├── commandsgen ├── code.go ├── commands.yml ├── docs.go └── parse.go ├── devserver ├── freeport.go ├── freeport_test.go ├── log.go └── server.go ├── duration.go ├── internal ├── cmd │ ├── gen-commands │ │ └── main.go │ └── gen-docs │ │ └── main.go ├── printer │ ├── printer.go │ ├── printer_test.go │ └── test │ │ └── main.go └── tracer │ ├── execution_icons.go │ ├── execution_state.go │ ├── execution_state_test.go │ ├── execution_test.go │ ├── execution_tmpls.go │ ├── tail_buffer.go │ ├── tail_buffer_test.go │ ├── templates │ ├── activity.tmpl │ ├── common.tmpl │ ├── timer.tmpl │ └── workflow.tmpl │ ├── term_size_unix.go │ ├── term_size_windows.go │ ├── term_writer.go │ ├── term_writer_test.go │ ├── workflow_execution_update.go │ ├── workflow_execution_update_test.go │ ├── workflow_state_worker.go │ └── workflow_tracer.go ├── payload.go ├── sqlite_test.go ├── strings.go └── timestamp.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @temporalio/sdk 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, macos-13, windows-latest, ubuntu-arm] 14 | include: 15 | - os: ubuntu-latest 16 | checkGenCodeTarget: true 17 | cloudTestTarget: true 18 | - os: ubuntu-arm 19 | runsOn: buildjet-4vcpu-ubuntu-2204-arm 20 | runs-on: ${{ matrix.runsOn || matrix.os }} 21 | env: 22 | # We can't check this directly in the cloud test's `if:` condition below, 23 | # so we have to check it here and report it in an env variable. 24 | HAS_SECRETS: ${{ secrets.TEMPORAL_CLIENT_CERT != '' && secrets.TEMPORAL_CLIENT_KEY != '' }} 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | submodules: recursive 30 | 31 | - name: Setup Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version-file: 'go.mod' 35 | 36 | - name: Install gotestsum 37 | run: go install gotest.tools/gotestsum@latest 38 | 39 | - name: Create junit-xml directory 40 | run: mkdir junit-xml 41 | 42 | - name: Test 43 | run: gotestsum --junitfile junit-xml/${{matrix.os}}.xml -- ./... 44 | 45 | - name: 'Upload junit-xml artifacts' 46 | uses: actions/upload-artifact@v4 47 | if: always() 48 | with: 49 | name: junit-xml--${{github.run_id}}--${{github.run_attempt}}--${{matrix.os}} 50 | path: junit-xml 51 | retention-days: 14 52 | 53 | - name: Regen code, confirm unchanged 54 | if: ${{ matrix.checkGenCodeTarget }} 55 | run: | 56 | go run ./temporalcli/internal/cmd/gen-commands 57 | git diff --exit-code 58 | 59 | - name: Test cloud mTLS 60 | if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} 61 | env: 62 | TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 63 | TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} 64 | TEMPORAL_TLS_CERT: client.crt 65 | TEMPORAL_TLS_CERT_CONTENT: ${{ secrets.TEMPORAL_CLIENT_CERT }} 66 | TEMPORAL_TLS_KEY: client.key 67 | TEMPORAL_TLS_KEY_CONTENT: ${{ secrets.TEMPORAL_CLIENT_KEY }} 68 | shell: bash 69 | run: | 70 | printf '%s\n' "$TEMPORAL_TLS_CERT_CONTENT" >> client.crt 71 | printf '%s\n' "$TEMPORAL_TLS_KEY_CONTENT" >> client.key 72 | go run ./cmd/temporal workflow list --limit 2 73 | 74 | - name: Test cloud API key env var 75 | if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} 76 | env: 77 | TEMPORAL_ADDRESS: us-west-2.aws.api.temporal.io:7233 78 | TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} 79 | TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} 80 | shell: bash 81 | run: go run ./cmd/temporal workflow list --limit 2 82 | 83 | - name: Test cloud API key arg 84 | if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }} 85 | env: 86 | TEMPORAL_ADDRESS: us-west-2.aws.api.temporal.io:7233 87 | TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} 88 | shell: bash 89 | run: go run ./cmd/temporal workflow list --limit 2 --api-key ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} 90 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 20 | with: 21 | go-version-file: "go.mod" 22 | check-latest: true 23 | 24 | - name: Get build date 25 | id: date 26 | run: echo "::set-output name=date::$(date '+%F-%T')" 27 | 28 | - name: Get build unix timestamp 29 | id: timestamp 30 | run: echo "::set-output name=timestamp::$(date '+%s')" 31 | 32 | - name: Get git branch 33 | id: branch 34 | run: echo "::set-output name=branch::$(git rev-parse --abbrev-ref HEAD)" 35 | 36 | - name: Get build platform 37 | id: platform 38 | run: echo "::set-output name=platform::$(go version | cut -d ' ' -f 4)" 39 | 40 | - name: Get Go version 41 | id: go 42 | run: echo "::set-output name=go::$(go version | cut -d ' ' -f 3)" 43 | 44 | - name: Run GoReleaser 45 | uses: goreleaser/goreleaser-action@336e29918d653399e599bfca99fadc1d7ffbc9f7 # v4.3.0 46 | with: 47 | version: v1.26.2 48 | args: release 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | BUILD_DATE: ${{ steps.date.outputs.date }} 52 | BUILD_TS_UNIX: ${{ steps.timestamp.outputs.timestamp }} 53 | GIT_BRANCH: ${{ steps.branch.outputs.branch }} 54 | BUILD_PLATFORM: ${{ steps.platform.outputs.platform }} 55 | GO_VERSION: ${{ steps.go.outputs.go }} 56 | -------------------------------------------------------------------------------- /.github/workflows/trigger-docs.yml: -------------------------------------------------------------------------------- 1 | name: Trigger CLI docs update 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [published] 6 | jobs: 7 | update: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | shell: bash 12 | steps: 13 | - name: Get user info from GitHub API 14 | id: get_user 15 | run: | 16 | echo "GitHub actor: ${{ github.actor }}" 17 | # Query the GitHub API for the user's details. 18 | curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 19 | https://api.github.com/users/${{ github.actor }} > user.json 20 | 21 | # Extract the user's full name if available, default to the username otherwise. 22 | git_name=$(jq -r '.name // empty' user.json) 23 | if [ -z "$git_name" ]; then 24 | git_name="${{ github.actor }}" 25 | fi 26 | 27 | git_email="${{ github.actor }}@users.noreply.github.com" 28 | 29 | # Set the outputs for subsequent steps. 30 | echo "GIT_NAME=$git_name" >> $GITHUB_OUTPUT 31 | echo "GIT_EMAIL=$git_email" >> $GITHUB_OUTPUT 32 | 33 | - name: Generate token 34 | id: generate_token 35 | uses: actions/create-github-app-token@v1 36 | with: 37 | app-id: ${{ secrets.TEMPORAL_CICD_APP_ID }} 38 | private-key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} 39 | owner: ${{ github.repository_owner }} 40 | repositories: documentation # generate a token with permissions to trigger GHA in documentation repo 41 | 42 | - name: Trigger Documentation Workflow 43 | env: 44 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 45 | run: | 46 | gh workflow run update-cli-docs.yml \ 47 | -R temporalio/documentation \ 48 | -r cli-docs-autoupdate \ 49 | -f cli_release_tag="${{ github.ref_name }}" \ 50 | -f commit_author="${{ steps.get_user.outputs.GIT_NAME }}" \ 51 | -f commit_author_email="${{ steps.get_user.outputs.GIT_EMAIL }}" \ 52 | -f commit_message="Update CLI docs for release ${{ github.ref_name }}" 53 | -------------------------------------------------------------------------------- /.github/workflows/trigger-publish.yml: -------------------------------------------------------------------------------- 1 | name: 'Trigger Docker image build' 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | trigger: 10 | if: ${{ ! contains(github.ref, '-rc.') }} 11 | name: 'trigger Docker image build' 12 | runs-on: ubuntu-latest 13 | 14 | defaults: 15 | run: 16 | shell: bash 17 | 18 | steps: 19 | - name: Generate a token 20 | id: generate_token 21 | uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 22 | with: 23 | app_id: ${{ secrets.TEMPORAL_CICD_APP_ID }} 24 | private_key: ${{ secrets.TEMPORAL_CICD_PRIVATE_KEY }} 25 | 26 | - name: Dispatch docker builds Github Action 27 | env: 28 | PAT: ${{ steps.generate_token.outputs.token }} 29 | PARENT_REPO: temporalio/docker-builds 30 | PARENT_BRANCH: ${{ toJSON('main') }} 31 | WORKFLOW_ID: update-submodules.yml 32 | REPO: ${{ toJSON('cli') }} 33 | BRANCH: ${{ toJSON('main') }} 34 | COMMIT: ${{ toJSON(github.sha) }} 35 | run: | 36 | curl -fL -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $PAT" "https://api.github.com/repos/$PARENT_REPO/actions/workflows/$WORKFLOW_ID/dispatches" -d '{"ref":'"$PARENT_BRANCH"', "inputs": { "repo":'"$REPO"', "branch":'"$BRANCH"', "commit": '"$COMMIT"' }}' 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | /temporal 3 | /temporal.exe 4 | 5 | # Used by IDE 6 | /.idea 7 | /.vscode 8 | /.zed 9 | *~ 10 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | release: 6 | prerelease: auto 7 | draft: false 8 | name_template: "v{{.Version}}" 9 | 10 | archives: 11 | - <<: &archive_defaults 12 | name_template: "temporal_cli_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 13 | id: nix 14 | builds: 15 | - nix 16 | format: tar.gz 17 | files: 18 | - LICENSE 19 | 20 | - <<: *archive_defaults 21 | id: windows-zip 22 | builds: 23 | - windows 24 | format: zip 25 | files: 26 | - LICENSE 27 | 28 | # used by SDKs as zip cannot be used by rust https://github.com/zip-rs/zip/issues/108 29 | - <<: *archive_defaults 30 | id: windows-targz 31 | builds: 32 | - windows 33 | files: 34 | - LICENSE 35 | 36 | builds: 37 | - <<: &build_defaults 38 | dir: cmd/temporal 39 | binary: temporal 40 | ldflags: 41 | - -s -w -X github.com/temporalio/cli/temporalcli.Version={{.Version}} 42 | goarch: 43 | - amd64 44 | - arm64 45 | env: 46 | - CGO_ENABLED=0 47 | id: nix 48 | goos: 49 | - linux 50 | - darwin 51 | 52 | - <<: *build_defaults 53 | id: windows 54 | goos: 55 | - windows 56 | hooks: 57 | post: # TODO sign Windows release 58 | 59 | checksum: 60 | name_template: "checksums.txt" 61 | algorithm: sha256 62 | 63 | changelog: 64 | skip: true 65 | 66 | announce: 67 | skip: "true" 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Building 4 | 5 | With the latest `go` version installed, simply run the following: 6 | 7 | go build ./cmd/temporal 8 | 9 | ## Testing 10 | 11 | Uses normal `go test`, e.g.: 12 | 13 | go test ./... 14 | 15 | See other tests for how to leverage things like the command harness and dev server suite. 16 | 17 | Example to run a single test case: 18 | 19 | go test ./... -run TestSharedServerSuite/TestOperator_SearchAttribute 20 | 21 | ## Adding/updating commands 22 | 23 | First, update [commands.yml](temporalcli/commandsgen/commands.yml) following the rules in that file. Then to regenerate the 24 | [commands.gen.go](temporalcli/commands.gen.go) file from code, simply run: 25 | 26 | go run ./temporalcli/internal/cmd/gen-commands 27 | 28 | This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement 29 | `run` on the new command in a separate file before it will compile. 30 | 31 | Once a command is updated, the CI will automatically generate new docs 32 | and create a PR in the Documentation repo with the corresponding updates. To generate these docs locally, run: 33 | 34 | go run ./temporalcli/internal/cmd/gen-docs 35 | 36 | This will auto-generate a new set of docs to `temporalcli/docs/`. If a new root command is added, a new file will be automatically generated, like `temporal activity` and `activity.mdx`. 37 | 38 | ## Inject additional build-time information 39 | 40 | To add build-time information to the version string printed by the binary, use 41 | 42 | go build -ldflags "-X github.com/temporalio/cli/temporalcli.buildInfo=" 43 | 44 | This can be useful if, for example, you've used a `replace` statement in go.mod pointing to a local directory. 45 | Note that inclusion of space characters in the value supplied via `-ldflags` is tricky. 46 | Here's an example that adds branch info from a local repo to the version string, and includes a space character: 47 | 48 | go build -ldflags "-X 'github.com/temporalio/cli/temporalcli.buildInfo=ServerBranch $(git -C ../temporal rev-parse --abbrev-ref HEAD)'" -o temporal ./cmd/temporal/main.go 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bookworm AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Copy everything 6 | COPY . ./ 7 | 8 | # Build 9 | RUN go build ./cmd/temporal 10 | 11 | # Use slim container for running 12 | FROM debian:bookworm-slim 13 | RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 14 | ca-certificates && \ 15 | rm -rf /var/lib/apt/lists/* 16 | 17 | # Copy binary 18 | COPY --from=builder /app/temporal /app/temporal 19 | 20 | # Set CLI as primary entrypoint 21 | ENTRYPOINT ["/app/temporal"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 Temporal Technologies Inc. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all gen build 2 | 3 | all: gen build 4 | 5 | gen: temporalcli/commands.gen.go 6 | 7 | temporalcli/commands.gen.go: temporalcli/commandsgen/commands.yml 8 | go run ./temporalcli/internal/cmd/gen-commands 9 | 10 | build: 11 | go build ./cmd/temporal 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Temporal CLI 2 | 3 | Temporal command-line interface and development server. 4 | 5 | **[DOCUMENTATION](https://docs.temporal.io/cli)** 6 | 7 | ## Quick Install 8 | 9 | Reference [the documentation](https://docs.temporal.io/cli) for detailed install information. 10 | 11 | ### Install via Homebrew 12 | 13 | brew install temporal 14 | 15 | ### Install via download 16 | 17 | 1. Download the version for your OS and architecture: 18 | - [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) 19 | - [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) 20 | - [macOS amd64](https://temporal.download/cli/archive/latest?platform=darwin&arch=amd64) 21 | - [macOS arm64](https://temporal.download/cli/archive/latest?platform=darwin&arch=arm64) (Apple silicon) 22 | - [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) 23 | 2. Extract the downloaded archive. 24 | 3. Add the `temporal` binary to your `PATH` (`temporal.exe` for Windows). 25 | 26 | ### Build 27 | 28 | 1. Install [Go](https://go.dev/) 29 | 2. Clone repository 30 | 3. Switch to cloned directory, and run `go build ./cmd/temporal` 31 | 32 | The executable will be at `temporal` (`temporal.exe` for Windows). 33 | 34 | ## Usage 35 | 36 | Reference [the documentation](https://docs.temporal.io/cli) for detailed usage information. 37 | 38 | ## Contributing 39 | 40 | See [CONTRIBUTING.md](CONTRIBUTING.md). 41 | -------------------------------------------------------------------------------- /cmd/temporal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/temporalio/cli/temporalcli" 7 | 8 | // Prevent the pinned version of sqlite driver from unintentionally changing 9 | // until https://gitlab.com/cznic/sqlite/-/issues/196 is resolved. 10 | _ "modernc.org/sqlite" 11 | // Embed time zone database as a fallback if platform database can't be found 12 | _ "time/tzdata" 13 | ) 14 | 15 | func main() { 16 | ctx, cancel := context.WithCancel(context.Background()) 17 | defer cancel() 18 | temporalcli.Execute(ctx, temporalcli.CommandOptions{}) 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/temporalio/cli 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/alitto/pond v1.9.2 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc 9 | github.com/dustin/go-humanize v1.0.1 10 | github.com/fatih/color v1.18.0 11 | github.com/google/uuid v1.6.0 12 | github.com/mattn/go-isatty v0.0.20 13 | github.com/nexus-rpc/sdk-go v0.3.0 14 | github.com/olekukonko/tablewriter v0.0.5 15 | github.com/spf13/cobra v1.9.1 16 | github.com/spf13/pflag v1.0.6 17 | github.com/stretchr/testify v1.10.0 18 | github.com/temporalio/ui-server/v2 v2.36.0 19 | go.temporal.io/api v1.46.0 20 | go.temporal.io/sdk v1.33.0 21 | go.temporal.io/sdk/contrib/envconfig v0.1.0 22 | go.temporal.io/server v1.27.1 23 | google.golang.org/grpc v1.71.0 24 | google.golang.org/protobuf v1.36.6 25 | gopkg.in/yaml.v3 v3.0.1 26 | modernc.org/sqlite v1.34.1 27 | ) 28 | 29 | require ( 30 | cel.dev/expr v0.22.1 // indirect 31 | cloud.google.com/go v0.120.0 // indirect 32 | cloud.google.com/go/auth v0.15.0 // indirect 33 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 34 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 35 | cloud.google.com/go/iam v1.4.2 // indirect 36 | cloud.google.com/go/monitoring v1.24.1 // indirect 37 | cloud.google.com/go/storage v1.51.0 // indirect 38 | filippo.io/edwards25519 v1.1.0 // indirect 39 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 40 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect 41 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect 42 | github.com/apache/thrift v0.21.0 // indirect 43 | github.com/aws/aws-sdk-go v1.55.6 // indirect 44 | github.com/benbjohnson/clock v1.3.5 // indirect 45 | github.com/beorn7/perks v1.0.1 // indirect 46 | github.com/blang/semver/v4 v4.0.0 // indirect 47 | github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c // indirect 48 | github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect 49 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 50 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 51 | github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect 52 | github.com/coreos/go-oidc/v3 v3.13.0 // indirect 53 | github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect 54 | github.com/emirpasic/gods v1.18.1 // indirect 55 | github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect 56 | github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect 57 | github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect 58 | github.com/felixge/httpsnoop v1.0.4 // indirect 59 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 60 | github.com/go-logr/logr v1.4.2 // indirect 61 | github.com/go-logr/stdr v1.2.2 // indirect 62 | github.com/go-sql-driver/mysql v1.9.1 // indirect 63 | github.com/gocql/gocql v1.7.0 // indirect 64 | github.com/gogo/protobuf v1.3.2 // indirect 65 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 66 | github.com/golang/mock v1.7.0-rc.1 // indirect 67 | github.com/golang/snappy v1.0.0 // indirect 68 | github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect 69 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 70 | github.com/google/s2a-go v0.1.9 // indirect 71 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 72 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 73 | github.com/gorilla/mux v1.8.1 // indirect 74 | github.com/gorilla/securecookie v1.1.2 // indirect 75 | github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect 76 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 77 | github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 78 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 79 | github.com/iancoleman/strcase v0.3.0 // indirect 80 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 81 | github.com/jackc/pgpassfile v1.0.0 // indirect 82 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 83 | github.com/jackc/pgx/v5 v5.7.4 // indirect 84 | github.com/jackc/puddle/v2 v2.2.2 // indirect 85 | github.com/jmespath/go-jmespath v0.4.0 // indirect 86 | github.com/jmoiron/sqlx v1.4.0 // indirect 87 | github.com/josharian/intern v1.0.0 // indirect 88 | github.com/klauspost/compress v1.18.0 // indirect 89 | github.com/labstack/echo/v4 v4.13.3 // indirect 90 | github.com/labstack/gommon v0.4.2 // indirect 91 | github.com/lib/pq v1.10.9 // indirect 92 | github.com/mailru/easyjson v0.9.0 // indirect 93 | github.com/mattn/go-colorable v0.1.14 // indirect 94 | github.com/mattn/go-runewidth v0.0.16 // indirect 95 | github.com/mitchellh/mapstructure v1.5.0 // indirect 96 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 97 | github.com/ncruces/go-strftime v0.1.9 // indirect 98 | github.com/olivere/elastic/v7 v7.0.32 // indirect 99 | github.com/opentracing/opentracing-go v1.2.0 // indirect 100 | github.com/pborman/uuid v1.2.1 // indirect 101 | github.com/pkg/errors v0.9.1 // indirect 102 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 103 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 104 | github.com/prometheus/client_golang v1.21.1 // indirect 105 | github.com/prometheus/client_model v0.6.1 // indirect 106 | github.com/prometheus/common v0.63.0 // indirect 107 | github.com/prometheus/procfs v0.16.0 // indirect 108 | github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect 109 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 110 | github.com/rivo/uniseg v0.4.7 // indirect 111 | github.com/robfig/cron v1.2.0 // indirect 112 | github.com/robfig/cron/v3 v3.0.1 // indirect 113 | github.com/sirupsen/logrus v1.9.3 // indirect 114 | github.com/sony/gobreaker v1.0.0 // indirect 115 | github.com/stretchr/objx v0.5.2 // indirect 116 | github.com/temporalio/ringpop-go v0.0.0-20250130211428-b97329e994f7 // indirect 117 | github.com/temporalio/sqlparser v0.0.0-20231115171017-f4060bcfa6cb // indirect 118 | github.com/temporalio/tchannel-go v1.22.1-0.20240528171429-1db37fdea938 // indirect 119 | github.com/twmb/murmur3 v1.1.8 // indirect 120 | github.com/uber-common/bark v1.3.0 // indirect 121 | github.com/uber-go/tally/v4 v4.1.17-0.20240412215630-22fe011f5ff0 // indirect 122 | github.com/valyala/bytebufferpool v1.0.0 // indirect 123 | github.com/valyala/fasttemplate v1.2.2 // indirect 124 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 125 | go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect 126 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 127 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 128 | go.opentelemetry.io/otel v1.35.0 // indirect 129 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect 130 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 131 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 132 | go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect 133 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 134 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 135 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 136 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 137 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 138 | go.temporal.io/version v0.3.0 // indirect 139 | go.uber.org/atomic v1.11.0 // indirect 140 | go.uber.org/dig v1.18.1 // indirect 141 | go.uber.org/fx v1.23.0 // indirect 142 | go.uber.org/mock v0.5.0 // indirect 143 | go.uber.org/multierr v1.11.0 // indirect 144 | go.uber.org/zap v1.27.0 // indirect 145 | golang.org/x/crypto v0.36.0 // indirect 146 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 147 | golang.org/x/net v0.38.0 // indirect 148 | golang.org/x/oauth2 v0.28.0 // indirect 149 | golang.org/x/sync v0.12.0 // indirect 150 | golang.org/x/sys v0.31.0 // indirect 151 | golang.org/x/text v0.23.0 // indirect 152 | golang.org/x/time v0.11.0 // indirect 153 | google.golang.org/api v0.228.0 // indirect 154 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 // indirect 155 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect 156 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 157 | gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect 158 | gopkg.in/inf.v0 v0.9.1 // indirect 159 | gopkg.in/validator.v2 v2.0.1 // indirect 160 | modernc.org/cc/v4 v4.25.2 // indirect 161 | modernc.org/gc/v2 v2.6.5 // indirect 162 | modernc.org/gc/v3 v3.0.0 // indirect 163 | modernc.org/libc v1.55.3 // indirect 164 | modernc.org/mathutil v1.7.1 // indirect 165 | modernc.org/memory v1.9.1 // indirect 166 | modernc.org/strutil v1.2.1 // indirect 167 | modernc.org/token v1.1.0 // indirect 168 | ) 169 | -------------------------------------------------------------------------------- /install.test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=dash 3 | 4 | set -e 5 | 6 | assert() { 7 | local _command 8 | _command="$1" 9 | local _string 10 | _string="$2" 11 | local _colored 12 | _colored="$3" 13 | 14 | if ! eval "$_command" | grep -q "$_string"; then 15 | local _status 16 | _status="$(failure "Assertion failed:" "$_colored")" 17 | printf "%s '%s' does not contain '%s'\n" "$_status" "$_command" "$_string" 18 | exit 1 19 | fi 20 | } 21 | 22 | failure() { 23 | local _string 24 | _string="$1" 25 | local _colored 26 | _colored="$2" 27 | 28 | if $_colored; then 29 | _string="\33[1;31m$_string\33[0m" 30 | fi 31 | 32 | echo "$_string" 33 | } 34 | 35 | success() { 36 | local _string="$1" 37 | local _colored="$2" 38 | 39 | if $_colored; then 40 | _string="\33[1;32m$_string\33[0m" 41 | fi 42 | 43 | echo "$_string" 44 | } 45 | 46 | main() { 47 | sh ./install.sh 48 | export PATH="$PATH:$HOME/.temporalio/bin" 49 | 50 | local _colored 51 | _colored=false 52 | if [ -t 2 ]; then 53 | if [ "${TERM+set}" = 'set' ]; then 54 | case "$TERM" in 55 | xterm* | rxvt* | urxvt* | linux* | vt*) 56 | # ansi escapes are valid 57 | _colored=true 58 | ;; 59 | esac 60 | fi 61 | fi 62 | 63 | assert "temporal -v" "temporal version" $_colored 64 | assert "sh ./install.sh --help" "Temporal CLI" $_colored 65 | 66 | local _status 67 | _status="$(success "Tests passed" $_colored)" 68 | printf "%s\n" "$_status" 69 | } 70 | 71 | main "$@" || exit 1 72 | -------------------------------------------------------------------------------- /temporalcli/commands.batch.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/temporalio/cli/temporalcli/internal/printer" 9 | "go.temporal.io/api/serviceerror" 10 | "go.temporal.io/api/workflowservice/v1" 11 | "go.temporal.io/sdk/client" 12 | "google.golang.org/protobuf/types/known/timestamppb" 13 | ) 14 | 15 | type ( 16 | batchDescribe struct { 17 | State string 18 | Type string 19 | StartTime time.Time 20 | CloseTime time.Time `cli:",cardOmitEmpty"` 21 | CompletedCount string 22 | FailureCount string 23 | } 24 | batchTableRow struct { 25 | JobId string 26 | State string 27 | StartTime time.Time 28 | CloseTime time.Time 29 | } 30 | ) 31 | 32 | func (c TemporalBatchDescribeCommand) run(cctx *CommandContext, args []string) error { 33 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 34 | if err != nil { 35 | return err 36 | } 37 | defer cl.Close() 38 | 39 | resp, err := cl.WorkflowService().DescribeBatchOperation(cctx, &workflowservice.DescribeBatchOperationRequest{ 40 | Namespace: c.Parent.Namespace, 41 | JobId: c.JobId, 42 | }) 43 | var notFound *serviceerror.NotFound 44 | if errors.As(err, ¬Found) { 45 | return fmt.Errorf("could not find Batch Job '%v'", c.JobId) 46 | } else if err != nil { 47 | return fmt.Errorf("failed to describe batch job: %w", err) 48 | } 49 | 50 | if cctx.JSONOutput { 51 | return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{}) 52 | } 53 | 54 | _ = cctx.Printer.PrintStructured(batchDescribe{ 55 | Type: resp.OperationType.String(), 56 | State: resp.State.String(), 57 | StartTime: toTime(resp.StartTime), 58 | CloseTime: toTime(resp.CloseTime), 59 | CompletedCount: fmt.Sprintf("%d/%d", resp.CompleteOperationCount, resp.TotalOperationCount), 60 | FailureCount: fmt.Sprintf("%d/%d", resp.FailureOperationCount, resp.TotalOperationCount), 61 | }, printer.StructuredOptions{}) 62 | 63 | return nil 64 | } 65 | 66 | func (c TemporalBatchListCommand) run(cctx *CommandContext, args []string) error { 67 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 68 | if err != nil { 69 | return err 70 | } 71 | defer cl.Close() 72 | 73 | // This is a listing command subject to json vs jsonl rules 74 | cctx.Printer.StartList() 75 | defer cctx.Printer.EndList() 76 | 77 | pageFetcher := c.pageFetcher(cctx, cl) 78 | var nextPageToken []byte 79 | var jobsProcessed int 80 | for pageIndex := 0; ; pageIndex++ { 81 | page, err := pageFetcher(nextPageToken) 82 | if err != nil { 83 | return fmt.Errorf("failed to list batch jobs: %w", err) 84 | } 85 | 86 | if pageIndex == 0 && len(page.GetOperationInfo()) == 0 { 87 | return nil 88 | } 89 | 90 | var textTable []batchTableRow 91 | for _, job := range page.GetOperationInfo() { 92 | if c.Limit > 0 && jobsProcessed >= c.Limit { 93 | break 94 | } 95 | jobsProcessed++ 96 | // For JSON we are going to dump one line of JSON per execution 97 | if cctx.JSONOutput { 98 | _ = cctx.Printer.PrintStructured(job, printer.StructuredOptions{}) 99 | } else { 100 | // For non-JSON, we are doing a table for each page 101 | textTable = append(textTable, batchTableRow{ 102 | JobId: job.JobId, 103 | State: job.State.String(), 104 | StartTime: toTime(job.StartTime), 105 | CloseTime: toTime(job.CloseTime), 106 | }) 107 | } 108 | } 109 | // Print table, headers only on first table 110 | if len(textTable) > 0 { 111 | _ = cctx.Printer.PrintStructured(textTable, printer.StructuredOptions{ 112 | Table: &printer.TableOptions{NoHeader: pageIndex > 0}, 113 | }) 114 | } 115 | // Stop if next page token non-existing or list reached limit 116 | nextPageToken = page.GetNextPageToken() 117 | if len(nextPageToken) == 0 || (c.Limit > 0 && jobsProcessed >= c.Limit) { 118 | return nil 119 | } 120 | } 121 | } 122 | 123 | func (c *TemporalBatchListCommand) pageFetcher( 124 | cctx *CommandContext, 125 | cl client.Client, 126 | ) func(next []byte) (*workflowservice.ListBatchOperationsResponse, error) { 127 | return func(next []byte) (*workflowservice.ListBatchOperationsResponse, error) { 128 | return cl.WorkflowService().ListBatchOperations(cctx, &workflowservice.ListBatchOperationsRequest{ 129 | Namespace: c.Parent.Namespace, 130 | NextPageToken: next, 131 | }) 132 | } 133 | } 134 | 135 | func (c TemporalBatchTerminateCommand) run(cctx *CommandContext, args []string) error { 136 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 137 | if err != nil { 138 | return err 139 | } 140 | defer cl.Close() 141 | 142 | _, err = cl.WorkflowService().StopBatchOperation(cctx, &workflowservice.StopBatchOperationRequest{ 143 | Namespace: c.Parent.Namespace, 144 | JobId: c.JobId, 145 | Reason: c.Reason, 146 | Identity: clientIdentity(), 147 | }) 148 | 149 | var notFound *serviceerror.NotFound 150 | if errors.As(err, ¬Found) { 151 | return fmt.Errorf("could not find Batch Job '%v'", c.JobId) 152 | } else if err != nil { 153 | return fmt.Errorf("failed to terminate batch job: %w", err) 154 | } 155 | 156 | cctx.Printer.Printlnf("Terminated Batch Job '%v'", c.JobId) 157 | 158 | return nil 159 | } 160 | 161 | // Converts the timestamp to Go's native time.Time. 162 | // Returns the zero time.Time value for nil timestamp. 163 | func toTime(timestamp *timestamppb.Timestamp) (t time.Time) { 164 | if timestamp != nil { 165 | t = timestamp.AsTime() 166 | } 167 | return 168 | } 169 | -------------------------------------------------------------------------------- /temporalcli/commands.batch_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/davecgh/go-spew/spew" 12 | "go.temporal.io/api/batch/v1" 13 | "go.temporal.io/api/workflowservice/v1" 14 | ) 15 | 16 | func (s *SharedServerSuite) TestBatchJob_Describe() { 17 | s.t.Run("non-existing job id", func(t *testing.T) { 18 | t.Run("as text", func(t *testing.T) { 19 | res := s.Execute( 20 | "batch", "describe", 21 | "--address", s.Address(), 22 | "--job-id", "not-found") 23 | s.EqualError(res.Err, "could not find Batch Job 'not-found'") 24 | }) 25 | 26 | t.Run("as json", func(t *testing.T) { 27 | res := s.Execute( 28 | "batch", "describe", 29 | "--address", s.Address(), 30 | "--job-id", "not-found", 31 | "-o", "json") 32 | s.EqualError(res.Err, "could not find Batch Job 'not-found'") 33 | }) 34 | }) 35 | 36 | s.t.Run("existing job id", func(t *testing.T) { 37 | // kickstart batch job (using Client directly to provide a `JobId`) 38 | jobId := "TestBatchJob_Describe" 39 | s.startBatchJob(jobId, s.Namespace()) 40 | 41 | t.Run("as text", func(t *testing.T) { 42 | res := s.Execute( 43 | "batch", "describe", 44 | "--address", s.Address(), 45 | "--job-id", jobId) 46 | s.NoError(res.Err) 47 | s.Empty(res.Stderr.String()) 48 | 49 | out := res.Stdout.String() 50 | s.Regexp("State[ \t]+(Running|Completed)", out) 51 | s.ContainsOnSameLine(out, "Type", "Terminate") 52 | s.ContainsOnSameLine(out, "CompletedCount", "0/0") 53 | s.ContainsOnSameLine(out, "FailureCount", "0/0") 54 | }) 55 | 56 | t.Run("as json", func(t *testing.T) { 57 | res := s.Execute( 58 | "batch", "describe", 59 | "--address", s.Address(), 60 | "--job-id", jobId, 61 | "-o", "json") 62 | s.NoError(res.Err) 63 | s.Empty(res.Stderr.String()) 64 | 65 | spew.Dump(res.Stdout.String()) 66 | 67 | var jsonOut map[string]any 68 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 69 | s.Equal(jobId, jsonOut["jobId"]) 70 | s.Equal("BATCH_OPERATION_TYPE_TERMINATE", jsonOut["operationType"]) 71 | s.Equal("REASON", jsonOut["reason"]) 72 | }) 73 | }) 74 | } 75 | 76 | func (s *SharedServerSuite) TestBatchJob_List() { 77 | // NOTE: this test is the only test to use the "batch-empty" namespace; 78 | // ie it is guaranteed to be empty at the start 79 | 80 | s.t.Run("no batch jobs", func(t *testing.T) { 81 | t.Run("as text", func(t *testing.T) { 82 | res := s.Execute( 83 | "batch", "list", 84 | "--namespace", "batch-empty", 85 | "--address", s.Address()) 86 | s.NoError(res.Err) 87 | s.Empty(res.Stderr.String()) 88 | s.Empty(res.Stdout.String()) 89 | }) 90 | 91 | t.Run("as json", func(t *testing.T) { 92 | res := s.Execute( 93 | "batch", "list", 94 | "--address", s.Address(), 95 | "--namespace", "batch-empty", 96 | "-o", "json", 97 | ) 98 | s.NoError(res.Err) 99 | s.Empty(res.Stderr.String()) 100 | s.Equal("[\n]\n", res.Stdout.String()) 101 | }) 102 | }) 103 | 104 | s.t.Run("a few batch jobs", func(t *testing.T) { 105 | // start a few batch jobs 106 | for i := 0; i < 3; i++ { 107 | s.startBatchJob(fmt.Sprintf("TestBatchJob_List_%d", i), "batch-empty") 108 | } 109 | 110 | t.Run("as text", func(t *testing.T) { 111 | var res *CommandResult 112 | s.Eventually(func() bool { 113 | res = s.Execute( 114 | "batch", "list", 115 | "--address", s.Address(), 116 | "--namespace", "batch-empty") 117 | s.NoError(res.Err) 118 | s.Empty(res.Stderr.String()) 119 | return strings.Count(res.Stdout.String(), "Completed") == 3 120 | }, 5*time.Second, 100*time.Millisecond) 121 | 122 | out := res.Stdout.String() 123 | s.Equal(4, strings.Count(out, "\n"), "expect 3 data rows + 1 header row") 124 | s.ContainsOnSameLine(out, "JobId", "State", "StartTime", "CloseTime") // header 125 | s.ContainsOnSameLine(out, "TestBatchJob_List_2", "Completed") 126 | s.ContainsOnSameLine(out, "TestBatchJob_List_1", "Completed") 127 | s.ContainsOnSameLine(out, "TestBatchJob_List_0", "Completed") 128 | }) 129 | 130 | t.Run("as text with limit", func(t *testing.T) { 131 | res := s.Execute( 132 | "batch", "list", 133 | "--address", s.Address(), 134 | "--namespace", "batch-empty", 135 | "--limit", "1") 136 | s.NoError(res.Err) 137 | s.Empty(res.Stderr.String()) 138 | 139 | out := res.Stdout.String() 140 | s.Equal(2, strings.Count(out, "\n"), "expect 1 data row + 1 header row") 141 | s.ContainsOnSameLine(out, "JobId", "State", "StartTime", "CloseTime") // header 142 | }) 143 | 144 | t.Run("as json", func(t *testing.T) { 145 | res := s.Execute( 146 | "batch", "list", 147 | "--address", s.Address(), 148 | "--namespace", "batch-empty", 149 | "-o", "json") 150 | s.NoError(res.Err) 151 | s.Empty(res.Stderr.String()) 152 | 153 | out := res.Stdout.String() 154 | s.ContainsOnSameLine(out, "\"jobId\": \"TestBatchJob_List_2\"") 155 | s.ContainsOnSameLine(out, "\"jobId\": \"TestBatchJob_List_1\"") 156 | s.ContainsOnSameLine(out, "\"jobId\": \"TestBatchJob_List_0\"") 157 | }) 158 | }) 159 | } 160 | 161 | func (s *SharedServerSuite) TestBatchJob_Terminate() { 162 | s.t.Run("non-existing job id", func(t *testing.T) { 163 | t.Run("as text", func(t *testing.T) { 164 | res := s.Execute( 165 | "batch", "terminate", 166 | "--address", s.Address(), 167 | "--job-id", "not-found", 168 | "--reason", "testing") 169 | s.EqualError(res.Err, "could not find Batch Job 'not-found'") 170 | }) 171 | 172 | t.Run("as json", func(t *testing.T) { 173 | res := s.Execute( 174 | "batch", "terminate", 175 | "--address", s.Address(), 176 | "--job-id", "not-found", 177 | "--reason", "testing", 178 | "-o", "json") 179 | s.EqualError(res.Err, "could not find Batch Job 'not-found'") 180 | }) 181 | }) 182 | 183 | s.t.Run("existing job id", func(t *testing.T) { 184 | t.Run("as text", func(t *testing.T) { 185 | jobId := "TestBatchJob_Terminate_Text" 186 | s.startBatchJob(jobId, s.Namespace()) 187 | 188 | var res *CommandResult 189 | s.Eventually(func() bool { 190 | res = s.Execute( 191 | "batch", "terminate", 192 | "--address", s.Address(), 193 | "--job-id", jobId, 194 | "--reason", "testing") 195 | return res.Err == nil 196 | }, 5*time.Second, 100*time.Millisecond) 197 | 198 | s.Empty(res.Stderr.String()) 199 | s.Equal("Terminated Batch Job '"+jobId+"'\n", res.Stdout.String()) 200 | }) 201 | 202 | t.Run("as json", func(t *testing.T) { 203 | jobId := "TestBatchJob_Terminate_JSON" 204 | s.startBatchJob(jobId, s.Namespace()) 205 | 206 | var res *CommandResult 207 | s.Eventually(func() bool { 208 | res = s.Execute( 209 | "batch", "terminate", 210 | "--address", s.Address(), 211 | "--job-id", jobId, 212 | "--reason", "testing", 213 | "-o", "json") 214 | return res.Err == nil 215 | }, 5*time.Second, 100*time.Millisecond) 216 | 217 | s.Empty(res.Stderr.String()) 218 | s.Empty(res.Stdout.String()) 219 | }) 220 | }) 221 | } 222 | 223 | // kickstart batch job (using Client directly to provide a `JobId`) 224 | func (s *SharedServerSuite) startBatchJob(jobId, namespace string) { 225 | _, err := s.Client.WorkflowService().StartBatchOperation( 226 | context.Background(), 227 | &workflowservice.StartBatchOperationRequest{ 228 | JobId: jobId, 229 | Namespace: namespace, 230 | VisibilityQuery: "WorkflowType=\"BATCH-TEST\"", 231 | Reason: "REASON", 232 | Operation: &workflowservice.StartBatchOperationRequest_TerminationOperation{ 233 | TerminationOperation: &batch.BatchOperationTermination{}, 234 | }, 235 | }, 236 | ) 237 | s.NoError(err) 238 | } 239 | -------------------------------------------------------------------------------- /temporalcli/commands.env.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/temporalio/cli/temporalcli/internal/printer" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func (c *TemporalEnvCommand) envNameAndKey(cctx *CommandContext, args []string, keyFlag string) (string, string, error) { 15 | if len(args) > 0 { 16 | cctx.Logger.Warn("Arguments to env commands are deprecated; please use --env and --key (or -k) instead") 17 | 18 | if c.Parent.Env != "default" || keyFlag != "" { 19 | return "", "", fmt.Errorf("cannot specify both an argument and flags; please use flags instead") 20 | } 21 | 22 | keyPieces := strings.Split(args[0], ".") 23 | switch len(keyPieces) { 24 | case 0: 25 | return "", "", fmt.Errorf("no env or property name specified") 26 | case 1: 27 | return keyPieces[0], "", nil 28 | case 2: 29 | return keyPieces[0], keyPieces[1], nil 30 | default: 31 | return "", "", fmt.Errorf("property name may not contain dots") 32 | } 33 | } 34 | 35 | if strings.Contains(keyFlag, ".") { 36 | return "", "", fmt.Errorf("property name may not contain dots") 37 | } 38 | 39 | return c.Parent.Env, keyFlag, nil 40 | } 41 | 42 | func (c *TemporalEnvDeleteCommand) run(cctx *CommandContext, args []string) error { 43 | envName, key, err := c.Parent.envNameAndKey(cctx, args, c.Key) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | // Env is guaranteed to already be present 49 | env, _ := cctx.DeprecatedEnvConfigValues[envName] 50 | // User can remove single flag or all in env 51 | if key != "" { 52 | cctx.Logger.Info("Deleting env property", "env", envName, "property", key) 53 | delete(env, key) 54 | } else { 55 | cctx.Logger.Info("Deleting env", "env", env) 56 | delete(cctx.DeprecatedEnvConfigValues, envName) 57 | } 58 | return writeDeprecatedEnvConfigToFile(cctx) 59 | } 60 | 61 | func (c *TemporalEnvGetCommand) run(cctx *CommandContext, args []string) error { 62 | envName, key, err := c.Parent.envNameAndKey(cctx, args, c.Key) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // Env is guaranteed to already be present 68 | env, _ := cctx.DeprecatedEnvConfigValues[envName] 69 | type prop struct { 70 | Property string `json:"property"` 71 | Value string `json:"value"` 72 | } 73 | var props []prop 74 | // User can ask for single flag or all in env 75 | if key != "" { 76 | props = []prop{{Property: key, Value: env[key]}} 77 | } else { 78 | props = make([]prop, 0, len(env)) 79 | for k, v := range env { 80 | props = append(props, prop{Property: k, Value: v}) 81 | } 82 | sort.Slice(props, func(i, j int) bool { return props[i].Property < props[j].Property }) 83 | } 84 | // Print as table 85 | return cctx.Printer.PrintStructured(props, printer.StructuredOptions{Table: &printer.TableOptions{}}) 86 | } 87 | 88 | func (c *TemporalEnvListCommand) run(cctx *CommandContext, args []string) error { 89 | type env struct { 90 | Name string `json:"name"` 91 | } 92 | envs := make([]env, 0, len(cctx.DeprecatedEnvConfigValues)) 93 | for k := range cctx.DeprecatedEnvConfigValues { 94 | envs = append(envs, env{Name: k}) 95 | } 96 | // Print as table 97 | return cctx.Printer.PrintStructured(envs, printer.StructuredOptions{Table: &printer.TableOptions{}}) 98 | } 99 | 100 | func (c *TemporalEnvSetCommand) run(cctx *CommandContext, args []string) error { 101 | envName, key, err := c.Parent.envNameAndKey(cctx, args, c.Key) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | if key == "" { 107 | return fmt.Errorf("property name must be specified with -k") 108 | } 109 | 110 | value := c.Value 111 | switch len(args) { 112 | case 0: 113 | // Use what's in the flag 114 | case 1: 115 | // We got an "env.name" argument passed above, but no "value" argument 116 | return fmt.Errorf("no value provided; see --help") 117 | case 2: 118 | // Old-style syntax; pull the value out of the args 119 | value = args[1] 120 | default: 121 | // Cobra should catch this / we should never get here; included for 122 | // completeness anyway. 123 | return fmt.Errorf("too many arguments provided; see --help") 124 | } 125 | 126 | if cctx.DeprecatedEnvConfigValues == nil { 127 | cctx.DeprecatedEnvConfigValues = map[string]map[string]string{} 128 | } 129 | if cctx.DeprecatedEnvConfigValues[envName] == nil { 130 | cctx.DeprecatedEnvConfigValues[envName] = map[string]string{} 131 | } 132 | cctx.Logger.Info("Setting env property", "env", envName, "property", key, "value", value) 133 | cctx.DeprecatedEnvConfigValues[envName][key] = value 134 | return writeDeprecatedEnvConfigToFile(cctx) 135 | } 136 | 137 | func writeDeprecatedEnvConfigToFile(cctx *CommandContext) error { 138 | if cctx.Options.DeprecatedEnvConfig.EnvConfigFile == "" { 139 | return fmt.Errorf("unable to find place for env file (unknown HOME dir)") 140 | } 141 | cctx.Logger.Info("Writing env file", "file", cctx.Options.DeprecatedEnvConfig.EnvConfigFile) 142 | return writeDeprecatedEnvConfigFile(cctx.Options.DeprecatedEnvConfig.EnvConfigFile, cctx.DeprecatedEnvConfigValues) 143 | } 144 | 145 | // May be empty result if can't get user home dir 146 | func defaultDeprecatedEnvConfigFile(appName, configName string) string { 147 | // No env file if no $HOME 148 | if dir, err := os.UserHomeDir(); err == nil { 149 | return filepath.Join(dir, ".config", appName, configName+".yaml") 150 | } 151 | return "" 152 | } 153 | 154 | func readDeprecatedEnvConfigFile(file string) (env map[string]map[string]string, err error) { 155 | b, err := os.ReadFile(file) 156 | if os.IsNotExist(err) { 157 | return nil, nil 158 | } else if err != nil { 159 | return nil, fmt.Errorf("failed reading env file: %w", err) 160 | } 161 | var m map[string]map[string]map[string]string 162 | if err := yaml.Unmarshal(b, &m); err != nil { 163 | return nil, fmt.Errorf("failed unmarshalling env YAML: %w", err) 164 | } 165 | return m["env"], nil 166 | } 167 | 168 | func writeDeprecatedEnvConfigFile(file string, env map[string]map[string]string) error { 169 | b, err := yaml.Marshal(map[string]any{"env": env}) 170 | if err != nil { 171 | return fmt.Errorf("failed marshaling YAML: %w", err) 172 | } 173 | // Make parent directories as needed 174 | if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { 175 | return fmt.Errorf("failed making env file parent dirs: %w", err) 176 | } else if err := os.WriteFile(file, b, 0600); err != nil { 177 | return fmt.Errorf("failed writing env file: %w", err) 178 | } 179 | return nil 180 | } 181 | -------------------------------------------------------------------------------- /temporalcli/commands.env_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | func TestEnv_Simple(t *testing.T) { 11 | h := NewCommandHarness(t) 12 | defer h.Close() 13 | 14 | // Non-existent file, no env found for get 15 | h.Options.DeprecatedEnvConfig.EnvConfigFile = "does-not-exist" 16 | res := h.Execute("env", "get", "--env", "myenv1") 17 | h.ErrorContains(res.Err, `environment "myenv1" not found`) 18 | 19 | // Temp file for env 20 | tmpFile, err := os.CreateTemp("", "") 21 | h.NoError(err) 22 | h.Options.DeprecatedEnvConfig.EnvConfigFile = tmpFile.Name() 23 | defer os.Remove(h.Options.DeprecatedEnvConfig.EnvConfigFile) 24 | 25 | // Store a key 26 | res = h.Execute("env", "set", "--env", "myenv1", "-k", "foo", "-v", "bar") 27 | h.NoError(res.Err) 28 | // Confirm file is YAML with expected values 29 | b, err := os.ReadFile(h.Options.DeprecatedEnvConfig.EnvConfigFile) 30 | h.NoError(err) 31 | var yamlVals map[string]map[string]map[string]string 32 | h.NoError(yaml.Unmarshal(b, &yamlVals)) 33 | h.Equal("bar", yamlVals["env"]["myenv1"]["foo"]) 34 | 35 | // Store another key and another env 36 | res = h.Execute("env", "set", "--env", "myenv1", "-k", "baz", "-v", "qux") 37 | h.NoError(res.Err) 38 | res = h.Execute("env", "set", "--env", "myenv2", "-k", "foo", "-v", "baz") 39 | h.NoError(res.Err) 40 | 41 | // Get single prop 42 | res = h.Execute("env", "get", "--env", "myenv1", "-k", "baz") 43 | h.NoError(res.Err) 44 | h.ContainsOnSameLine(res.Stdout.String(), "baz", "qux") 45 | h.NotContains(res.Stdout.String(), "foo") 46 | 47 | // Get all props for env 48 | res = h.Execute("env", "get", "--env", "myenv1") 49 | h.NoError(res.Err) 50 | h.ContainsOnSameLine(res.Stdout.String(), "foo", "bar") 51 | h.ContainsOnSameLine(res.Stdout.String(), "baz", "qux") 52 | 53 | // List envs 54 | res = h.Execute("env", "list") 55 | h.NoError(res.Err) 56 | h.Contains(res.Stdout.String(), "myenv1") 57 | h.Contains(res.Stdout.String(), "myenv2") 58 | 59 | // Delete single env value 60 | res = h.Execute("env", "delete", "--env", "myenv1", "-k", "foo") 61 | h.NoError(res.Err) 62 | res = h.Execute("env", "get", "myenv1") 63 | h.NoError(res.Err) 64 | h.NotContains(res.Stdout.String(), "foo") 65 | 66 | // Delete entire env 67 | res = h.Execute("env", "delete", "myenv2") 68 | h.NoError(res.Err) 69 | res = h.Execute("env", "list") 70 | h.NoError(res.Err) 71 | h.NotContains(res.Stdout.String(), "myenv2") 72 | } 73 | 74 | func TestEnv_InputValidation(t *testing.T) { 75 | h := NewCommandHarness(t) 76 | defer h.Close() 77 | 78 | // myenv1 needs to exist 79 | tmpFile, err := os.CreateTemp("", "") 80 | h.NoError(err) 81 | h.Options.DeprecatedEnvConfig.EnvConfigFile = tmpFile.Name() 82 | defer os.Remove(h.Options.DeprecatedEnvConfig.EnvConfigFile) 83 | res := h.Execute("env", "set", "--env", "myenv1", "-k", "foo", "-v", "bar") 84 | h.NoError(res.Err) 85 | 86 | res = h.Execute("env", "get", "--env", "myenv1", "foo.bar") 87 | h.ErrorContains(res.Err, `cannot specify both`) 88 | 89 | res = h.Execute("env", "get", "-k", "key", "foo.bar") 90 | h.ErrorContains(res.Err, `cannot specify both`) 91 | 92 | res = h.Execute("env", "get", "--env", "myenv1", "-k", "foo.bar") 93 | h.ErrorContains(res.Err, `property name may not contain dots`) 94 | 95 | res = h.Execute("env", "set", "--env", "myenv1", "-k", "foo.bar", "-v", "") 96 | h.ErrorContains(res.Err, `property name may not contain dots`) 97 | 98 | res = h.Execute("env", "set", "--env", "myenv1", "-k", "", "-v", "") 99 | h.ErrorContains(res.Err, `property name must be specified`) 100 | 101 | res = h.Execute("env", "set", "myenv1") 102 | h.ErrorContains(res.Err, `property name must be specified`) 103 | 104 | res = h.Execute("env", "set", "myenv1.foo") 105 | h.ErrorContains(res.Err, `no value provided`) 106 | } 107 | -------------------------------------------------------------------------------- /temporalcli/commands.operator_cluster.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/temporalio/cli/temporalcli/internal/printer" 8 | "go.temporal.io/api/operatorservice/v1" 9 | "go.temporal.io/api/workflowservice/v1" 10 | "go.temporal.io/sdk/client" 11 | ) 12 | 13 | func (c *TemporalOperatorClusterHealthCommand) run(cctx *CommandContext, args []string) error { 14 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 15 | if err != nil { 16 | return err 17 | } 18 | defer cl.Close() 19 | _, err = cl.CheckHealth(cctx, &client.CheckHealthRequest{}) 20 | if err != nil { 21 | return fmt.Errorf("failed checking cluster health: %w", err) 22 | } 23 | if cctx.JSONOutput { 24 | return cctx.Printer.PrintStructured( 25 | struct { 26 | Status string `json:"status"` 27 | }{"SERVING"}, 28 | printer.StructuredOptions{}) 29 | } 30 | cctx.Printer.Println(color.GreenString("SERVING")) 31 | return nil 32 | } 33 | 34 | func (c *TemporalOperatorClusterSystemCommand) run(cctx *CommandContext, args []string) error { 35 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 36 | if err != nil { 37 | return err 38 | } 39 | defer cl.Close() 40 | resp, err := cl.WorkflowService().GetSystemInfo(cctx, &workflowservice.GetSystemInfoRequest{}) 41 | if err != nil { 42 | return fmt.Errorf("unable to get system information: %w", err) 43 | } 44 | // For JSON, we'll just dump the proto 45 | if cctx.JSONOutput { 46 | return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{}) 47 | } 48 | return cctx.Printer.PrintStructured( 49 | struct { 50 | ServerVersion string 51 | SupportsSchedules bool 52 | UpsertMemo bool 53 | EagerWorkflowStart bool 54 | }{ 55 | ServerVersion: resp.GetServerVersion(), 56 | SupportsSchedules: resp.GetCapabilities().SupportsSchedules, 57 | UpsertMemo: resp.GetCapabilities().UpsertMemo, 58 | EagerWorkflowStart: resp.GetCapabilities().EagerWorkflowStart, 59 | }, 60 | printer.StructuredOptions{ 61 | Table: &printer.TableOptions{}, 62 | }) 63 | } 64 | 65 | func (c *TemporalOperatorClusterDescribeCommand) run(cctx *CommandContext, args []string) error { 66 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 67 | if err != nil { 68 | return err 69 | } 70 | defer cl.Close() 71 | resp, err := cl.WorkflowService().GetClusterInfo(cctx, &workflowservice.GetClusterInfoRequest{}) 72 | if err != nil { 73 | return fmt.Errorf("unable to get cluster information: %w", err) 74 | } 75 | // For JSON, we'll just dump the proto 76 | if cctx.JSONOutput { 77 | return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{}) 78 | } 79 | 80 | fields := []string{"ClusterName", "PersistenceStore", "VisibilityStore"} 81 | if c.Detail { 82 | fields = append(fields, "HistoryShardCount", "VersionInfo") 83 | } 84 | 85 | return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{ 86 | Fields: fields, 87 | Table: &printer.TableOptions{}, 88 | }) 89 | } 90 | 91 | func (c *TemporalOperatorClusterListCommand) run(cctx *CommandContext, args []string) error { 92 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 93 | if err != nil { 94 | return err 95 | } 96 | defer cl.Close() 97 | 98 | // This is a listing command subject to json vs jsonl rules 99 | cctx.Printer.StartList() 100 | defer cctx.Printer.EndList() 101 | 102 | var nextPageToken []byte 103 | var execsProcessed int 104 | for pageIndex := 0; ; pageIndex++ { 105 | page, err := cl.OperatorService().ListClusters(cctx, &operatorservice.ListClustersRequest{ 106 | NextPageToken: nextPageToken, 107 | }) 108 | if err != nil { 109 | return fmt.Errorf("failed listing clusters: %w", err) 110 | } 111 | var textTable []map[string]any 112 | for _, cluster := range page.GetClusters() { 113 | if c.Limit > 0 && execsProcessed >= c.Limit { 114 | break 115 | } 116 | execsProcessed++ 117 | // For JSON we are going to dump one line of JSON per execution 118 | if cctx.JSONOutput { 119 | _ = cctx.Printer.PrintStructured(cluster, printer.StructuredOptions{}) 120 | } else { 121 | // For non-JSON, we are doing a table for each page 122 | textTable = append(textTable, map[string]any{ 123 | "Name": cluster.ClusterName, 124 | "ClusterId": cluster.ClusterId, 125 | "Address": cluster.Address, 126 | "HistoryShardCount": cluster.HistoryShardCount, 127 | "InitialFailoverVersion": cluster.InitialFailoverVersion, 128 | "IsConnectionEnabled": cluster.IsConnectionEnabled, 129 | }) 130 | } 131 | } 132 | // Print table, headers only on first table 133 | if len(textTable) > 0 { 134 | _ = cctx.Printer.PrintStructured(textTable, printer.StructuredOptions{ 135 | Fields: []string{"Name", "ClusterId", "Address", "HistoryShardCount", "InitialFailoverVersion", "IsConnectionEnabled"}, 136 | Table: &printer.TableOptions{NoHeader: pageIndex > 0}, 137 | }) 138 | } 139 | // Stop if next page token non-existing or executions reached limit 140 | nextPageToken = page.GetNextPageToken() 141 | if len(nextPageToken) == 0 || (c.Limit > 0 && execsProcessed >= c.Limit) { 142 | return nil 143 | } 144 | } 145 | } 146 | 147 | func (c *TemporalOperatorClusterUpsertCommand) run(cctx *CommandContext, args []string) error { 148 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 149 | if err != nil { 150 | return err 151 | } 152 | defer cl.Close() 153 | _, err = cl.OperatorService().AddOrUpdateRemoteCluster(cctx, &operatorservice.AddOrUpdateRemoteClusterRequest{ 154 | FrontendAddress: c.FrontendAddress, 155 | EnableRemoteClusterConnection: c.EnableConnection, 156 | }) 157 | if err != nil { 158 | return fmt.Errorf("unable to upsert cluster: %w", err) 159 | } 160 | if cctx.JSONOutput { 161 | return cctx.Printer.PrintStructured( 162 | struct { 163 | FrontendAddress string `json:"frontendAddress"` 164 | }{FrontendAddress: c.FrontendAddress}, 165 | printer.StructuredOptions{}) 166 | } 167 | cctx.Printer.Println(color.GreenString(fmt.Sprintf("Upserted cluster %s", c.FrontendAddress))) 168 | return nil 169 | } 170 | 171 | func (c *TemporalOperatorClusterRemoveCommand) run(cctx *CommandContext, args []string) error { 172 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 173 | if err != nil { 174 | return err 175 | } 176 | defer cl.Close() 177 | _, err = cl.OperatorService().RemoveRemoteCluster(cctx, &operatorservice.RemoveRemoteClusterRequest{ 178 | ClusterName: c.Name, 179 | }) 180 | if err != nil { 181 | return fmt.Errorf("failed removing cluster: %w", err) 182 | } 183 | if cctx.JSONOutput { 184 | return cctx.Printer.PrintStructured( 185 | struct { 186 | ClusterName string `json:"clusterName"` 187 | }{ClusterName: c.Name}, 188 | printer.StructuredOptions{}) 189 | } 190 | cctx.Printer.Println(color.GreenString(fmt.Sprintf("Removed cluster %s", c.Name))) 191 | return nil 192 | } 193 | -------------------------------------------------------------------------------- /temporalcli/commands.operator_cluster_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/temporalio/cli/temporalcli" 10 | "github.com/temporalio/cli/temporalcli/devserver" 11 | "go.temporal.io/api/workflowservice/v1" 12 | ) 13 | 14 | func (s *SharedServerSuite) TestOperator_Cluster_System() { 15 | // Text 16 | res := s.Execute( 17 | "operator", "cluster", "system", 18 | "--address", s.Address(), 19 | ) 20 | s.NoError(res.Err) 21 | out := res.Stdout.String() 22 | s.ContainsOnSameLine(out, "ServerVersion", "SupportsSchedules", "UpsertMemo", "EagerWorkflowStart") 23 | s.ContainsOnSameLine(out, "true", "true", "true") 24 | 25 | // JSON 26 | res = s.Execute( 27 | "operator", "cluster", "system", 28 | "-o", "json", 29 | "--address", s.Address(), 30 | ) 31 | s.NoError(res.Err) 32 | var jsonOut workflowservice.GetSystemInfoResponse 33 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &jsonOut, true)) 34 | s.NotEmpty(jsonOut.ServerVersion) 35 | s.True(jsonOut.Capabilities.SupportsSchedules) 36 | s.True(jsonOut.Capabilities.UpsertMemo) 37 | s.True(jsonOut.Capabilities.EagerWorkflowStart) 38 | } 39 | 40 | func (s *SharedServerSuite) TestOperator_Cluster_Describe() { 41 | // Text 42 | res := s.Execute( 43 | "operator", "cluster", "describe", 44 | "--address", s.Address(), 45 | ) 46 | s.NoError(res.Err) 47 | out := res.Stdout.String() 48 | s.ContainsOnSameLine(out, "ClusterName", "PersistenceStore", "VisibilityStore") 49 | s.ContainsOnSameLine(out, "active", "sqlite", "sqlite") 50 | 51 | // JSON 52 | res = s.Execute( 53 | "operator", "cluster", "describe", 54 | "--address", s.Address(), 55 | "-o", "json", 56 | ) 57 | s.NoError(res.Err) 58 | var jsonOut workflowservice.GetClusterInfoResponse 59 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &jsonOut, true)) 60 | s.Equal("active", jsonOut.ClusterName) 61 | s.Equal("sqlite", jsonOut.PersistenceStore) 62 | s.Equal("sqlite", jsonOut.VisibilityStore) 63 | } 64 | 65 | func (s *SharedServerSuite) TestOperator_Cluster_Health() { 66 | // Text 67 | res := s.Execute( 68 | "operator", "cluster", "health", 69 | "--address", s.Address(), 70 | ) 71 | s.NoError(res.Err) 72 | out := res.Stdout.String() 73 | s.Equal(out, "SERVING\n") 74 | 75 | // JSON 76 | res = s.Execute( 77 | "operator", "cluster", "health", 78 | "--address", s.Address(), 79 | "-o", "json", 80 | ) 81 | s.NoError(res.Err) 82 | var jsonOut map[string]string 83 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 84 | s.Equal(jsonOut["status"], "SERVING") 85 | } 86 | 87 | func (s *SharedServerSuite) TestOperator_Cluster_Operations() { 88 | // Create some clusters 89 | standbyCluster1 := StartDevServer(s.Suite.T(), DevServerOptions{ 90 | StartOptions: devserver.StartOptions{ 91 | MasterClusterName: "standby1", 92 | CurrentClusterName: "standby1", 93 | InitialFailoverVersion: 2, 94 | EnableGlobalNamespace: true, 95 | }, 96 | }) 97 | defer standbyCluster1.Stop() 98 | 99 | standbyCluster2 := StartDevServer(s.Suite.T(), DevServerOptions{ 100 | StartOptions: devserver.StartOptions{ 101 | MasterClusterName: "standby2", 102 | CurrentClusterName: "standby2", 103 | InitialFailoverVersion: 3, 104 | EnableGlobalNamespace: true, 105 | }, 106 | }) 107 | defer standbyCluster2.Stop() 108 | 109 | // Upsert the clusters 110 | 111 | // Text 112 | res := s.Execute( 113 | "operator", "cluster", "upsert", 114 | "--frontend-address", standbyCluster1.Address(), 115 | "--address", s.Address(), 116 | ) 117 | s.NoError(res.Err) 118 | out := res.Stdout.String() 119 | s.Equal("Upserted cluster "+standbyCluster1.Address()+"\n", out) 120 | 121 | // JSON 122 | res = s.Execute( 123 | "operator", "cluster", "upsert", 124 | "--frontend-address", standbyCluster2.Address(), 125 | "--address", s.Address(), 126 | "-o", "json", 127 | ) 128 | s.NoError(res.Err) 129 | var jsonOut map[string]string 130 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 131 | s.Equal(jsonOut["frontendAddress"], standbyCluster2.Address()) 132 | 133 | // List the clusters 134 | 135 | // Text 136 | res = s.Execute( 137 | "operator", "cluster", "list", 138 | "--address", s.Address(), 139 | ) 140 | s.NoError(res.Err) 141 | out = res.Stdout.String() 142 | s.ContainsOnSameLine(out, "Name", "ClusterId", "Address", "HistoryShardCount", "InitialFailoverVersion", "IsConnectionEnabled") 143 | s.ContainsOnSameLine(out, "active", s.DevServer.Options.ClusterID, s.DevServer.Address(), "1", "1", "true") 144 | s.ContainsOnSameLine(out, standbyCluster1.Options.CurrentClusterName, standbyCluster1.Options.ClusterID, standbyCluster1.Address(), "1", strconv.Itoa(standbyCluster1.Options.InitialFailoverVersion), "false") 145 | s.ContainsOnSameLine(out, standbyCluster2.Options.CurrentClusterName, standbyCluster2.Options.ClusterID, standbyCluster2.Address(), "1", strconv.Itoa(standbyCluster2.Options.InitialFailoverVersion), "false") 146 | 147 | // JSON 148 | res = s.Execute( 149 | "operator", "cluster", "list", 150 | "--address", s.Address(), 151 | "-o", "json", 152 | ) 153 | s.NoError(res.Err) 154 | out = res.Stdout.String() 155 | // If we improve the output of list commands https://github.com/temporalio/cli/issues/448 156 | // we can do more detailed checks here. 157 | s.ContainsOnSameLine(out, fmt.Sprintf("\"clusterId\": \"%s\"", s.DevServer.Options.ClusterID)) 158 | s.ContainsOnSameLine(out, fmt.Sprintf("\"clusterId\": \"%s\"", standbyCluster1.Options.ClusterID)) 159 | s.ContainsOnSameLine(out, fmt.Sprintf("\"clusterId\": \"%s\"", standbyCluster2.Options.ClusterID)) 160 | 161 | // Need to wait for the cluster cache to be updated 162 | // clusterMetadataRefreshInterval is 100 millisecond, waiting 163 | // 200 milliseconds should allow for a sufficient buffer 164 | time.Sleep(200 * time.Millisecond) 165 | 166 | // Remove the clusters 167 | 168 | // Test 169 | res = s.Execute( 170 | "operator", "cluster", "remove", 171 | "--name", standbyCluster1.Options.CurrentClusterName, 172 | "--address", s.Address(), 173 | ) 174 | s.NoError(res.Err) 175 | s.Equal(fmt.Sprintf("Removed cluster %s\n", standbyCluster1.Options.CurrentClusterName), res.Stdout.String()) 176 | 177 | // JSON 178 | res = s.Execute( 179 | "operator", "cluster", "remove", 180 | "--name", standbyCluster2.Options.CurrentClusterName, 181 | "--address", s.Address(), 182 | "-o", "json", 183 | ) 184 | jsonOut = make(map[string]string) 185 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 186 | s.Equal(jsonOut["clusterName"], standbyCluster2.Options.CurrentClusterName) 187 | } 188 | -------------------------------------------------------------------------------- /temporalcli/commands.operator_namespace_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/temporalio/cli/temporalcli" 8 | "go.temporal.io/api/enums/v1" 9 | "go.temporal.io/api/workflowservice/v1" 10 | ) 11 | 12 | func (s *SharedServerSuite) TestOperator_NamespaceCreateListAndDescribe() { 13 | nsName := "test_namespace" 14 | res := s.Execute( 15 | "operator", "namespace", "create", 16 | "--address", s.Address(), 17 | nsName, 18 | ) 19 | s.NoError(res.Err) 20 | 21 | res = s.Execute( 22 | "operator", "namespace", "list", 23 | "--address", s.Address(), 24 | "--output", "json", 25 | ) 26 | s.NoError(res.Err) 27 | output := fmt.Sprintf("{\"namespaces\": %s}", res.Stdout.String()) 28 | var listResp workflowservice.ListNamespacesResponse 29 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions([]byte(output), &listResp, true)) 30 | var uuid string 31 | for _, ns := range listResp.Namespaces { 32 | if ns.NamespaceInfo.Name == nsName { 33 | uuid = ns.NamespaceInfo.Id 34 | } 35 | } 36 | s.NotEmpty(uuid) 37 | 38 | res = s.Execute( 39 | "operator", "namespace", "describe", 40 | "--address", s.Address(), 41 | "--output", "json", 42 | "-n", nsName, 43 | ) 44 | s.NoError(res.Err) 45 | var describeResp workflowservice.DescribeNamespaceResponse 46 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &describeResp, true)) 47 | s.Equal(nsName, describeResp.NamespaceInfo.Name) 48 | 49 | // Validate default values 50 | s.Equal("active", describeResp.ReplicationConfig.ActiveClusterName) 51 | s.Equal("active", describeResp.ReplicationConfig.Clusters[0].ClusterName) 52 | s.Len(describeResp.NamespaceInfo.Data, 0) 53 | s.Len(describeResp.NamespaceInfo.Description, 0) 54 | s.Len(describeResp.NamespaceInfo.OwnerEmail, 0) 55 | s.Equal(false, describeResp.IsGlobalNamespace) 56 | s.Equal(enums.ARCHIVAL_STATE_DISABLED, describeResp.Config.HistoryArchivalState) 57 | s.Len(describeResp.Config.HistoryArchivalUri, 0) 58 | s.Equal(describeResp.Config.WorkflowExecutionRetentionTtl.AsDuration(), 72*time.Hour) 59 | s.Equal(enums.ARCHIVAL_STATE_DISABLED, describeResp.Config.VisibilityArchivalState) 60 | s.Len(describeResp.Config.VisibilityArchivalUri, 0) 61 | } 62 | 63 | func (s *SharedServerSuite) TestNamespaceUpdate() { 64 | nsName := "test-namespace-update-verbose" 65 | 66 | res := s.Execute( 67 | "operator", "namespace", "create", 68 | "--address", s.Address(), 69 | "--description", "description before", 70 | "--email", "email@before", 71 | "--retention", "24h", 72 | "--data", "k1=v0", 73 | "--data", "k3=v3", 74 | "-n", nsName, 75 | ) 76 | s.NoError(res.Err) 77 | 78 | res = s.Execute( 79 | "operator", "namespace", "update", 80 | "--address", s.Address(), 81 | "--description", "description after", 82 | "--email", "email@after", 83 | "--retention", "2d", 84 | "--data", "k1=v1", 85 | "--data", "k2=v2", 86 | "--output", "json", 87 | "-n", nsName, 88 | ) 89 | s.NoError(res.Err) 90 | 91 | res = s.Execute( 92 | "operator", "namespace", "describe", 93 | "--address", s.Address(), 94 | "--output", "json", 95 | "-n", nsName, 96 | ) 97 | s.NoError(res.Err) 98 | var describeResp workflowservice.DescribeNamespaceResponse 99 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &describeResp, true)) 100 | 101 | s.Equal("description after", describeResp.NamespaceInfo.Description) 102 | s.Equal("email@after", describeResp.NamespaceInfo.OwnerEmail) 103 | s.Equal(48*time.Hour, describeResp.Config.WorkflowExecutionRetentionTtl.AsDuration()) 104 | s.Equal("v1", describeResp.NamespaceInfo.Data["k1"]) 105 | s.Equal("v2", describeResp.NamespaceInfo.Data["k2"]) 106 | s.Equal("v3", describeResp.NamespaceInfo.Data["k3"]) 107 | } 108 | 109 | func (s *SharedServerSuite) TestNamespaceUpdate_NamespaceDontExist() { 110 | nsName := "missing-namespace" 111 | res := s.Execute( 112 | "operator", "namespace", "update", 113 | "--email", "email@after", 114 | "--address", s.Address(), 115 | "-n", nsName, 116 | ) 117 | s.Error(res.Err) 118 | s.Contains(res.Err.Error(), "Namespace missing-namespace is not found") 119 | } 120 | 121 | func (s *SharedServerSuite) TestDeleteNamespace() { 122 | nsName := "test-namespace" 123 | res := s.Execute( 124 | "operator", "namespace", "create", 125 | "--address", s.Address(), 126 | nsName, 127 | ) 128 | s.NoError(res.Err) 129 | 130 | res = s.Execute( 131 | "operator", "namespace", "describe", 132 | "--address", s.Address(), 133 | "--output", "json", 134 | "-n", nsName, 135 | ) 136 | s.NoError(res.Err) 137 | var describeResp workflowservice.DescribeNamespaceResponse 138 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &describeResp, true)) 139 | s.Equal(nsName, describeResp.NamespaceInfo.Name) 140 | 141 | res = s.Execute( 142 | "operator", "namespace", "delete", 143 | "--address", s.Address(), 144 | "--yes", 145 | "-n", nsName, 146 | ) 147 | s.NoError(res.Err) 148 | 149 | res = s.Execute( 150 | "operator", "namespace", "describe", 151 | "--address", s.Address(), 152 | "--output", "json", 153 | "-n", nsName, 154 | ) 155 | s.Error(res.Err) 156 | s.Contains(res.Err.Error(), "Namespace test-namespace is not found") 157 | } 158 | 159 | func (s *SharedServerSuite) TestDescribeWithID() { 160 | res := s.Execute( 161 | "operator", "namespace", "describe", 162 | "--address", s.Address(), 163 | "--output", "json", 164 | "-n", "default", 165 | ) 166 | s.NoError(res.Err) 167 | 168 | var describeResp1 workflowservice.DescribeNamespaceResponse 169 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &describeResp1, true)) 170 | nsID := describeResp1.NamespaceInfo.Id 171 | 172 | res = s.Execute( 173 | "operator", "namespace", "describe", 174 | "--address", s.Address(), 175 | "--output", "json", 176 | "--namespace-id", nsID, 177 | ) 178 | s.NoError(res.Err) 179 | var describeResp2 workflowservice.DescribeNamespaceResponse 180 | 181 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &describeResp2, true)) 182 | s.Equal(describeResp1.NamespaceInfo, describeResp2.NamespaceInfo) 183 | } 184 | 185 | func (s *SharedServerSuite) TestDescribeBothNameAndID() { 186 | res := s.Execute( 187 | "operator", "namespace", "describe", 188 | "--address", s.Address(), 189 | "--output", "json", 190 | "-n", "asdf", 191 | "--namespace-id=ad7ef0ce-7139-4333-b8ee-60a79c8fda1d", 192 | ) 193 | s.Error(res.Err) 194 | s.ContainsOnSameLine(res.Err.Error(), "provide one of", "but not both") 195 | } 196 | 197 | func (s *SharedServerSuite) TestUpdateOldAndNewNSArgs() { 198 | res := s.Execute( 199 | "operator", "namespace", "update", 200 | "--address", s.Address(), 201 | "--output", "json", 202 | "--email", "foo@bar", 203 | "-n", "asdf", 204 | "asdf", 205 | ) 206 | s.Error(res.Err) 207 | s.ContainsOnSameLine(res.Err.Error(), "namespace was provided as both an argument", "and a flag") 208 | } 209 | -------------------------------------------------------------------------------- /temporalcli/commands.operator_search_attribute.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/fatih/color" 8 | "github.com/temporalio/cli/temporalcli/internal/printer" 9 | "go.temporal.io/api/enums/v1" 10 | "go.temporal.io/api/operatorservice/v1" 11 | ) 12 | 13 | func (c *TemporalOperatorSearchAttributeCreateCommand) run(cctx *CommandContext, args []string) error { 14 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 15 | if err != nil { 16 | return err 17 | } 18 | defer cl.Close() 19 | 20 | // Name and Type are required parameters, and there must be the same number of them. 21 | 22 | listReq := &operatorservice.ListSearchAttributesRequest{ 23 | Namespace: c.Parent.Parent.Namespace, 24 | } 25 | existingSearchAttributes, err := cl.OperatorService().ListSearchAttributes(cctx, listReq) 26 | if err != nil { 27 | return fmt.Errorf("unable to get existing search attributes: %w", err) 28 | } 29 | 30 | searchAttributes := make(map[string]enums.IndexedValueType, len(c.Type.Values)) 31 | for i, saType := range c.Type.Values { 32 | saName := c.Name[i] 33 | typeInt, err := searchAttributeTypeStringToEnum(saType) 34 | if err != nil { 35 | return fmt.Errorf("unable to parse search attribute type %s: %w", saType, err) 36 | } 37 | existingSearchAttributeType, searchAttributeExists := existingSearchAttributes.CustomAttributes[saName] 38 | if searchAttributeExists && existingSearchAttributeType != typeInt { 39 | return fmt.Errorf("search attribute %s already exists and has different type %s", saName, existingSearchAttributeType.String()) 40 | } 41 | searchAttributes[saName] = typeInt 42 | } 43 | 44 | request := &operatorservice.AddSearchAttributesRequest{ 45 | SearchAttributes: searchAttributes, 46 | Namespace: c.Parent.Parent.Namespace, 47 | } 48 | 49 | _, err = cl.OperatorService().AddSearchAttributes(cctx, request) 50 | if err != nil { 51 | return fmt.Errorf("unable to add search attributes: %w", err) 52 | } 53 | cctx.Printer.Println(color.GreenString("Search attributes have been added")) 54 | return nil 55 | } 56 | 57 | func searchAttributeTypeStringToEnum(search string) (enums.IndexedValueType, error) { 58 | for k, v := range enums.IndexedValueType_shorthandValue { 59 | if strings.EqualFold(search, k) { 60 | return enums.IndexedValueType(v), nil 61 | } 62 | } 63 | return enums.INDEXED_VALUE_TYPE_UNSPECIFIED, fmt.Errorf("unsupported search attribute type: %q", search) 64 | } 65 | 66 | func (c *TemporalOperatorSearchAttributeRemoveCommand) run(cctx *CommandContext, args []string) error { 67 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 68 | if err != nil { 69 | return err 70 | } 71 | defer cl.Close() 72 | 73 | yes, err := cctx.promptYes( 74 | fmt.Sprintf("You are about to remove search attribute %q? y/N", c.Name), c.Yes) 75 | if err != nil { 76 | return err 77 | } else if !yes { 78 | // We consider this a command failure 79 | return fmt.Errorf("user denied confirmation, operation aborted") 80 | } 81 | 82 | names := c.Name 83 | namespace := c.Parent.Parent.Namespace 84 | if err != nil { 85 | return err 86 | } 87 | 88 | request := &operatorservice.RemoveSearchAttributesRequest{ 89 | SearchAttributes: names, 90 | Namespace: namespace, 91 | } 92 | 93 | _, err = cl.OperatorService().RemoveSearchAttributes(cctx, request) 94 | if err != nil { 95 | return fmt.Errorf("unable to remove search attributes: %w", err) 96 | } 97 | 98 | // response contains nothing 99 | cctx.Printer.Println(color.GreenString("Search attributes have been removed")) 100 | return nil 101 | } 102 | 103 | func (c *TemporalOperatorSearchAttributeListCommand) run(cctx *CommandContext, args []string) error { 104 | cl, err := c.Parent.Parent.ClientOptions.dialClient(cctx) 105 | if err != nil { 106 | return err 107 | } 108 | defer cl.Close() 109 | 110 | request := &operatorservice.ListSearchAttributesRequest{ 111 | Namespace: c.Parent.Parent.Namespace, 112 | } 113 | resp, err := cl.OperatorService().ListSearchAttributes(cctx, request) 114 | if err != nil { 115 | return fmt.Errorf("unable to list search attributes: %w", err) 116 | } 117 | if cctx.JSONOutput { 118 | return cctx.Printer.PrintStructured(resp, printer.StructuredOptions{}) 119 | } 120 | 121 | type saNameType struct { 122 | Name string `json:"name"` 123 | Type string `json:"type"` 124 | } 125 | 126 | var sas []saNameType 127 | for saName, saType := range resp.SystemAttributes { 128 | sas = append(sas, saNameType{ 129 | Name: saName, 130 | Type: saType.String(), 131 | }) 132 | } 133 | for saName, saType := range resp.CustomAttributes { 134 | sas = append(sas, saNameType{ 135 | Name: saName, 136 | Type: saType.String(), 137 | }) 138 | } 139 | 140 | return cctx.Printer.PrintStructured(sas, printer.StructuredOptions{Table: &printer.TableOptions{}}) 141 | } 142 | -------------------------------------------------------------------------------- /temporalcli/commands.operator_search_attribute_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "github.com/temporalio/cli/temporalcli" 5 | "go.temporal.io/api/enums/v1" 6 | "go.temporal.io/api/operatorservice/v1" 7 | ) 8 | 9 | func (s *SharedServerSuite) TestOperator_SearchAttribute_Create_Already_Exists() { 10 | /* 11 | This test try to create: 12 | 1, a system search attribute, that should fail. 13 | 2, a custom search attribute with different type, that should fail. 14 | 3, a custom search attribute with same type, that should pass. 15 | */ 16 | // Create System search attribute (WorkflowId) 17 | res := s.Execute( 18 | "operator", "search-attribute", "create", 19 | "--address", s.Address(), 20 | "--name", "WorkflowId", 21 | "--type", "Keyword", 22 | ) 23 | s.Error(res.Err) 24 | s.Contains(res.Err.Error(), "unable to add search attributes: Search attribute WorkflowId is reserved by system.") 25 | 26 | // Create Custom search attribute with different type 27 | res2 := s.Execute( 28 | "operator", "search-attribute", "create", 29 | "--address", s.Address(), 30 | "--name", "CustomKeywordField", 31 | "--type", "Int", 32 | ) 33 | s.Error(res2.Err) 34 | s.Contains(res2.Err.Error(), "search attribute CustomKeywordField already exists and has different type Keyword") 35 | 36 | // Create Custom search attribute with same type 37 | res3 := s.Execute( 38 | "operator", "search-attribute", "create", 39 | "--address", s.Address(), 40 | "--name", "CustomKeywordField", 41 | "--type", "Keyword", 42 | ) 43 | s.NoError(res3.Err) 44 | } 45 | 46 | func (s *SharedServerSuite) TestOperator_SearchAttribute() { 47 | /* 48 | This test case first create 2 new custom search attributes, and verify the creation by list them. 49 | Then we delete one of the newly created SA, and verify the deletion by list again. 50 | */ 51 | res := s.Execute( 52 | "operator", "search-attribute", "create", 53 | "--address", s.Address(), 54 | "--name", "MySearchAttributeForTestCreateKeyword", 55 | "--type", "Keyword", 56 | "--name", "MySearchAttributeForTestCreateInt", 57 | "--type", "Int", 58 | ) 59 | s.NoError(res.Err) 60 | 61 | // verify the creation succeed 62 | res2 := s.Execute( 63 | "operator", "search-attribute", "list", 64 | "--address", s.Address(), 65 | "-o", "json", 66 | ) 67 | s.NoError(res2.Err) 68 | var jsonOut operatorservice.ListSearchAttributesResponse 69 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res2.Stdout.Bytes(), &jsonOut, true)) 70 | s.Equal(enums.INDEXED_VALUE_TYPE_KEYWORD, jsonOut.CustomAttributes["MySearchAttributeForTestCreateKeyword"]) 71 | s.Equal(enums.INDEXED_VALUE_TYPE_INT, jsonOut.CustomAttributes["MySearchAttributeForTestCreateInt"]) 72 | 73 | // Remove it 74 | res3 := s.Execute( 75 | "operator", "search-attribute", "remove", 76 | "--address", s.Address(), 77 | "--name", "MySearchAttributeForTestCreateKeyword", 78 | "--yes", 79 | ) 80 | s.NoError(res3.Err) 81 | 82 | // verify deletion 83 | res4 := s.Execute( 84 | "operator", "search-attribute", "list", 85 | "--address", s.Address(), 86 | "-o", "json", 87 | ) 88 | s.NoError(res2.Err) 89 | var jsonOut2 operatorservice.ListSearchAttributesResponse 90 | s.NoError(temporalcli.UnmarshalProtoJSONWithOptions(res4.Stdout.Bytes(), &jsonOut2, true)) 91 | // Int SA still exists 92 | s.Equal(enums.INDEXED_VALUE_TYPE_INT, jsonOut2.CustomAttributes["MySearchAttributeForTestCreateInt"]) 93 | // Keyword SA no longer exists 94 | _, exists := jsonOut2.CustomAttributes["MySearchAttributeForTestCreateKeyword"] 95 | s.False(exists) 96 | 97 | // also verify some system attributes from list result 98 | s.Equal(enums.INDEXED_VALUE_TYPE_DATETIME, jsonOut.SystemAttributes["StartTime"]) 99 | s.Equal(enums.INDEXED_VALUE_TYPE_KEYWORD, jsonOut.SystemAttributes["WorkflowId"]) 100 | } 101 | -------------------------------------------------------------------------------- /temporalcli/commands.server.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/uuid" 9 | "github.com/temporalio/cli/temporalcli/devserver" 10 | "go.temporal.io/api/enums/v1" 11 | ) 12 | 13 | var defaultDynamicConfigValues = map[string]any{ 14 | // Make search attributes immediately visible on creation, so users don't 15 | // have to wait for eventual consistency to happen when testing against the 16 | // dev-server. Since it's a very rare thing to create search attributes, 17 | // we're comfortable that this is very unlikely to mask bugs in user code. 18 | "system.forceSearchAttributesCacheRefreshOnRead": true, 19 | 20 | // Since we disable the SA cache, we need to bump max QPS accordingly. 21 | // These numbers were chosen to maintain the ratio between the two that's 22 | // established in the defaults. 23 | "frontend.persistenceMaxQPS": 10000, 24 | "history.persistenceMaxQPS": 45000, 25 | } 26 | 27 | func (t *TemporalServerStartDevCommand) run(cctx *CommandContext, args []string) error { 28 | // Have to assume "localhost" is 127.0.0.1 for server to work (it expects IP) 29 | if t.Ip == "localhost" { 30 | t.Ip = "127.0.0.1" 31 | } 32 | // Prepare options 33 | opts := devserver.StartOptions{ 34 | FrontendIP: t.Ip, 35 | FrontendPort: t.Port, 36 | Namespaces: append([]string{"default"}, t.Namespace...), 37 | Logger: cctx.Logger, 38 | DatabaseFile: t.DbFilename, 39 | MetricsPort: t.MetricsPort, 40 | FrontendHTTPPort: t.HttpPort, 41 | ClusterID: uuid.NewString(), 42 | MasterClusterName: "active", 43 | CurrentClusterName: "active", 44 | InitialFailoverVersion: 1, 45 | } 46 | // Set the log level value of the server to the overall log level given to the 47 | // CLI. But if it is "never" we have to do a special value, and if it was 48 | // never changed, we have to use the default of "warn" instead of the CLI 49 | // default of "info" since server is noisier. 50 | logLevel := t.Parent.Parent.LogLevel.Value 51 | if !t.Parent.Parent.LogLevel.ChangedFromDefault { 52 | logLevel = "warn" 53 | } 54 | if logLevel == "never" { 55 | opts.LogLevel = 100 56 | } else if err := opts.LogLevel.UnmarshalText([]byte(logLevel)); err != nil { 57 | return fmt.Errorf("invalid log level %q: %w", logLevel, err) 58 | } 59 | if err := devserver.CheckPortFree(opts.FrontendIP, opts.FrontendPort); err != nil { 60 | return fmt.Errorf("can't set frontend port %d: %w", opts.FrontendPort, err) 61 | } 62 | 63 | if opts.FrontendHTTPPort > 0 { 64 | if err := devserver.CheckPortFree(opts.FrontendIP, opts.FrontendHTTPPort); err != nil { 65 | return fmt.Errorf("can't set frontend HTTP port %d: %w", opts.FrontendHTTPPort, err) 66 | } 67 | } 68 | // Setup UI 69 | if !t.Headless { 70 | opts.UIIP, opts.UIPort = t.UiIp, t.UiPort 71 | if opts.UIIP == "" { 72 | opts.UIIP = t.Ip 73 | } 74 | if opts.UIPort == 0 { 75 | opts.UIPort = t.Port + 1000 76 | if opts.UIPort > 65535 { 77 | opts.UIPort = 65535 78 | } 79 | if err := devserver.CheckPortFree(opts.UIIP, opts.UIPort); err != nil { 80 | return fmt.Errorf("can't use default UI port %d (%d + 1000): %w", opts.UIPort, t.Port, err) 81 | } 82 | } else { 83 | if err := devserver.CheckPortFree(opts.UIIP, opts.UIPort); err != nil { 84 | return fmt.Errorf("can't set UI port %d: %w", opts.UIPort, err) 85 | } 86 | } 87 | opts.UIAssetPath, opts.UICodecEndpoint, opts.PublicPath = t.UiAssetPath, t.UiCodecEndpoint, t.UiPublicPath 88 | } 89 | // Pragmas and dyn config 90 | var err error 91 | if opts.SqlitePragmas, err = stringKeysValues(t.SqlitePragma); err != nil { 92 | return fmt.Errorf("invalid pragma: %w", err) 93 | } else if opts.DynamicConfigValues, err = stringKeysJSONValues(t.DynamicConfigValue, true); err != nil { 94 | return fmt.Errorf("invalid dynamic config values: %w", err) 95 | } 96 | // We have to convert all dynamic config values that JSON number to int if we 97 | // can because server dynamic config expecting int won't work with the default 98 | // float JSON unmarshal uses 99 | for k, v := range opts.DynamicConfigValues { 100 | if num, ok := v.(json.Number); ok { 101 | if newV, err := num.Int64(); err == nil { 102 | // Dynamic config only accepts int type, not int32 nor int64 103 | opts.DynamicConfigValues[k] = int(newV) 104 | } else if newV, err := num.Float64(); err == nil { 105 | opts.DynamicConfigValues[k] = newV 106 | } else { 107 | return fmt.Errorf("invalid JSON value for key %q", k) 108 | } 109 | } 110 | } 111 | 112 | // Apply set of default dynamic config values if not already present 113 | for k, v := range defaultDynamicConfigValues { 114 | if _, ok := opts.DynamicConfigValues[k]; !ok { 115 | if opts.DynamicConfigValues == nil { 116 | opts.DynamicConfigValues = map[string]any{} 117 | } 118 | opts.DynamicConfigValues[k] = v 119 | } 120 | } 121 | 122 | // Prepare search attributes for adding before starting server 123 | searchAttrs, err := t.prepareSearchAttributes() 124 | if err != nil { 125 | return err 126 | } 127 | opts.SearchAttributes = searchAttrs 128 | 129 | // If not using DB file, set persistent cluster ID 130 | if t.DbFilename == "" { 131 | opts.ClusterID = persistentClusterID() 132 | } 133 | // Log config if requested 134 | if t.LogConfig { 135 | opts.LogConfig = func(b []byte) { 136 | cctx.Logger.Info("Logging config") 137 | _, _ = cctx.Options.Stderr.Write(b) 138 | } 139 | } 140 | // Grab a free port for metrics ahead-of-time so we know what port is selected 141 | if opts.MetricsPort == 0 { 142 | opts.MetricsPort = devserver.MustGetFreePort(opts.FrontendIP) 143 | } else { 144 | if err := devserver.CheckPortFree(opts.FrontendIP, opts.MetricsPort); err != nil { 145 | return fmt.Errorf("can't set metrics port %d: %w", opts.MetricsPort, err) 146 | } 147 | } 148 | 149 | // Start, wait for context complete, then stop 150 | s, err := devserver.Start(opts) 151 | if err != nil { 152 | return fmt.Errorf("failed starting server: %w", err) 153 | } 154 | defer s.Stop() 155 | 156 | cctx.Printer.Printlnf("CLI %v\n", VersionString()) 157 | cctx.Printer.Printlnf("%-8s %v:%v", "Server:", toFriendlyIp(opts.FrontendIP), opts.FrontendPort) 158 | // Only print HTTP port if explicitly provided to avoid promoting the unstable HTTP API. 159 | if opts.FrontendHTTPPort > 0 { 160 | cctx.Printer.Printlnf("%-8s %v:%v", "HTTP:", toFriendlyIp(opts.FrontendIP), opts.FrontendHTTPPort) 161 | } 162 | if !t.Headless { 163 | cctx.Printer.Printlnf("%-8s http://%v:%v%v", "UI:", toFriendlyIp(opts.UIIP), opts.UIPort, opts.PublicPath) 164 | } 165 | cctx.Printer.Printlnf("%-8s http://%v:%v/metrics", "Metrics:", toFriendlyIp(opts.FrontendIP), opts.MetricsPort) 166 | <-cctx.Done() 167 | cctx.Printer.Println("Stopping server...") 168 | return nil 169 | } 170 | 171 | func toFriendlyIp(host string) string { 172 | if host == "127.0.0.1" || host == "::1" { 173 | return "localhost" 174 | } 175 | return devserver.MaybeEscapeIPv6(host) 176 | } 177 | 178 | func persistentClusterID() string { 179 | // If there is not a database file in use, we want a cluster ID to be the same 180 | // for every re-run, so we set it as an environment config in a special env 181 | // file. We do not error if we can neither read nor write the file. 182 | file := defaultDeprecatedEnvConfigFile("temporalio", "version-info") 183 | if file == "" { 184 | // No file, can do nothing here 185 | return uuid.NewString() 186 | } 187 | // Try to get existing first 188 | env, _ := readDeprecatedEnvConfigFile(file) 189 | if id := env["default"]["cluster-id"]; id != "" { 190 | return id 191 | } 192 | // Create and try to write 193 | id := uuid.NewString() 194 | _ = writeDeprecatedEnvConfigFile(file, map[string]map[string]string{"default": {"cluster-id": id}}) 195 | return id 196 | } 197 | 198 | func (t *TemporalServerStartDevCommand) prepareSearchAttributes() (map[string]enums.IndexedValueType, error) { 199 | opts, err := stringKeysValues(t.SearchAttribute) 200 | if err != nil { 201 | return nil, fmt.Errorf("invalid search attributes: %w", err) 202 | } 203 | attrs := make(map[string]enums.IndexedValueType, len(opts)) 204 | for k, v := range opts { 205 | // Case-insensitive index type lookup 206 | var valType enums.IndexedValueType 207 | for valTypeName, valTypeOrd := range enums.IndexedValueType_shorthandValue { 208 | if strings.EqualFold(v, valTypeName) { 209 | valType = enums.IndexedValueType(valTypeOrd) 210 | break 211 | } 212 | } 213 | if valType == 0 { 214 | return nil, fmt.Errorf("invalid search attribute value type %q", v) 215 | } 216 | attrs[k] = valType 217 | } 218 | return attrs, nil 219 | } 220 | -------------------------------------------------------------------------------- /temporalcli/commands.server_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "sync" 10 | "sync/atomic" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "github.com/temporalio/cli/temporalcli/devserver" 17 | "go.temporal.io/api/operatorservice/v1" 18 | "go.temporal.io/sdk/client" 19 | "go.temporal.io/sdk/temporal" 20 | ) 21 | 22 | // TODO(cretz): To test: 23 | // * Start server with UI 24 | // * Server reuse existing database file 25 | 26 | func TestServer_StartDev_Simple(t *testing.T) { 27 | port := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 28 | httpPort := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 29 | startDevServerAndRunSimpleTest( 30 | t, 31 | // TODO(cretz): Remove --headless when 32 | // https://github.com/temporalio/ui/issues/1773 fixed 33 | []string{"server", "start-dev", "-p", port, "--http-port", httpPort, "--headless"}, 34 | "127.0.0.1:"+port, 35 | ) 36 | } 37 | 38 | func TestServer_StartDev_IPv4Unspecified(t *testing.T) { 39 | port := strconv.Itoa(devserver.MustGetFreePort("0.0.0.0")) 40 | httpPort := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 41 | startDevServerAndRunSimpleTest( 42 | t, 43 | []string{"server", "start-dev", "--ip", "0.0.0.0", "-p", port, "--http-port", httpPort, "--headless"}, 44 | "0.0.0.0:"+port, 45 | ) 46 | } 47 | 48 | func TestServer_StartDev_SQLitePragma(t *testing.T) { 49 | port := strconv.Itoa(devserver.MustGetFreePort("0.0.0.0")) 50 | httpPort := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 51 | dbFilename := filepath.Join(os.TempDir(), "devserver-sqlite-pragma.sqlite") 52 | defer func() { 53 | _ = os.Remove(dbFilename) 54 | _ = os.Remove(dbFilename + "-shm") 55 | _ = os.Remove(dbFilename + "-wal") 56 | }() 57 | startDevServerAndRunSimpleTest( 58 | t, 59 | []string{ 60 | "server", "start-dev", 61 | "-p", port, 62 | "--http-port", httpPort, 63 | "--headless", 64 | "--db-filename", dbFilename, 65 | "--sqlite-pragma", "journal_mode=WAL", 66 | "--sqlite-pragma", "synchronous=NORMAL", 67 | "--sqlite-pragma", "busy_timeout=5000", 68 | }, 69 | "0.0.0.0:"+port, 70 | ) 71 | assert.FileExists(t, dbFilename, "sqlite database file not created") 72 | assert.FileExists(t, dbFilename+"-shm", "sqlite shared memory file not created") 73 | assert.FileExists(t, dbFilename+"-wal", "sqlite write-ahead log file not created") 74 | } 75 | 76 | func TestServer_StartDev_IPv6Unspecified(t *testing.T) { 77 | _, err := net.InterfaceByName("::1") 78 | if err != nil { 79 | t.Skip("Machine has no IPv6 support") 80 | return 81 | } 82 | 83 | port := strconv.Itoa(devserver.MustGetFreePort("::")) 84 | httpPort := strconv.Itoa(devserver.MustGetFreePort("::")) 85 | startDevServerAndRunSimpleTest( 86 | t, 87 | []string{ 88 | "server", "start-dev", 89 | "--ip", "::", "--ui-ip", "::1", 90 | "-p", port, 91 | "--http-port", httpPort, 92 | "--ui-port", strconv.Itoa(devserver.MustGetFreePort("::")), 93 | "--http-port", strconv.Itoa(devserver.MustGetFreePort("::")), 94 | "--metrics-port", strconv.Itoa(devserver.MustGetFreePort("::"))}, 95 | "[::]:"+port, 96 | ) 97 | } 98 | 99 | func startDevServerAndRunSimpleTest(t *testing.T, args []string, dialAddress string) { 100 | h := NewCommandHarness(t) 101 | defer h.Close() 102 | 103 | // Start in background, then wait for client to be able to connect 104 | resCh := make(chan *CommandResult, 1) 105 | go func() { resCh <- h.Execute(args...) }() 106 | 107 | // Try to connect for a bit while checking for error 108 | var cl client.Client 109 | h.EventuallyWithT(func(t *assert.CollectT) { 110 | select { 111 | case res := <-resCh: 112 | require.NoError(t, res.Err) 113 | require.Fail(t, "got early server result") 114 | default: 115 | } 116 | var err error 117 | cl, err = client.Dial(client.Options{HostPort: dialAddress}) 118 | assert.NoError(t, err) 119 | }, 3*time.Second, 200*time.Millisecond) 120 | defer cl.Close() 121 | 122 | // Just a simple workflow start will suffice for now 123 | run, err := cl.ExecuteWorkflow( 124 | context.Background(), 125 | client.StartWorkflowOptions{TaskQueue: "my-task-queue"}, 126 | "MyWorkflow", 127 | ) 128 | h.NoError(err) 129 | h.NotEmpty(run.GetRunID()) 130 | 131 | // Send an interrupt by cancelling context 132 | h.CancelContext() 133 | select { 134 | case <-time.After(20 * time.Second): 135 | h.Fail("didn't cleanup after 20 seconds") 136 | case res := <-resCh: 137 | h.NoError(res.Err) 138 | } 139 | } 140 | 141 | func TestServer_StartDev_ConcurrentStarts(t *testing.T) { 142 | startOne := func() { 143 | h := NewCommandHarness(t) 144 | defer h.Close() 145 | 146 | // Start in background, then wait for client to be able to connect 147 | port := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 148 | httpPort := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 149 | resCh := make(chan *CommandResult, 1) 150 | go func() { 151 | resCh <- h.Execute("server", "start-dev", "-p", port, "--http-port", httpPort, "--headless", "--log-level", "never") 152 | }() 153 | 154 | // Try to connect for a bit while checking for error 155 | var cl client.Client 156 | h.EventuallyWithT(func(t *assert.CollectT) { 157 | select { 158 | case res := <-resCh: 159 | require.NoError(t, res.Err) 160 | require.Fail(t, "got early server result") 161 | default: 162 | } 163 | var err error 164 | cl, err = client.Dial(client.Options{HostPort: "127.0.0.1:" + port, Logger: testLogger{t: h.t}}) 165 | assert.NoError(t, err) 166 | }, 3*time.Second, 200*time.Millisecond) 167 | defer cl.Close() 168 | 169 | // Send an interrupt by cancelling context 170 | h.CancelContext() 171 | 172 | select { 173 | case <-time.After(20 * time.Second): 174 | h.Fail("didn't cleanup after 20 seconds") 175 | case res := <-resCh: 176 | h.NoError(res.Err) 177 | } 178 | } 179 | 180 | // Start 40 dev server instances, with 8 concurrent executions 181 | instanceCounter := atomic.Int32{} 182 | instanceCounter.Store(40) 183 | wg := &sync.WaitGroup{} 184 | for i := 0; i < 6; i++ { 185 | wg.Add(1) 186 | go func() { 187 | for instanceCounter.Add(-1) >= 0 { 188 | startOne() 189 | } 190 | wg.Done() 191 | }() 192 | } 193 | wg.Wait() 194 | } 195 | 196 | func TestServer_StartDev_WithSearchAttributes(t *testing.T) { 197 | h := NewCommandHarness(t) 198 | defer h.Close() 199 | 200 | // Start in background, then wait for client to be able to connect 201 | port := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 202 | httpPort := strconv.Itoa(devserver.MustGetFreePort("127.0.0.1")) 203 | resCh := make(chan *CommandResult, 1) 204 | go func() { 205 | resCh <- h.Execute( 206 | "server", "start-dev", 207 | "-p", port, 208 | "--http-port", httpPort, 209 | "--headless", 210 | "--search-attribute", "search-attr-1=Int", 211 | "--search-attribute", "search-attr-2=kEyWoRdLiSt", 212 | ) 213 | }() 214 | 215 | // Try to connect for a bit while checking for error 216 | var cl client.Client 217 | h.EventuallyWithT(func(t *assert.CollectT) { 218 | select { 219 | case res := <-resCh: 220 | if res.Err != nil { 221 | panic(res.Err) 222 | } 223 | default: 224 | } 225 | var err error 226 | cl, err = client.Dial(client.Options{HostPort: "127.0.0.1:" + port}) 227 | if !assert.NoError(t, err) { 228 | return 229 | } 230 | // Confirm search attributes are present 231 | resp, err := cl.OperatorService().ListSearchAttributes( 232 | context.Background(), &operatorservice.ListSearchAttributesRequest{Namespace: "default"}) 233 | if !assert.NoError(t, err) { 234 | return 235 | } 236 | assert.Contains(t, resp.CustomAttributes, "search-attr-1") 237 | assert.Contains(t, resp.CustomAttributes, "search-attr-2") 238 | 239 | }, 3*time.Second, 200*time.Millisecond) 240 | defer cl.Close() 241 | 242 | // Do a workflow start with the search attributes 243 | run, err := cl.ExecuteWorkflow( 244 | context.Background(), 245 | client.StartWorkflowOptions{ 246 | TaskQueue: "my-task-queue", 247 | TypedSearchAttributes: temporal.NewSearchAttributes( 248 | temporal.NewSearchAttributeKeyInt64("search-attr-1").ValueSet(123), 249 | temporal.NewSearchAttributeKeyKeywordList("search-attr-2").ValueSet([]string{"foo", "bar"}), 250 | ), 251 | }, 252 | "MyWorkflow", 253 | ) 254 | h.NoError(err) 255 | h.NotEmpty(run.GetRunID()) 256 | 257 | // Check that they are there 258 | desc, err := cl.DescribeWorkflowExecution(context.Background(), run.GetID(), "") 259 | h.NoError(err) 260 | sa := desc.WorkflowExecutionInfo.SearchAttributes.IndexedFields 261 | h.JSONEq("123", string(sa["search-attr-1"].Data)) 262 | h.JSONEq(`["foo","bar"]`, string(sa["search-attr-2"].Data)) 263 | 264 | // Send an interrupt by cancelling context 265 | h.CancelContext() 266 | select { 267 | case <-time.After(20 * time.Second): 268 | h.Fail("didn't cleanup after 20 seconds") 269 | case res := <-resCh: 270 | h.NoError(res.Err) 271 | } 272 | } 273 | 274 | type testLogger struct { 275 | t *testing.T 276 | } 277 | 278 | func (l testLogger) Debug(msg string, keysAndValues ...interface{}) { 279 | l.t.Logf("DEBUG: "+msg, keysAndValues...) 280 | } 281 | 282 | func (l testLogger) Info(msg string, keysAndValues ...interface{}) { 283 | l.t.Logf("INFO: "+msg, keysAndValues...) 284 | } 285 | 286 | func (l testLogger) Warn(msg string, keysAndValues ...interface{}) { 287 | l.t.Logf("WARN: "+msg, keysAndValues...) 288 | } 289 | 290 | func (l testLogger) Error(msg string, keysAndValues ...interface{}) { 291 | l.t.Logf("ERROR: "+msg, keysAndValues...) 292 | } 293 | -------------------------------------------------------------------------------- /temporalcli/commands.taskqueue_build_id_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "cmp" 5 | "encoding/json" 6 | "slices" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | func (s *SharedServerSuite) TestTaskQueue_BuildId() { 12 | buildIdTaskQueue := uuid.NewString() 13 | res := s.Execute( 14 | "task-queue", "update-build-ids", "add-new-default", 15 | "--address", s.Address(), 16 | "--task-queue", buildIdTaskQueue, 17 | "--build-id", "1.0", 18 | ) 19 | s.NoError(res.Err) 20 | 21 | res = s.Execute( 22 | "task-queue", "update-build-ids", "add-new-default", 23 | "--address", s.Address(), 24 | "--task-queue", buildIdTaskQueue, 25 | "--build-id", "2.0", 26 | ) 27 | s.NoError(res.Err) 28 | 29 | res = s.Execute( 30 | "task-queue", "update-build-ids", "add-new-compatible", 31 | "--address", s.Address(), 32 | "--task-queue", buildIdTaskQueue, 33 | "--build-id", "1.1", 34 | "--existing-compatible-build-id", "1.0", 35 | ) 36 | s.NoError(res.Err) 37 | 38 | // Text 39 | res = s.Execute( 40 | "task-queue", "get-build-ids", 41 | "--address", s.Address(), 42 | "--task-queue", buildIdTaskQueue, 43 | ) 44 | s.NoError(res.Err) 45 | s.ContainsOnSameLine(res.Stdout.String(), "[1.0, 1.1]", "1.1", "false") 46 | s.ContainsOnSameLine(res.Stdout.String(), "[2.0]", "2.0", "true") 47 | 48 | // json 49 | res = s.Execute( 50 | "task-queue", "get-build-ids", 51 | "-o", "json", 52 | "--address", s.Address(), 53 | "--task-queue", buildIdTaskQueue, 54 | ) 55 | s.NoError(res.Err) 56 | type rowType struct { 57 | BuildIds []string `json:"buildIds"` 58 | DefaultForSet string `json:"defaultForSet"` 59 | IsDefaultSet bool `json:"isDefaultSet"` 60 | } 61 | var jsonOut []rowType 62 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 63 | s.Equal([]rowType{ 64 | rowType{ 65 | BuildIds: []string{"1.0", "1.1"}, 66 | DefaultForSet: "1.1", 67 | IsDefaultSet: false, 68 | }, 69 | rowType{ 70 | BuildIds: []string{"2.0"}, 71 | DefaultForSet: "2.0", 72 | IsDefaultSet: true, 73 | }, 74 | }, jsonOut) 75 | 76 | // Test promote commands 77 | res = s.Execute( 78 | "task-queue", "update-build-ids", "promote-id-in-set", 79 | "--address", s.Address(), 80 | "--task-queue", buildIdTaskQueue, 81 | "--build-id", "1.0", 82 | ) 83 | s.NoError(res.Err) 84 | 85 | res = s.Execute( 86 | "task-queue", "update-build-ids", "promote-set", 87 | "--address", s.Address(), 88 | "--task-queue", buildIdTaskQueue, 89 | "--build-id", "1.0", 90 | ) 91 | s.NoError(res.Err) 92 | 93 | // Text 94 | res = s.Execute( 95 | "task-queue", "get-build-ids", 96 | "--address", s.Address(), 97 | "--task-queue", buildIdTaskQueue, 98 | ) 99 | s.NoError(res.Err) 100 | s.ContainsOnSameLine(res.Stdout.String(), "[2.0]", "2.0", "false") 101 | s.ContainsOnSameLine(res.Stdout.String(), "[1.1, 1.0]", "1.0", "true") 102 | 103 | // json 104 | res = s.Execute( 105 | "task-queue", "get-build-ids", 106 | "-o", "json", 107 | "--address", s.Address(), 108 | "--task-queue", buildIdTaskQueue, 109 | ) 110 | s.NoError(res.Err) 111 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 112 | s.Equal([]rowType{ 113 | rowType{ 114 | BuildIds: []string{"2.0"}, 115 | DefaultForSet: "2.0", 116 | IsDefaultSet: false, 117 | }, 118 | rowType{ 119 | BuildIds: []string{"1.1", "1.0"}, 120 | DefaultForSet: "1.0", 121 | IsDefaultSet: true, 122 | }, 123 | }, jsonOut) 124 | 125 | // Test reachability 126 | res = s.Execute( 127 | "task-queue", "get-build-id-reachability", 128 | "--address", s.Address(), 129 | "--task-queue", buildIdTaskQueue, 130 | "--build-id", "1.0", 131 | "--build-id", "1.1", 132 | "--build-id", "2.0", 133 | ) 134 | s.NoError(res.Err) 135 | s.ContainsOnSameLine(res.Stdout.String(), "1.0", buildIdTaskQueue, "[NewWorkflows]") 136 | s.ContainsOnSameLine(res.Stdout.String(), "1.1", buildIdTaskQueue, "[]") 137 | // Not clear why 2.0 can create new workflows if it is not the default... 138 | s.ContainsOnSameLine(res.Stdout.String(), "2.0", buildIdTaskQueue, "[NewWorkflows]") 139 | 140 | res = s.Execute( 141 | "task-queue", "get-build-id-reachability", 142 | "-o", "json", 143 | "--address", s.Address(), 144 | "--task-queue", buildIdTaskQueue, 145 | "--build-id", "1.0", 146 | "--build-id", "1.1", 147 | "--build-id", "2.0", 148 | ) 149 | 150 | s.NoError(res.Err) 151 | type rowReachType struct { 152 | BuildId string `json:"buildId"` 153 | TaskQueue string `json:"taskQueue"` 154 | Reachability []string `json:"reachability"` 155 | } 156 | 157 | var jsonReachOut []rowReachType 158 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonReachOut)) 159 | 160 | // Output ordering does not need to match `build-id` input array order 161 | slices.SortFunc(jsonReachOut, func(a, b rowReachType) int { 162 | return cmp.Compare(a.BuildId, b.BuildId) 163 | }) 164 | 165 | s.Equal([]rowReachType{ 166 | rowReachType{ 167 | BuildId: "1.0", 168 | TaskQueue: buildIdTaskQueue, 169 | Reachability: []string{"NewWorkflows"}, 170 | }, 171 | rowReachType{ 172 | BuildId: "1.1", 173 | TaskQueue: buildIdTaskQueue, 174 | Reachability: []string{}, 175 | }, 176 | rowReachType{ 177 | BuildId: "2.0", 178 | TaskQueue: buildIdTaskQueue, 179 | Reachability: []string{"NewWorkflows"}, 180 | }, 181 | }, jsonReachOut) 182 | } 183 | -------------------------------------------------------------------------------- /temporalcli/commands.taskqueue_get_build_id.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/fatih/color" 8 | "github.com/temporalio/cli/temporalcli/internal/printer" 9 | "go.temporal.io/sdk/client" 10 | ) 11 | 12 | func (c *TemporalTaskQueueGetBuildIdsCommand) run(cctx *CommandContext, args []string) error { 13 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 14 | if err != nil { 15 | return err 16 | } 17 | defer cl.Close() 18 | 19 | sets, err := cl.GetWorkerBuildIdCompatibility(cctx, &client.GetWorkerBuildIdCompatibilityOptions{ 20 | TaskQueue: c.TaskQueue, 21 | MaxSets: c.MaxSets, 22 | }) 23 | if err != nil { 24 | return fmt.Errorf("unable to get task queue build ids: %w", err) 25 | } 26 | 27 | type rowtype struct { 28 | BuildIds []string `json:"buildIds"` 29 | DefaultForSet string `json:"defaultForSet"` 30 | IsDefaultSet bool `json:"isDefaultSet"` 31 | } 32 | var items []rowtype 33 | for ix, s := range sets.Sets { 34 | row := rowtype{ 35 | BuildIds: s.BuildIDs, 36 | IsDefaultSet: ix == len(sets.Sets)-1, 37 | DefaultForSet: s.BuildIDs[len(s.BuildIDs)-1], 38 | } 39 | items = append(items, row) 40 | } 41 | 42 | cctx.Printer.Println(color.MagentaString("Version Sets:")) 43 | return cctx.Printer.PrintStructured(items, printer.StructuredOptions{Table: &printer.TableOptions{}}) 44 | } 45 | 46 | // Missing in the SDK? 47 | func taskReachabilityToString(x client.TaskReachability) string { 48 | switch x { 49 | case client.TaskReachabilityUnspecified: 50 | return "Unspecified" 51 | case client.TaskReachabilityNewWorkflows: 52 | return "NewWorkflows" 53 | case client.TaskReachabilityExistingWorkflows: 54 | return "ExistingWorkflows" 55 | case client.TaskReachabilityOpenWorkflows: 56 | return "OpenWorkflows" 57 | case client.TaskReachabilityClosedWorkflows: 58 | return "ClosedWorkflows" 59 | default: 60 | return strconv.Itoa(int(x)) 61 | } 62 | } 63 | 64 | func (c *TemporalTaskQueueGetBuildIdReachabilityCommand) run(cctx *CommandContext, args []string) error { 65 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 66 | if err != nil { 67 | return err 68 | } 69 | defer cl.Close() 70 | 71 | reachability := client.TaskReachabilityUnspecified 72 | if c.ReachabilityType.Value != "" { 73 | if c.ReachabilityType.Value == "open" { 74 | reachability = client.TaskReachabilityOpenWorkflows 75 | } else if c.ReachabilityType.Value == "closed" { 76 | reachability = client.TaskReachabilityClosedWorkflows 77 | } else if c.ReachabilityType.Value == "existing" { 78 | reachability = client.TaskReachabilityExistingWorkflows 79 | } else { 80 | return fmt.Errorf("invalid reachability type: %v", c.ReachabilityType) 81 | } 82 | } 83 | 84 | reach, err := cl.GetWorkerTaskReachability(cctx, &client.GetWorkerTaskReachabilityOptions{ 85 | BuildIDs: c.BuildId, 86 | TaskQueues: c.TaskQueue, 87 | Reachability: client.TaskReachability(reachability), 88 | }) 89 | if err != nil { 90 | return fmt.Errorf("unable to get Build ID reachability: %w", err) 91 | } 92 | 93 | type rowtype struct { 94 | BuildId string `json:"buildId"` 95 | TaskQueue string `json:"taskQueue"` 96 | Reachability []string `json:"reachability"` 97 | } 98 | 99 | var items []rowtype 100 | 101 | for bid, e := range reach.BuildIDReachability { 102 | for tq, r := range e.TaskQueueReachable { 103 | reachability := make([]string, len(r.TaskQueueReachability)) 104 | for i, v := range r.TaskQueueReachability { 105 | reachability[i] = taskReachabilityToString(v) 106 | } 107 | row := rowtype{ 108 | BuildId: bid, 109 | TaskQueue: tq, 110 | Reachability: reachability, 111 | } 112 | items = append(items, row) 113 | } 114 | } 115 | 116 | cctx.Printer.Println(color.MagentaString("Reachability:")) 117 | return cctx.Printer.PrintStructured(items, printer.StructuredOptions{Table: &printer.TableOptions{}}) 118 | } 119 | -------------------------------------------------------------------------------- /temporalcli/commands.taskqueue_update_build_ids.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.temporal.io/sdk/client" 7 | ) 8 | 9 | func (c *TemporalTaskQueueUpdateBuildIdsCommand) updateBuildIds(cctx *CommandContext, options *client.UpdateWorkerBuildIdCompatibilityOptions) error { 10 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 11 | if err != nil { 12 | return err 13 | } 14 | defer cl.Close() 15 | 16 | if err := cl.UpdateWorkerBuildIdCompatibility(cctx, options); err != nil { 17 | return fmt.Errorf("error updating task queue build IDs: %w", err) 18 | } 19 | 20 | cctx.Printer.Println("Successfully updated task queue build IDs") 21 | return nil 22 | } 23 | 24 | func (c *TemporalTaskQueueUpdateBuildIdsAddNewDefaultCommand) run(cctx *CommandContext, args []string) error { 25 | options := &client.UpdateWorkerBuildIdCompatibilityOptions{ 26 | TaskQueue: c.TaskQueue, 27 | Operation: &client.BuildIDOpAddNewIDInNewDefaultSet{ 28 | BuildID: c.BuildId, 29 | }, 30 | } 31 | err := c.Parent.updateBuildIds(cctx, options) 32 | if err != nil { 33 | return err 34 | } 35 | return nil 36 | } 37 | 38 | func (c *TemporalTaskQueueUpdateBuildIdsAddNewCompatibleCommand) run(cctx *CommandContext, args []string) error { 39 | options := &client.UpdateWorkerBuildIdCompatibilityOptions{ 40 | TaskQueue: c.TaskQueue, 41 | Operation: &client.BuildIDOpAddNewCompatibleVersion{ 42 | BuildID: c.BuildId, 43 | ExistingCompatibleBuildID: c.ExistingCompatibleBuildId, 44 | MakeSetDefault: c.SetAsDefault, 45 | }, 46 | } 47 | err := c.Parent.updateBuildIds(cctx, options) 48 | if err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | 54 | func (c *TemporalTaskQueueUpdateBuildIdsPromoteSetCommand) run(cctx *CommandContext, args []string) error { 55 | options := &client.UpdateWorkerBuildIdCompatibilityOptions{ 56 | TaskQueue: c.TaskQueue, 57 | Operation: &client.BuildIDOpPromoteSet{ 58 | BuildID: c.BuildId, 59 | }, 60 | } 61 | err := c.Parent.updateBuildIds(cctx, options) 62 | if err != nil { 63 | return err 64 | } 65 | return nil 66 | } 67 | 68 | func (c *TemporalTaskQueueUpdateBuildIdsPromoteIdInSetCommand) run(cctx *CommandContext, args []string) error { 69 | options := &client.UpdateWorkerBuildIdCompatibilityOptions{ 70 | TaskQueue: c.TaskQueue, 71 | Operation: &client.BuildIDOpPromoteIDWithinSet{ 72 | BuildID: c.BuildId, 73 | }, 74 | } 75 | err := c.Parent.updateBuildIds(cctx, options) 76 | if err != nil { 77 | return err 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /temporalcli/commands.taskqueue_versioning_rules_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *SharedServerSuite) TestTaskQueue_Rules_BuildId() { 11 | type assignmentRowType struct { 12 | TargetBuildID string `json:"targetBuildID"` 13 | RampPercentage float32 `json:"rampPercentage"` 14 | CreateTime time.Time `json:"-"` 15 | } 16 | 17 | type redirectRowType struct { 18 | SourceBuildID string `json:"sourceBuildID"` 19 | TargetBuildID string `json:"targetBuildID"` 20 | CreateTime time.Time `json:"-"` 21 | } 22 | 23 | type formattedRulesType struct { 24 | AssignmentRules []assignmentRowType `json:"assignmentRules"` 25 | RedirectRules []redirectRowType `json:"redirectRules"` 26 | } 27 | 28 | buildIdTaskQueue := uuid.NewString() 29 | 30 | res := s.Execute( 31 | "task-queue", "versioning", "get-rules", 32 | "--address", s.Address(), 33 | "--task-queue", buildIdTaskQueue, 34 | "--output", "json", 35 | ) 36 | s.NoError(res.Err) 37 | 38 | var jsonOut formattedRulesType 39 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 40 | s.Equal(formattedRulesType{}, jsonOut) 41 | 42 | res = s.Execute( 43 | "task-queue", "versioning", "insert-assignment-rule", 44 | "--build-id", "id1", 45 | "-y", 46 | "--address", s.Address(), 47 | "--task-queue", buildIdTaskQueue, 48 | "--output", "json", 49 | ) 50 | s.NoError(res.Err) 51 | 52 | res = s.Execute( 53 | "task-queue", "versioning", "insert-assignment-rule", 54 | "--build-id", "id2", 55 | "--percentage", "10", 56 | "--rule-index", "0", 57 | "-y", 58 | "--address", s.Address(), 59 | "--task-queue", buildIdTaskQueue, 60 | "--output", "json", 61 | ) 62 | s.NoError(res.Err) 63 | 64 | res = s.Execute( 65 | "task-queue", "versioning", "replace-assignment-rule", 66 | "--build-id", "id2", 67 | "--percentage", "40", 68 | "--rule-index", "0", 69 | "-y", 70 | "--address", s.Address(), 71 | "--task-queue", buildIdTaskQueue, 72 | "--output", "json", 73 | ) 74 | s.NoError(res.Err) 75 | 76 | res = s.Execute( 77 | "task-queue", "versioning", "insert-assignment-rule", 78 | "--build-id", "id3", 79 | "--percentage", "10", 80 | "--rule-index", "100", 81 | "-y", 82 | "--address", s.Address(), 83 | "--task-queue", buildIdTaskQueue, 84 | "--output", "json", 85 | ) 86 | s.NoError(res.Err) 87 | 88 | res = s.Execute( 89 | "task-queue", "versioning", "delete-assignment-rule", 90 | "--rule-index", "2", 91 | "-y", 92 | "--address", s.Address(), 93 | "--task-queue", buildIdTaskQueue, 94 | ) 95 | s.NoError(res.Err) 96 | 97 | res = s.Execute( 98 | "task-queue", "versioning", "add-redirect-rule", 99 | "--source-build-id", "id1", 100 | "--target-build-id", "id3", 101 | "-y", 102 | "--address", s.Address(), 103 | "--task-queue", buildIdTaskQueue, 104 | "--output", "json", 105 | ) 106 | s.NoError(res.Err) 107 | 108 | res = s.Execute( 109 | "task-queue", "versioning", "add-redirect-rule", 110 | "--source-build-id", "id3", 111 | "--target-build-id", "id4", 112 | "-y", 113 | "--address", s.Address(), 114 | "--task-queue", buildIdTaskQueue, 115 | ) 116 | s.NoError(res.Err) 117 | 118 | res = s.Execute( 119 | "task-queue", "versioning", "replace-redirect-rule", 120 | "--source-build-id", "id3", 121 | "--target-build-id", "id5", 122 | "-y", 123 | "--address", s.Address(), 124 | "--task-queue", buildIdTaskQueue, 125 | "--output", "json", 126 | ) 127 | s.NoError(res.Err) 128 | 129 | res = s.Execute( 130 | "task-queue", "versioning", "delete-redirect-rule", 131 | "--source-build-id", "id1", 132 | "-y", 133 | "--address", s.Address(), 134 | "--task-queue", buildIdTaskQueue, 135 | "--output", "json", 136 | ) 137 | s.NoError(res.Err) 138 | 139 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 140 | s.Equal(formattedRulesType{ 141 | AssignmentRules: []assignmentRowType{ 142 | { 143 | TargetBuildID: "id2", 144 | RampPercentage: 40.0, 145 | }, 146 | { 147 | TargetBuildID: "id1", 148 | RampPercentage: 100.0, 149 | }, 150 | }, 151 | RedirectRules: []redirectRowType{ 152 | { 153 | SourceBuildID: "id3", 154 | TargetBuildID: "id5", 155 | }, 156 | }, 157 | }, jsonOut) 158 | 159 | // Plain output 160 | 161 | res = s.Execute( 162 | "task-queue", "versioning", "get-rules", 163 | "--address", s.Address(), 164 | "--task-queue", buildIdTaskQueue, 165 | ) 166 | s.NoError(res.Err) 167 | 168 | s.ContainsOnSameLine(res.Stdout.String(), "0", "id2", "40") 169 | s.ContainsOnSameLine(res.Stdout.String(), "1", "id1", "100") 170 | s.ContainsOnSameLine(res.Stdout.String(), "id3", "id5") 171 | 172 | // Safe mode 173 | 174 | s.CommandHarness.Stdin.WriteString("y\n") 175 | res = s.Execute( 176 | "task-queue", "versioning", "replace-redirect-rule", 177 | "--source-build-id", "id3", 178 | "--target-build-id", "id9", 179 | "--address", s.Address(), 180 | "--task-queue", buildIdTaskQueue, 181 | "--output", "json", 182 | ) 183 | s.Error(res.Err) // json output needs needs autoconfirm 184 | 185 | s.CommandHarness.Stdin.WriteString("y\n") 186 | res = s.Execute( 187 | "task-queue", "versioning", "replace-redirect-rule", 188 | "--source-build-id", "id3", 189 | "--target-build-id", "id9", 190 | "--address", s.Address(), 191 | "--task-queue", buildIdTaskQueue, 192 | ) 193 | s.NoError(res.Err) 194 | // Shown before replacing 195 | s.ContainsOnSameLine(res.Stdout.String(), "id3", "id5") 196 | // Shown after replacing 197 | s.ContainsOnSameLine(res.Stdout.String(), "id3", "id9") 198 | 199 | // Commit 200 | 201 | res = s.Execute( 202 | "task-queue", "versioning", "commit-build-id", 203 | "--build-id", "id2", 204 | "--force", 205 | "-y", 206 | "--address", s.Address(), 207 | "--task-queue", buildIdTaskQueue, 208 | "--output", "json", 209 | ) 210 | 211 | s.NoError(json.Unmarshal(res.Stdout.Bytes(), &jsonOut)) 212 | s.Equal(formattedRulesType{ 213 | AssignmentRules: []assignmentRowType{ 214 | { 215 | TargetBuildID: "id2", 216 | RampPercentage: 100.0, 217 | }, 218 | }, 219 | RedirectRules: []redirectRowType{ 220 | { 221 | SourceBuildID: "id3", 222 | TargetBuildID: "id9", 223 | }, 224 | }, 225 | }, jsonOut) 226 | } 227 | -------------------------------------------------------------------------------- /temporalcli/commands.workflow_fix.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | 7 | "go.temporal.io/sdk/client" 8 | "google.golang.org/protobuf/encoding/protojson" 9 | ) 10 | 11 | func (c *TemporalWorkflowFixHistoryJsonCommand) run(cctx *CommandContext, args []string) error { 12 | raw, err := os.ReadFile(c.Source) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | hjo := client.HistoryJSONOptions{} 18 | history, err := client.HistoryFromJSON(bytes.NewReader(raw), hjo) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | mo := protojson.MarshalOptions{Indent: " "} 24 | raw, err = mo.Marshal(history) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | switch c.Target { 30 | case "", "-": 31 | _, err = cctx.Options.Stdout.Write(raw) 32 | return err 33 | 34 | default: 35 | return os.WriteFile(c.Target, raw, 0o666) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /temporalcli/commands.workflow_trace.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "syscall" 7 | "time" 8 | 9 | "github.com/fatih/color" 10 | "github.com/temporalio/cli/temporalcli/internal/printer" 11 | "github.com/temporalio/cli/temporalcli/internal/tracer" 12 | "go.temporal.io/api/enums/v1" 13 | "go.temporal.io/sdk/client" 14 | ) 15 | 16 | var workflowTraceFoldFlags = map[string]enums.WorkflowExecutionStatus{ 17 | "running": enums.WORKFLOW_EXECUTION_STATUS_RUNNING, 18 | "completed": enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 19 | "failed": enums.WORKFLOW_EXECUTION_STATUS_FAILED, 20 | "canceled": enums.WORKFLOW_EXECUTION_STATUS_CANCELED, 21 | "terminated": enums.WORKFLOW_EXECUTION_STATUS_TERMINATED, 22 | "timedout": enums.WORKFLOW_EXECUTION_STATUS_TIMED_OUT, 23 | "continueasnew": enums.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW, 24 | } 25 | 26 | func (c *TemporalWorkflowTraceCommand) getFoldStatuses() ([]enums.WorkflowExecutionStatus, error) { 27 | // defaults 28 | if len(c.Fold) == 0 { 29 | return []enums.WorkflowExecutionStatus{ 30 | enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 31 | enums.WORKFLOW_EXECUTION_STATUS_CANCELED, 32 | enums.WORKFLOW_EXECUTION_STATUS_TERMINATED, 33 | }, nil 34 | } 35 | 36 | // parse flags 37 | var values []enums.WorkflowExecutionStatus 38 | for _, flag := range c.Fold { 39 | status, ok := workflowTraceFoldFlags[flag] 40 | if !ok { 41 | return nil, fmt.Errorf("fold status %q not recognized", flag) 42 | } 43 | values = append(values, status) 44 | } 45 | return values, nil 46 | } 47 | 48 | func (c *TemporalWorkflowTraceCommand) run(cctx *CommandContext, _ []string) error { 49 | if cctx.JSONOutput { 50 | return fmt.Errorf("JSON output not supported for trace command") 51 | } 52 | cl, err := c.Parent.ClientOptions.dialClient(cctx) 53 | if err != nil { 54 | return err 55 | } 56 | defer cl.Close() 57 | 58 | opts := tracer.WorkflowTracerOptions{ 59 | Depth: c.Depth, 60 | Concurrency: c.Concurrency, 61 | NoFold: c.NoFold, 62 | } 63 | opts.FoldStatuses, err = c.getFoldStatuses() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if err = c.printWorkflowSummary(cctx, cl, c.WorkflowId, c.RunId); err != nil { 69 | return err 70 | } 71 | _, err = c.printWorkflowTrace(cctx, cl, c.WorkflowId, c.RunId, opts) 72 | 73 | return err 74 | } 75 | 76 | type workflowTraceSummary struct { 77 | WorkflowId string `json:"workflowId"` 78 | RunId string `json:"runId"` 79 | Type string `json:"type"` 80 | Namespace string `json:"namespace"` 81 | TaskQueue string `json:"taskQueue"` 82 | } 83 | 84 | // printWorkflowSummary prints a summary of the workflow execution, similar to the one available when starting a workflow. 85 | func (c *TemporalWorkflowTraceCommand) printWorkflowSummary(cctx *CommandContext, cl client.Client, wfId, runId string) error { 86 | res, err := cl.DescribeWorkflowExecution(cctx, wfId, runId) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | info := res.GetWorkflowExecutionInfo() 92 | 93 | cctx.Printer.Println(color.MagentaString("Execution summary:")) 94 | 95 | _ = cctx.Printer.PrintStructured(workflowTraceSummary{ 96 | WorkflowId: info.GetExecution().GetWorkflowId(), 97 | RunId: info.GetExecution().GetRunId(), 98 | Type: info.GetType().GetName(), 99 | Namespace: c.Parent.Namespace, 100 | TaskQueue: info.GetTaskQueue(), 101 | }, printer.StructuredOptions{}) 102 | cctx.Printer.Println() 103 | 104 | return err 105 | } 106 | 107 | // PrintWorkflowTrace prints and updates a workflow trace following printWorkflowProgress pattern 108 | func (c *TemporalWorkflowTraceCommand) printWorkflowTrace(cctx *CommandContext, cl client.Client, wid, rid string, opts tracer.WorkflowTracerOptions) (int, error) { 109 | // Load templates 110 | tmpl, err := tracer.NewExecutionTemplate(opts.FoldStatuses, opts.NoFold) 111 | if err != nil { 112 | return 1, err 113 | } 114 | 115 | workflowTracer, err := tracer.NewWorkflowTracer(cl, 116 | tracer.WithOptions(opts), 117 | tracer.WithOutput(cctx.Printer.Output), 118 | tracer.WithInterrupts(os.Interrupt, syscall.SIGTERM, syscall.SIGINT), 119 | ) 120 | if err != nil { 121 | return 1, err 122 | } 123 | 124 | err = workflowTracer.GetExecutionUpdates(cctx, wid, rid) 125 | if err != nil { 126 | return 1, err 127 | } 128 | 129 | cctx.Printer.Println(color.MagentaString("Progress:")) 130 | return workflowTracer.PrintUpdates(tmpl, time.Second) 131 | } 132 | -------------------------------------------------------------------------------- /temporalcli/commands.workflow_trace_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "go.temporal.io/api/enums/v1" 9 | "go.temporal.io/sdk/client" 10 | "go.temporal.io/sdk/workflow" 11 | ) 12 | 13 | func (s *SharedServerSuite) TestWorkflow_Trace_Summary() { 14 | // Start the workflow and wait until it has at least reached activity failure 15 | run, err := s.Client.ExecuteWorkflow( 16 | s.Context, 17 | client.StartWorkflowOptions{TaskQueue: s.Worker().Options.TaskQueue}, 18 | DevWorkflow, 19 | map[string]string{"foo": "bar"}, 20 | ) 21 | s.NoError(err) 22 | s.NoError(run.Get(s.Context, nil)) 23 | 24 | // Text 25 | res := s.Execute( 26 | "workflow", "trace", 27 | "--address", s.Address(), 28 | "-w", run.GetID(), 29 | ) 30 | s.NoError(res.Err) 31 | out := res.Stdout.String() 32 | 33 | s.ContainsOnSameLine(out, "WorkflowId", run.GetID()) 34 | s.ContainsOnSameLine(out, "RunId", run.GetRunID()) 35 | s.ContainsOnSameLine(out, "Type", "DevWorkflow") 36 | s.ContainsOnSameLine(out, "Namespace", s.Namespace()) 37 | s.ContainsOnSameLine(out, "TaskQueue", s.Worker().Options.TaskQueue) 38 | } 39 | 40 | func (s *SharedServerSuite) TestWorkflow_Trace_Complete() { 41 | // Start the workflow and wait until it has at least reached activity failure 42 | run, err := s.Client.ExecuteWorkflow( 43 | s.Context, 44 | client.StartWorkflowOptions{TaskQueue: s.Worker().Options.TaskQueue}, 45 | DevWorkflow, 46 | map[string]string{"foo": "bar"}, 47 | ) 48 | s.NoError(err) 49 | s.NoError(run.Get(s.Context, nil)) 50 | 51 | // Text 52 | res := s.Execute( 53 | "workflow", "trace", 54 | "--address", s.Address(), 55 | "-w", run.GetID(), 56 | ) 57 | s.NoError(res.Err) 58 | out := res.Stdout.String() 59 | 60 | s.ContainsOnSameLine(out, "WorkflowId", run.GetID()) 61 | s.Contains(out, "╪ ✓ DevWorkflow") 62 | s.Contains(out, fmt.Sprintf("wfId: %s, runId: %s", run.GetID(), run.GetRunID())) 63 | s.Contains(out, " │ ┼ ✓ DevActivity") 64 | } 65 | 66 | func (s *SharedServerSuite) TestWorkflow_Trace_Failure() { 67 | s.Worker().OnDevActivity(func(ctx context.Context, a any) (any, error) { 68 | return nil, fmt.Errorf("intentional error") 69 | }) 70 | s.Worker().OnDevWorkflow(func(ctx workflow.Context, input any) (any, error) { 71 | ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 72 | StartToCloseTimeout: 5 * time.Second, 73 | }) 74 | var res any 75 | err := workflow.ExecuteActivity(ctx, DevActivity, input).Get(ctx, &res) 76 | return res, err 77 | }) 78 | 79 | // Start the workflow and wait until it has at least reached activity failure 80 | run, err := s.Client.ExecuteWorkflow( 81 | s.Context, 82 | client.StartWorkflowOptions{TaskQueue: s.Worker().Options.TaskQueue, WorkflowExecutionTimeout: time.Second}, 83 | DevWorkflow, 84 | map[string]string{"foo": "bar"}, 85 | ) 86 | s.NoError(err) 87 | s.Eventually(func() bool { 88 | resp, err := s.Client.DescribeWorkflowExecution(s.Context, run.GetID(), run.GetRunID()) 89 | s.NoError(err) 90 | return resp.WorkflowExecutionInfo.Status == enums.WORKFLOW_EXECUTION_STATUS_FAILED 91 | }, 5*time.Second, 100*time.Millisecond) 92 | 93 | // Text 94 | res := s.Execute( 95 | "workflow", "trace", 96 | "--address", s.Address(), 97 | "-w", run.GetID(), 98 | ) 99 | s.NoError(res.Err) 100 | out := res.Stdout.String() 101 | 102 | s.Contains(out, "╪ ! DevWorkflow") 103 | s.Contains(out, fmt.Sprintf("wfId: %s, runId: %s", run.GetID(), run.GetRunID())) 104 | s.Contains(out, " │ Failure: activity error") 105 | s.Contains(out, " │ ┼ ! DevActivity") 106 | s.Contains(out, " │ │ Failure: intentional error") 107 | } 108 | 109 | func (s *SharedServerSuite) TestWorkflow_Trace_Fold() { 110 | s.Worker().OnDevActivity(func(ctx context.Context, a any) (any, error) { 111 | return nil, fmt.Errorf("intentional error") 112 | }) 113 | s.Worker().OnDevWorkflow(func(ctx workflow.Context, input any) (any, error) { 114 | ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 115 | StartToCloseTimeout: 5 * time.Second, 116 | }) 117 | var res any 118 | err := workflow.ExecuteActivity(ctx, DevActivity, input).Get(ctx, &res) 119 | return res, err 120 | }) 121 | 122 | // Start the workflow and wait until it has at least reached activity failure 123 | run, err := s.Client.ExecuteWorkflow( 124 | s.Context, 125 | client.StartWorkflowOptions{TaskQueue: s.Worker().Options.TaskQueue, WorkflowExecutionTimeout: time.Second}, 126 | DevWorkflow, 127 | map[string]string{"foo": "bar"}, 128 | ) 129 | s.NoError(err) 130 | s.Eventually(func() bool { 131 | resp, err := s.Client.DescribeWorkflowExecution(s.Context, run.GetID(), run.GetRunID()) 132 | s.NoError(err) 133 | return resp.WorkflowExecutionInfo.Status == enums.WORKFLOW_EXECUTION_STATUS_FAILED 134 | }, 5*time.Second, 100*time.Millisecond) 135 | 136 | // Text 137 | res := s.Execute( 138 | "workflow", "trace", 139 | "--address", s.Address(), 140 | "-w", run.GetID(), 141 | ) 142 | s.NoError(res.Err) 143 | out := res.Stdout.String() 144 | 145 | s.Contains(out, "╪ ! DevWorkflow") 146 | s.Contains(out, fmt.Sprintf("wfId: %s, runId: %s", run.GetID(), run.GetRunID())) 147 | s.Contains(out, " │ Failure: activity error") 148 | s.Contains(out, " │ ┼ ! DevActivity") 149 | s.Contains(out, " │ │ Failure: intentional error") 150 | } 151 | -------------------------------------------------------------------------------- /temporalcli/commandsgen/docs.go: -------------------------------------------------------------------------------- 1 | package commandsgen 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { 12 | 13 | optionSetMap := make(map[string]OptionSets) 14 | for i, optionSet := range commands.OptionSets { 15 | optionSetMap[optionSet.Name] = commands.OptionSets[i] 16 | } 17 | 18 | w := &docWriter{ 19 | fileMap: make(map[string]*bytes.Buffer), 20 | optionSetMap: optionSetMap, 21 | allCommands: commands.CommandList, 22 | } 23 | 24 | // sorted ascending by full name of command (activity complete, batch list, etc) 25 | for _, cmd := range commands.CommandList { 26 | if err := cmd.writeDoc(w); err != nil { 27 | return nil, fmt.Errorf("failed writing docs for command %s: %w", cmd.FullName, err) 28 | } 29 | } 30 | 31 | // Format and return 32 | var finalMap = make(map[string][]byte) 33 | for key, buf := range w.fileMap { 34 | finalMap[key] = buf.Bytes() 35 | } 36 | return finalMap, nil 37 | } 38 | 39 | type docWriter struct { 40 | allCommands []Command 41 | fileMap map[string]*bytes.Buffer 42 | optionSetMap map[string]OptionSets 43 | optionsStack [][]Option 44 | } 45 | 46 | func (c *Command) writeDoc(w *docWriter) error { 47 | w.processOptions(c) 48 | 49 | // If this is a root command, write a new file 50 | depth := c.depth() 51 | if depth == 1 { 52 | w.writeCommand(c) 53 | } else if depth > 1 { 54 | w.writeSubcommand(c) 55 | } 56 | return nil 57 | } 58 | 59 | func (w *docWriter) writeCommand(c *Command) { 60 | fileName := c.fileName() 61 | w.fileMap[fileName] = &bytes.Buffer{} 62 | w.fileMap[fileName].WriteString("---\n") 63 | w.fileMap[fileName].WriteString("id: " + fileName + "\n") 64 | w.fileMap[fileName].WriteString("title: Temporal CLI " + fileName + " command reference\n") 65 | w.fileMap[fileName].WriteString("sidebar_label: " + fileName + "\n") 66 | w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n") 67 | w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") 68 | 69 | w.fileMap[fileName].WriteString("keywords:\n") 70 | for _, keyword := range c.Docs.Keywords { 71 | w.fileMap[fileName].WriteString(" - " + keyword + "\n") 72 | } 73 | w.fileMap[fileName].WriteString("tags:\n") 74 | for _, tag := range c.Docs.Tags { 75 | w.fileMap[fileName].WriteString(" - " + tag + "\n") 76 | } 77 | w.fileMap[fileName].WriteString("---\n\n") 78 | } 79 | 80 | func (w *docWriter) writeSubcommand(c *Command) { 81 | fileName := c.fileName() 82 | prefix := strings.Repeat("#", c.depth()) 83 | w.fileMap[fileName].WriteString(prefix + " " + c.leafName() + "\n\n") 84 | w.fileMap[fileName].WriteString(c.Description + "\n\n") 85 | 86 | if w.isLeafCommand(c) { 87 | w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command.\n\n") 88 | 89 | // gather options from command and all options aviailable from parent commands 90 | var options = make([]Option, 0) 91 | var globalOptions = make([]Option, 0) 92 | for i, o := range w.optionsStack { 93 | if i == len(w.optionsStack)-1 { 94 | options = append(options, o...) 95 | } else { 96 | globalOptions = append(globalOptions, o...) 97 | } 98 | } 99 | 100 | // alphabetize options 101 | sort.Slice(options, func(i, j int) bool { 102 | return options[i].Name < options[j].Name 103 | }) 104 | 105 | sort.Slice(globalOptions, func(i, j int) bool { 106 | return globalOptions[i].Name < globalOptions[j].Name 107 | }) 108 | 109 | w.writeOptions("Flags", options, c) 110 | w.writeOptions("Global Flags", globalOptions, c) 111 | 112 | } 113 | } 114 | 115 | func (w *docWriter) writeOptions(prefix string, options []Option, c *Command) { 116 | if len(options) == 0 { 117 | return 118 | } 119 | 120 | fileName := c.fileName() 121 | 122 | w.fileMap[fileName].WriteString(fmt.Sprintf("**%s:**\n\n", prefix)) 123 | 124 | for _, o := range options { 125 | // option name and alias 126 | w.fileMap[fileName].WriteString(fmt.Sprintf("**--%s**", o.Name)) 127 | if len(o.Short) > 0 { 128 | w.fileMap[fileName].WriteString(fmt.Sprintf(", **-%s**", o.Short)) 129 | } 130 | w.fileMap[fileName].WriteString(fmt.Sprintf(" _%s_\n\n", o.Type)) 131 | 132 | // description 133 | w.fileMap[fileName].WriteString(encodeJSONExample(o.Description)) 134 | if o.Required { 135 | w.fileMap[fileName].WriteString(" Required.") 136 | } 137 | if len(o.EnumValues) > 0 { 138 | w.fileMap[fileName].WriteString(fmt.Sprintf(" Accepted values: %s.", strings.Join(o.EnumValues, ", "))) 139 | } 140 | if len(o.Default) > 0 { 141 | w.fileMap[fileName].WriteString(fmt.Sprintf(` (default "%s")`, o.Default)) 142 | } 143 | w.fileMap[fileName].WriteString("\n\n") 144 | 145 | if o.Experimental { 146 | w.fileMap[fileName].WriteString(":::note" + "\n\n") 147 | w.fileMap[fileName].WriteString("Option is experimental." + "\n\n") 148 | w.fileMap[fileName].WriteString(":::" + "\n\n") 149 | } 150 | } 151 | } 152 | 153 | func (w *docWriter) processOptions(c *Command) { 154 | // Pop options from stack if we are moving up a level 155 | if len(w.optionsStack) >= len(strings.Split(c.FullName, " ")) { 156 | w.optionsStack = w.optionsStack[:len(w.optionsStack)-1] 157 | } 158 | var options []Option 159 | options = append(options, c.Options...) 160 | 161 | // Maintain stack of options available from parent commands 162 | for _, set := range c.OptionSets { 163 | optionSetOptions := w.optionSetMap[set].Options 164 | options = append(options, optionSetOptions...) 165 | } 166 | 167 | w.optionsStack = append(w.optionsStack, options) 168 | } 169 | 170 | func (w *docWriter) isLeafCommand(c *Command) bool { 171 | for _, maybeSubCmd := range w.allCommands { 172 | if maybeSubCmd.isSubCommand(c) { 173 | return false 174 | } 175 | } 176 | return true 177 | } 178 | 179 | func encodeJSONExample(v string) string { 180 | // example: 'YourKey={"your": "value"}' 181 | // results in an mdx acorn rendering error 182 | // and wrapping in backticks lets it render 183 | re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) 184 | v = re.ReplaceAllString(v, "`$1`") 185 | return v 186 | } 187 | -------------------------------------------------------------------------------- /temporalcli/commandsgen/parse.go: -------------------------------------------------------------------------------- 1 | // Package commandsgen is built to read the YAML format described in 2 | // temporalcli/commandsgen/commands.yml and generate code from it. 3 | package commandsgen 4 | 5 | import ( 6 | "bytes" 7 | _ "embed" 8 | "fmt" 9 | "regexp" 10 | "slices" 11 | "sort" 12 | "strings" 13 | 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | //go:embed commands.yml 18 | var CommandsYAML []byte 19 | 20 | type ( 21 | // Option represents the structure of an option within option sets. 22 | Option struct { 23 | Name string `yaml:"name"` 24 | Type string `yaml:"type"` 25 | Description string `yaml:"description"` 26 | Deprecated string `yaml:"deprecated"` 27 | Short string `yaml:"short,omitempty"` 28 | Default string `yaml:"default,omitempty"` 29 | Env string `yaml:"env,omitempty"` 30 | ImpliedEnv string `yaml:"implied-env,omitempty"` 31 | Required bool `yaml:"required,omitempty"` 32 | Aliases []string `yaml:"aliases,omitempty"` 33 | EnumValues []string `yaml:"enum-values,omitempty"` 34 | Experimental bool `yaml:"experimental,omitempty"` 35 | HiddenLegacyValues []string `yaml:"hidden-legacy-values,omitempty"` 36 | } 37 | 38 | // Command represents the structure of each command in the commands map. 39 | Command struct { 40 | FullName string `yaml:"name"` 41 | NamePath []string 42 | Summary string `yaml:"summary"` 43 | Description string `yaml:"description"` 44 | DescriptionPlain string 45 | DescriptionHighlighted string 46 | Deprecated string `yaml:"deprecated"` 47 | HasInit bool `yaml:"has-init"` 48 | ExactArgs int `yaml:"exact-args"` 49 | MaximumArgs int `yaml:"maximum-args"` 50 | IgnoreMissingEnv bool `yaml:"ignores-missing-env"` 51 | Options []Option `yaml:"options"` 52 | OptionSets []string `yaml:"option-sets"` 53 | Docs Docs `yaml:"docs"` 54 | } 55 | 56 | // Docs represents docs-only information that is not used in CLI generation. 57 | Docs struct { 58 | Keywords []string `yaml:"keywords"` 59 | DescriptionHeader string `yaml:"description-header"` 60 | Tags []string `yaml:"tags"` 61 | } 62 | 63 | // OptionSets represents the structure of option sets. 64 | OptionSets struct { 65 | Name string `yaml:"name"` 66 | Description string `yaml:"description"` 67 | Options []Option `yaml:"options"` 68 | } 69 | 70 | // Commands represents the top-level structure holding commands and option sets. 71 | Commands struct { 72 | CommandList []Command `yaml:"commands"` 73 | OptionSets []OptionSets `yaml:"option-sets"` 74 | } 75 | ) 76 | 77 | func ParseCommands() (Commands, error) { 78 | // Fix CRLF 79 | md := bytes.ReplaceAll(CommandsYAML, []byte("\r\n"), []byte("\n")) 80 | 81 | var m Commands 82 | err := yaml.Unmarshal(md, &m) 83 | if err != nil { 84 | return Commands{}, fmt.Errorf("failed unmarshalling yaml: %w", err) 85 | } 86 | 87 | for i, optionSet := range m.OptionSets { 88 | if err := m.OptionSets[i].processSection(); err != nil { 89 | return Commands{}, fmt.Errorf("failed parsing option set section %q: %w", optionSet.Name, err) 90 | } 91 | } 92 | 93 | for i, command := range m.CommandList { 94 | if err := m.CommandList[i].processSection(); err != nil { 95 | return Commands{}, fmt.Errorf("failed parsing command section %q: %w", command.FullName, err) 96 | } 97 | } 98 | 99 | // alphabetize commands 100 | sort.Slice(m.CommandList, func(i, j int) bool { 101 | return m.CommandList[i].FullName < m.CommandList[j].FullName 102 | }) 103 | 104 | return m, nil 105 | } 106 | 107 | var markdownLinkPattern = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`) 108 | var markdownBlockCodeRegex = regexp.MustCompile("```([\\s\\S]+?)```") 109 | var markdownInlineCodeRegex = regexp.MustCompile("`([^`]+)`") 110 | 111 | const ansiReset = "\033[0m" 112 | const ansiBold = "\033[1m" 113 | 114 | func (o OptionSets) processSection() error { 115 | if o.Name == "" { 116 | return fmt.Errorf("missing option set name") 117 | } 118 | 119 | for i, option := range o.Options { 120 | if err := o.Options[i].processSection(); err != nil { 121 | return fmt.Errorf("failed parsing option '%v': %w", option.Name, err) 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (c *Command) processSection() error { 129 | if c.FullName == "" { 130 | return fmt.Errorf("missing command name") 131 | } 132 | c.NamePath = strings.Split(c.FullName, " ") 133 | 134 | if c.Summary == "" { 135 | return fmt.Errorf("missing summary for command") 136 | } 137 | if c.Summary[len(c.Summary)-1] == '.' { 138 | return fmt.Errorf("summary should not end in a '.'") 139 | } 140 | 141 | if c.MaximumArgs != 0 && c.ExactArgs != 0 { 142 | return fmt.Errorf("cannot have both maximum-args and exact-args") 143 | } 144 | 145 | if c.Description == "" { 146 | return fmt.Errorf("missing description for command: %s", c.FullName) 147 | } 148 | 149 | if len(c.NamePath) == 2 { 150 | if c.Docs.Keywords == nil { 151 | return fmt.Errorf("missing keywords for root command: %s", c.FullName) 152 | } 153 | if c.Docs.DescriptionHeader == "" { 154 | return fmt.Errorf("missing description for root command: %s", c.FullName) 155 | } 156 | if len(c.Docs.Tags) == 0 { 157 | return fmt.Errorf("missing tags for root command: %s", c.FullName) 158 | } 159 | } 160 | 161 | // Strip trailing newline for description 162 | c.Description = strings.TrimRight(c.Description, "\n") 163 | 164 | // Strip links for long plain/highlighted 165 | c.DescriptionPlain = markdownLinkPattern.ReplaceAllString(c.Description, "$1") 166 | c.DescriptionHighlighted = c.DescriptionPlain 167 | 168 | // Highlight code for long highlighted 169 | c.DescriptionHighlighted = markdownBlockCodeRegex.ReplaceAllStringFunc(c.DescriptionHighlighted, func(s string) string { 170 | s = strings.Trim(s, "`") 171 | s = strings.Trim(s, " ") 172 | s = strings.Trim(s, "\n") 173 | return ansiBold + s + ansiReset 174 | }) 175 | c.DescriptionHighlighted = markdownInlineCodeRegex.ReplaceAllStringFunc(c.DescriptionHighlighted, func(s string) string { 176 | s = strings.Trim(s, "`") 177 | return ansiBold + s + ansiReset 178 | }) 179 | 180 | // Each option 181 | for i, option := range c.Options { 182 | if err := c.Options[i].processSection(); err != nil { 183 | return fmt.Errorf("failed parsing option '%v': %w", option.Name, err) 184 | } 185 | } 186 | 187 | return nil 188 | } 189 | 190 | func (c *Command) isSubCommand(maybeParent *Command) bool { 191 | return len(c.NamePath) == len(maybeParent.NamePath)+1 && strings.HasPrefix(c.FullName, maybeParent.FullName+" ") 192 | } 193 | 194 | func (c *Command) leafName() string { 195 | return strings.Join(strings.Split(c.FullName, " ")[c.depth():], "") 196 | } 197 | 198 | func (c *Command) fileName() string { 199 | if c.depth() <= 0 { 200 | return "" 201 | } 202 | return strings.Split(c.FullName, " ")[1] 203 | } 204 | 205 | func (c *Command) depth() int { 206 | return len(strings.Split(c.FullName, " ")) - 1 207 | } 208 | 209 | func (o *Option) processSection() error { 210 | if o.Name == "" { 211 | return fmt.Errorf("missing option name") 212 | } 213 | 214 | if o.Type == "" { 215 | return fmt.Errorf("missing option type") 216 | } 217 | 218 | if o.Description == "" { 219 | return fmt.Errorf("missing description for option: %s", o.Name) 220 | } 221 | // Strip all newline for description and trailing whitespace 222 | o.Description = strings.ReplaceAll(o.Description, "\n", " ") 223 | o.Description = strings.TrimRight(o.Description, " ") 224 | 225 | // Check that description ends in a "." 226 | if o.Description[len(o.Description)-1] != '.' { 227 | return fmt.Errorf("description should end in a '.'") 228 | } 229 | 230 | if o.Env != strings.ToUpper(o.Env) { 231 | return fmt.Errorf("env variables must be in all caps") 232 | } 233 | 234 | if len(o.EnumValues) != 0 { 235 | if o.Type != "string-enum" && o.Type != "string-enum[]" { 236 | return fmt.Errorf("enum-values can only specified for string-enum and string-enum[] types") 237 | } 238 | // Check default enum values 239 | if o.Default != "" && !slices.Contains(o.EnumValues, o.Default) { 240 | return fmt.Errorf("default value '%s' must be one of the enum-values options %s", o.Default, o.EnumValues) 241 | } 242 | } 243 | return nil 244 | } 245 | -------------------------------------------------------------------------------- /temporalcli/devserver/freeport.go: -------------------------------------------------------------------------------- 1 | package devserver 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "runtime" 7 | ) 8 | 9 | // Returns a TCP port that is available to listen on, for the given (local) host. 10 | // 11 | // This works by binding a new TCP socket on port 0, which requests the OS to 12 | // allocate a free port. There is no strict guarantee that the port will remain 13 | // available after this function returns, but it should be safe to assume that 14 | // a given port will not be allocated again to any process on this machine 15 | // within a few seconds. 16 | // 17 | // On Unix-based systems, binding to the port returned by this function requires 18 | // setting the `SO_REUSEADDR` socket option (Go already does that by default, 19 | // but other languages may not); otherwise, the OS may fail with a message such 20 | // as "address already in use". Windows default behavior is already appropriate 21 | // in this regard; on that platform, `SO_REUSEADDR` has a different meaning and 22 | // should not be set (setting it may have unpredictable consequences). 23 | func GetFreePort(host string) (int, error) { 24 | host = MaybeEscapeIPv6(host) 25 | l, err := net.Listen("tcp", host+":0") 26 | if err != nil { 27 | return 0, fmt.Errorf("failed to assign a free port: %v", err) 28 | } 29 | defer l.Close() 30 | port := l.Addr().(*net.TCPAddr).Port 31 | 32 | // On Linux and some BSD variants, ephemeral ports are randomized, and may 33 | // consequently repeat within a short time frame after the listenning end 34 | // has been closed. To avoid this, we make a connection to the port, then 35 | // close that connection from the server's side (this is very important), 36 | // which puts the connection in TIME_WAIT state for some time (by default, 37 | // 60s on Linux). While it remains in that state, the OS will not reallocate 38 | // that port number for bind(:0) syscalls, yet we are not prevented from 39 | // explicitly binding to it (thanks to SO_REUSEADDR). 40 | // 41 | // On macOS and Windows, the above technique is not necessary, as the OS 42 | // allocates ephemeral ports sequentially, meaning a port number will only 43 | // be reused after the entire range has been exhausted. Quite the opposite, 44 | // given that these OSes use a significantly smaller range for ephemeral 45 | // ports, making an extra connection just to reserve a port might actually 46 | // be harmful (by hastening ephemeral port exhaustion). 47 | if runtime.GOOS != "darwin" && runtime.GOOS != "windows" { 48 | // DialTCP(..., l.Addr()) might fail if machine has IPv6 support, but 49 | // isn't fully configured (e.g. doesn't have a loopback interface bound 50 | // to ::1). For safety, rebuild address form the original host instead. 51 | tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port)) 52 | if err != nil { 53 | return 0, fmt.Errorf("error resolving address: %v", err) 54 | } 55 | r, err := net.DialTCP("tcp", nil, tcpAddr) 56 | if err != nil { 57 | return 0, fmt.Errorf("failed to assign a free port: %v", err) 58 | } 59 | c, err := l.Accept() 60 | if err != nil { 61 | return 0, fmt.Errorf("failed to assign a free port: %v", err) 62 | } 63 | // Closing the socket from the server side 64 | c.Close() 65 | defer r.Close() 66 | } 67 | 68 | return port, nil 69 | } 70 | 71 | // Returns a TCP port that is available to listen on, for the given (local) 72 | // host; panics if no port is available. 73 | // 74 | // This works by binding a new TCP socket on port 0, which requests the OS to 75 | // allocate a free port. There is no strict guarantee that the port will remain 76 | // available after this function returns, but it should be safe to assume that 77 | // a given port will not be allocated again to any process on this machine 78 | // within a few seconds. 79 | // 80 | // On Unix-based systems, binding to the port returned by this function requires 81 | // setting the `SO_REUSEADDR` socket option (Go already does that by default, 82 | // but other languages may not); otherwise, the OS may fail with a message such 83 | // as "address already in use". Windows default behavior is already appropriate 84 | // in this regard; on that platform, `SO_REUSEADDR` has a different meaning and 85 | // should not be set (setting it may have unpredictable consequences). 86 | func MustGetFreePort(host string) int { 87 | port, err := GetFreePort(host) 88 | if err != nil { 89 | panic(fmt.Errorf("failed assigning ephemeral port: %w", err)) 90 | } 91 | return port 92 | } 93 | 94 | // Asserts that the given TCP port is available to listen on, for the given 95 | // (local) host; return an error if it is not. 96 | func CheckPortFree(host string, port int) error { 97 | l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", MaybeEscapeIPv6(host), port)) 98 | if err != nil { 99 | return err 100 | } 101 | l.Close() 102 | return nil 103 | } 104 | 105 | // Escapes an IPv6 address with square brackets, if it is an IPv6 address. 106 | func MaybeEscapeIPv6(host string) string { 107 | if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { 108 | return "[" + host + "]" 109 | } 110 | return host 111 | } 112 | -------------------------------------------------------------------------------- /temporalcli/devserver/freeport_test.go: -------------------------------------------------------------------------------- 1 | package devserver_test 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "testing" 7 | 8 | "github.com/temporalio/cli/temporalcli/devserver" 9 | ) 10 | 11 | func TestFreePort_NoDouble(t *testing.T) { 12 | host := "127.0.0.1" 13 | portSet := make(map[int]bool) 14 | for i := 0; i < 2000; i++ { 15 | p, err := devserver.GetFreePort(host) 16 | if err != nil { 17 | t.Fatalf("Error: %s", err) 18 | break 19 | } 20 | 21 | if _, exists := portSet[p]; exists { 22 | t.Fatalf("Port %d has been assigned more than once", p) 23 | } 24 | 25 | // Add port to the set 26 | portSet[p] = true 27 | } 28 | } 29 | 30 | func TestFreePort_CanBindImmediatelySameProcess(t *testing.T) { 31 | host := "127.0.0.1" 32 | for i := 0; i < 500; i++ { 33 | p, err := devserver.GetFreePort(host) 34 | if err != nil { 35 | t.Fatalf("Error: %s", err) 36 | break 37 | } 38 | err = tryListenAndDialOn(host, p) 39 | if err != nil { 40 | t.Fatalf("Error: %s", err) 41 | break 42 | } 43 | } 44 | } 45 | 46 | func TestFreePort_IPv4Unspecified(t *testing.T) { 47 | host := "0.0.0.0" 48 | p, err := devserver.GetFreePort(host) 49 | if err != nil { 50 | t.Fatalf("Error: %s", err) 51 | return 52 | } 53 | err = tryListenAndDialOn(host, p) 54 | if err != nil { 55 | t.Fatalf("Error: %s", err) 56 | return 57 | } 58 | } 59 | 60 | func TestFreePort_IPv6Unspecified(t *testing.T) { 61 | host := "::" 62 | p, err := devserver.GetFreePort(host) 63 | if err != nil { 64 | t.Fatalf("Error: %s", err) 65 | return 66 | } 67 | err = tryListenAndDialOn(host, p) 68 | if err != nil { 69 | t.Fatalf("Error: %s", err) 70 | return 71 | } 72 | } 73 | 74 | // This function is used as part of unit tests, to ensure that the port 75 | func tryListenAndDialOn(host string, port int) error { 76 | host = devserver.MaybeEscapeIPv6(host) 77 | l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port)) 78 | if err != nil { 79 | return err 80 | } 81 | defer l.Close() 82 | 83 | tcpAddr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port)) 84 | if err != nil { 85 | panic(err) 86 | } 87 | r, err := net.DialTCP("tcp", nil, tcpAddr) 88 | if err != nil { 89 | panic(err) 90 | } 91 | defer r.Close() 92 | 93 | c, err := l.Accept() 94 | if err != nil { 95 | panic(err) 96 | } 97 | defer c.Close() 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /temporalcli/devserver/log.go: -------------------------------------------------------------------------------- 1 | package devserver 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "go.temporal.io/server/common/log" 8 | "go.temporal.io/server/common/log/tag" 9 | ) 10 | 11 | type slogLogger struct { 12 | log *slog.Logger 13 | level slog.Level 14 | } 15 | 16 | var _ log.Logger = slogLogger{} 17 | var _ log.SLogWrapper = slogLogger{} 18 | 19 | func (s slogLogger) Debug(msg string, tags ...tag.Tag) { s.Log(slog.LevelDebug, msg, tags) } 20 | func (s slogLogger) Info(msg string, tags ...tag.Tag) { s.Log(slog.LevelInfo, msg, tags) } 21 | func (s slogLogger) Warn(msg string, tags ...tag.Tag) { s.Log(slog.LevelWarn, msg, tags) } 22 | func (s slogLogger) Error(msg string, tags ...tag.Tag) { s.Log(slog.LevelError, msg, tags) } 23 | 24 | // Panics and fatals are just errors 25 | func (s slogLogger) DPanic(msg string, tags ...tag.Tag) { s.Log(slog.LevelError, msg, tags) } 26 | func (s slogLogger) Panic(msg string, tags ...tag.Tag) { s.Log(slog.LevelError, msg, tags) } 27 | func (s slogLogger) Fatal(msg string, tags ...tag.Tag) { s.Log(slog.LevelError, msg, tags) } 28 | 29 | func (s slogLogger) SLog() *slog.Logger { 30 | return s.log 31 | } 32 | 33 | func (s slogLogger) Log(level slog.Level, msg string, tags []tag.Tag) { 34 | if level >= s.level && s.log.Enabled(context.Background(), level) { 35 | s.log.LogAttrs(context.Background(), level, msg, logTagsToAttrs(tags)...) 36 | } 37 | } 38 | 39 | func logTagsToAttrs(tags []tag.Tag) []slog.Attr { 40 | attrs := make([]slog.Attr, len(tags)) 41 | for i, tag := range tags { 42 | attrs[i] = slog.Any(tag.Key(), tag.Value()) 43 | } 44 | return attrs 45 | } 46 | -------------------------------------------------------------------------------- /temporalcli/duration.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "time" 5 | 6 | "go.temporal.io/server/common/primitives/timestamp" 7 | ) 8 | 9 | type Duration time.Duration 10 | 11 | func (d Duration) Duration() time.Duration { 12 | return time.Duration(d) 13 | } 14 | 15 | func (d *Duration) String() string { 16 | return d.Duration().String() 17 | } 18 | 19 | func (d *Duration) Set(s string) error { 20 | p, err := timestamp.ParseDuration(s) 21 | if err != nil { 22 | return err 23 | } 24 | *d = Duration(p) 25 | return nil 26 | } 27 | 28 | func (d *Duration) Type() string { 29 | return "duration" 30 | } 31 | -------------------------------------------------------------------------------- /temporalcli/internal/cmd/gen-commands/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/temporalio/cli/temporalcli/commandsgen" 11 | ) 12 | 13 | func main() { 14 | if err := run(); err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | func run() error { 20 | // Get commands dir 21 | _, file, _, _ := runtime.Caller(0) 22 | commandsDir := filepath.Join(file, "../../../../") 23 | 24 | // Parse YAML 25 | cmds, err := commandsgen.ParseCommands() 26 | if err != nil { 27 | return fmt.Errorf("failed parsing markdown: %w", err) 28 | } 29 | 30 | // Generate code 31 | b, err := commandsgen.GenerateCommandsCode("temporalcli", cmds) 32 | if err != nil { 33 | return fmt.Errorf("failed generating code: %w", err) 34 | } 35 | 36 | // Write 37 | if err := os.WriteFile(filepath.Join(commandsDir, "commands.gen.go"), b, 0644); err != nil { 38 | return fmt.Errorf("failed writing file: %w", err) 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /temporalcli/internal/cmd/gen-docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/temporalio/cli/temporalcli/commandsgen" 11 | ) 12 | 13 | func main() { 14 | if err := run(); err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | func run() error { 20 | // Get commands dir 21 | _, file, _, _ := runtime.Caller(0) 22 | docsDir := filepath.Join(file, "../../../../docs/") 23 | 24 | err := os.MkdirAll(docsDir, os.ModePerm) 25 | if err != nil { 26 | log.Fatalf("Error creating directory: %v", err) 27 | } 28 | 29 | // Parse markdown 30 | cmds, err := commandsgen.ParseCommands() 31 | if err != nil { 32 | return fmt.Errorf("failed parsing markdown: %w", err) 33 | } 34 | 35 | // Generate docs 36 | b, err := commandsgen.GenerateDocsFiles(cmds) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | // Write 42 | for filename, content := range b { 43 | filePath := filepath.Join(docsDir, filename+".mdx") 44 | if err := os.WriteFile(filePath, content, 0644); err != nil { 45 | return fmt.Errorf("failed writing file: %w", err) 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /temporalcli/internal/printer/printer_test.go: -------------------------------------------------------------------------------- 1 | package printer_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | "unicode" 11 | 12 | "github.com/stretchr/testify/require" 13 | "github.com/temporalio/cli/temporalcli/internal/printer" 14 | ) 15 | 16 | // TODO(cretz): Test: 17 | // * Text printer specific fields 18 | // * Text printer specific and non-specific fields and all sorts of table options 19 | // * JSON printer 20 | 21 | func TestPrinter_Text(t *testing.T) { 22 | type MyStruct struct { 23 | Foo string 24 | Bar bool 25 | unexportedBaz string 26 | ReallyLongField any 27 | Omitted string `cli:",omit"` 28 | OmittedCardEmpty string `cli:",cardOmitEmpty"` 29 | } 30 | var buf bytes.Buffer 31 | p := printer.Printer{Output: &buf} 32 | // Simple struct non-table no fields set 33 | require.NoError(t, p.PrintStructured([]*MyStruct{ 34 | { 35 | Foo: "1", 36 | unexportedBaz: "2", 37 | ReallyLongField: struct { 38 | Key any `json:"key"` 39 | }{Key: 123}, 40 | Omitted: "value", 41 | OmittedCardEmpty: "value", 42 | }, 43 | { 44 | Foo: "not-a-number", 45 | Bar: true, 46 | ReallyLongField: map[string]int{"": 0}, 47 | }, 48 | }, printer.StructuredOptions{})) 49 | // Check 50 | require.Equal(t, normalizeMultiline(` 51 | Foo 1 52 | Bar false 53 | ReallyLongField {"key":123} 54 | OmittedCardEmpty value 55 | 56 | Foo not-a-number 57 | Bar true 58 | ReallyLongField map[:0]`), normalizeMultiline(buf.String())) 59 | 60 | // TODO(cretz): Tables and more options 61 | } 62 | 63 | func normalizeMultiline(s string) string { 64 | // Split lines, trim trailing space on each (also removes \r), remove empty 65 | // lines, re-join 66 | var ret string 67 | for _, line := range strings.Split(s, "\n") { 68 | line = strings.TrimRightFunc(line, unicode.IsSpace) 69 | // Only non-empty lines 70 | if line != "" { 71 | if ret != "" { 72 | ret += "\n" 73 | } 74 | ret += line 75 | } 76 | } 77 | return ret 78 | } 79 | 80 | func TestPrinter_JSON(t *testing.T) { 81 | var buf bytes.Buffer 82 | 83 | // With indentation 84 | p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} 85 | p.Println("should not print") 86 | require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) 87 | require.Equal(t, `{ 88 | "foo": "bar" 89 | } 90 | `, buf.String()) 91 | 92 | // Without indentation 93 | buf.Reset() 94 | p = printer.Printer{Output: &buf, JSON: true} 95 | p.Println("should not print") 96 | require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) 97 | require.Equal(t, "{\"foo\":\"bar\"}\n", buf.String()) 98 | } 99 | 100 | func TestPrinter_JSONList(t *testing.T) { 101 | var buf bytes.Buffer 102 | 103 | // With indentation 104 | p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} 105 | p.StartList() 106 | p.Println("should not print") 107 | require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) 108 | require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) 109 | p.EndList() 110 | require.Equal(t, `[ 111 | { 112 | "foo": "bar" 113 | }, 114 | { 115 | "baz": "qux" 116 | } 117 | ] 118 | `, buf.String()) 119 | 120 | // Without indentation 121 | buf.Reset() 122 | p = printer.Printer{Output: &buf, JSON: true} 123 | p.StartList() 124 | p.Println("should not print") 125 | require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) 126 | require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) 127 | p.EndList() 128 | require.Equal(t, "{\"foo\":\"bar\"}\n{\"baz\":\"qux\"}\n", buf.String()) 129 | 130 | // Empty with indentation 131 | buf.Reset() 132 | p = printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} 133 | p.StartList() 134 | p.Println("should not print") 135 | p.EndList() 136 | require.Equal(t, "[\n]\n", buf.String()) 137 | 138 | // Empty without indentation 139 | buf.Reset() 140 | p = printer.Printer{Output: &buf, JSON: true} 141 | p.StartList() 142 | p.Println("should not print") 143 | p.EndList() 144 | require.Equal(t, "", buf.String()) 145 | } 146 | 147 | // Asserts the printer package don't panic if the CLI is run without a STDOUT. 148 | // This is a tricky thing to validate, as it must be done in a subprocess and as 149 | // `go test` has its own internal fix for improper STDOUT. This was fixed in 150 | // Go 1.22, but keeping this here as a regression test. 151 | // See https://github.com/temporalio/cli/issues/544. 152 | func TestPrinter_NoPanicIfNoStdout(t *testing.T) { 153 | if runtime.GOOS == "windows" { 154 | t.Skip("Skipped on Windows") 155 | return 156 | } 157 | 158 | goPath, err := exec.LookPath("go") 159 | if err != nil { 160 | t.Fatalf("Error finding go executable: %v", err) 161 | } 162 | // Don't use exec.Command here, as it silently replaces nil file descriptors 163 | // with /dev/null on the parent side. We specifically want to test what 164 | // happens when stdout is nil. 165 | p, err := os.StartProcess( 166 | goPath, 167 | []string{"go", "run", "./test/main.go"}, 168 | &os.ProcAttr{ 169 | Files: []*os.File{os.Stdin, nil, os.Stderr}, 170 | }, 171 | ) 172 | if err != nil { 173 | t.Fatalf("Error running command: %v", err) 174 | } 175 | state, err := p.Wait() 176 | if err != nil { 177 | t.Fatalf("Error running command: %v", err) 178 | } 179 | if state.ExitCode() != 0 { 180 | t.Fatalf("Error running command; exit code = %d", state.ExitCode()) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /temporalcli/internal/printer/test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/temporalio/cli/temporalcli/internal/printer" 7 | ) 8 | 9 | // This main function is used to test that the printer package don't panic if 10 | // the CLI is run without a STDOUT. This is a tricky thing to validate, as it 11 | // must be done in a subprocess and as `go test` has its own internal fix for 12 | // improper STDOUT. This was fixed in Go 1.22, but keeping this here as a 13 | // regression test. See https://github.com/temporalio/cli/issues/544. 14 | func main() { 15 | p := &printer.Printer{ 16 | Output: os.Stdout, 17 | JSON: false, 18 | } 19 | p.Println("Test writing to stdout using Printer") 20 | os.Exit(0) 21 | } 22 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/execution_icons.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | "go.temporal.io/api/enums/v1" 6 | ) 7 | 8 | var ( 9 | StatusRunning string = color.BlueString("▷") 10 | StatusCompleted string = color.GreenString("✓") 11 | StatusTerminated string = color.RedString("x") 12 | StatusCanceled string = color.YellowString("x") 13 | StatusFailed string = color.RedString("!") 14 | StatusContinueAsNew string = color.GreenString("»") 15 | StatusTimedOut string = color.RedString("⏱") 16 | StatusUnspecifiedScheduled string = "•" 17 | StatusCancelRequested string = color.YellowString("▷") 18 | StatusTimerWaiting string = color.BlueString("⧖") 19 | StatusTimerFired string = color.GreenString("⧖") 20 | StatusTimerCanceled string = color.YellowString("⧖") 21 | ) 22 | 23 | var workflowIcons = map[enums.WorkflowExecutionStatus]string{ 24 | enums.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED: StatusUnspecifiedScheduled, 25 | enums.WORKFLOW_EXECUTION_STATUS_RUNNING: StatusRunning, 26 | enums.WORKFLOW_EXECUTION_STATUS_COMPLETED: StatusCompleted, 27 | enums.WORKFLOW_EXECUTION_STATUS_TERMINATED: StatusTerminated, 28 | enums.WORKFLOW_EXECUTION_STATUS_CANCELED: StatusCanceled, 29 | enums.WORKFLOW_EXECUTION_STATUS_FAILED: StatusFailed, 30 | enums.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: StatusContinueAsNew, 31 | enums.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: StatusTimedOut, 32 | } 33 | var activityIcons = map[ActivityExecutionStatus]string{ 34 | ACTIVITY_EXECUTION_STATUS_UNSPECIFIED: StatusUnspecifiedScheduled, 35 | ACTIVITY_EXECUTION_STATUS_SCHEDULED: StatusUnspecifiedScheduled, 36 | ACTIVITY_EXECUTION_STATUS_RUNNING: StatusRunning, 37 | ACTIVITY_EXECUTION_STATUS_COMPLETED: StatusCompleted, 38 | ACTIVITY_EXECUTION_STATUS_CANCEL_REQUESTED: StatusCancelRequested, 39 | ACTIVITY_EXECUTION_STATUS_CANCELED: StatusCanceled, 40 | ACTIVITY_EXECUTION_STATUS_FAILED: StatusFailed, 41 | ACTIVITY_EXECUTION_STATUS_TIMED_OUT: StatusTimedOut, 42 | } 43 | var timerIcons = map[TimerExecutionStatus]string{ 44 | TIMER_STATUS_WAITING: StatusTimerWaiting, 45 | TIMER_STATUS_FIRED: StatusTimerFired, 46 | TIMER_STATUS_CANCELED: StatusTimerCanceled, 47 | } 48 | 49 | // ExecutionStatus returns the icon (with color) for a given ExecutionState's status. 50 | func ExecutionStatus(exec ExecutionState) string { 51 | switch e := exec.(type) { 52 | case *WorkflowExecutionState: 53 | if icon, ok := workflowIcons[e.Status]; ok { 54 | return icon 55 | } 56 | case *ActivityExecutionState: 57 | if icon, ok := activityIcons[e.Status]; ok { 58 | return icon 59 | } 60 | case *TimerExecutionState: 61 | if icon, ok := timerIcons[e.Status]; ok { 62 | return icon 63 | } 64 | } 65 | return "?" 66 | } 67 | 68 | // StatusIcon has names for each status (useful for help messages). 69 | type StatusIcon struct { 70 | Name string 71 | Icon string 72 | } 73 | 74 | var StatusIconsLegend = []StatusIcon{ 75 | { 76 | Name: "Unspecified or Scheduled", Icon: StatusUnspecifiedScheduled, 77 | }, 78 | { 79 | Name: "Running", Icon: StatusRunning, 80 | }, 81 | { 82 | Name: "Completed", Icon: StatusCompleted, 83 | }, 84 | { 85 | Name: "Continue As New", Icon: StatusContinueAsNew, 86 | }, 87 | { 88 | Name: "Failed", Icon: StatusFailed, 89 | }, 90 | { 91 | Name: "Timed Out", Icon: StatusTimedOut, 92 | }, 93 | { 94 | Name: "Cancel Requested", Icon: StatusCancelRequested, 95 | }, 96 | { 97 | Name: "Canceled", Icon: StatusCanceled, 98 | }, 99 | { 100 | Name: "Terminated", Icon: StatusTerminated, 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/execution_test.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "bytes" 5 | "runtime" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "google.golang.org/protobuf/types/known/durationpb" 11 | "google.golang.org/protobuf/types/known/timestamppb" 12 | 13 | "github.com/stretchr/testify/require" 14 | "go.temporal.io/api/common/v1" 15 | 16 | "github.com/stretchr/testify/assert" 17 | "go.temporal.io/api/enums/v1" 18 | ) 19 | 20 | var foldStatus = []enums.WorkflowExecutionStatus{ 21 | enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 22 | enums.WORKFLOW_EXECUTION_STATUS_CANCELED, 23 | enums.WORKFLOW_EXECUTION_STATUS_TERMINATED, 24 | enums.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW, 25 | } 26 | 27 | func GetRelativeTime(d time.Duration) *timestamppb.Timestamp { 28 | startTime := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) 29 | if d > 0 { 30 | startTime = startTime.Add(d) 31 | } 32 | return timestamppb.New(startTime) 33 | } 34 | 35 | func GetDuration(d time.Duration) *durationpb.Duration { 36 | return durationpb.New(d) 37 | } 38 | 39 | func TestExecuteWorkflowTemplate(t *testing.T) { 40 | tests := map[string]struct { 41 | state ExecutionState 42 | depth int 43 | want string 44 | }{ 45 | "simple wf": { 46 | state: &WorkflowExecutionState{ 47 | Execution: &common.WorkflowExecution{WorkflowId: "foo", RunId: "bar"}, 48 | Status: enums.WORKFLOW_EXECUTION_STATUS_RUNNING, 49 | Type: &common.WorkflowType{Name: "foo"}, 50 | Attempt: 1, 51 | LastEventId: 52, 52 | HistoryLength: 52, 53 | ChildStates: []ExecutionState{}, 54 | }, 55 | depth: 0, 56 | want: " ╪ ▷ foo \n" + 57 | " │ wfId: foo, runId: bar\n", 58 | }, 59 | "extras": { 60 | state: &WorkflowExecutionState{ 61 | Execution: &common.WorkflowExecution{WorkflowId: "foo", RunId: "bar"}, 62 | Status: enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 63 | Type: &common.WorkflowType{Name: "foo"}, 64 | Attempt: 10, 65 | LastEventId: 52, 66 | HistoryLength: 52, 67 | ChildStates: []ExecutionState{}, 68 | StartTime: GetRelativeTime(0), 69 | CloseTime: GetRelativeTime(time.Hour), 70 | }, 71 | depth: 0, 72 | want: " ╪ ✓ foo (1h0m0s, 10 attempts)\n" + 73 | " │ wfId: foo, runId: bar\n", 74 | }, 75 | "child wf": { 76 | state: &WorkflowExecutionState{ 77 | Execution: &common.WorkflowExecution{WorkflowId: "foo", RunId: "bar"}, 78 | Status: enums.WORKFLOW_EXECUTION_STATUS_TERMINATED, 79 | Type: &common.WorkflowType{Name: "foo"}, 80 | Attempt: 1, 81 | LastEventId: 52, 82 | HistoryLength: 52, 83 | ChildStates: []ExecutionState{ 84 | &WorkflowExecutionState{ 85 | LastEventId: 1, // Child workflow has its own events 86 | HistoryLength: 1, 87 | Status: enums.WORKFLOW_EXECUTION_STATUS_RUNNING, 88 | Type: &common.WorkflowType{ 89 | Name: "baz", 90 | }, 91 | Execution: &common.WorkflowExecution{ 92 | WorkflowId: "childWfId", 93 | RunId: "childRunId", 94 | }, 95 | Attempt: 1, 96 | }, 97 | }, 98 | }, 99 | depth: 0, 100 | want: " ╪ x foo \n" + 101 | " │ wfId: foo, runId: bar\n" + 102 | " │ ╪ ▷ baz \n" + 103 | " │ │ wfId: childWfId, runId: childRunId\n", 104 | }, 105 | "activity": { 106 | state: &ActivityExecutionState{ 107 | ActivityId: "1", 108 | Status: ACTIVITY_EXECUTION_STATUS_COMPLETED, 109 | Type: &common.ActivityType{Name: "foobar"}, 110 | Attempt: 0, 111 | RetryState: 0, 112 | StartTime: GetRelativeTime(0), 113 | CloseTime: GetRelativeTime(10 * time.Second), 114 | }, 115 | want: " ┼ ✓ foobar (10s)\n", 116 | }, 117 | "timer": { 118 | state: &TimerExecutionState{ 119 | StartToFireTimeout: GetDuration(time.Hour), 120 | Status: TIMER_STATUS_CANCELED, 121 | Name: "Timer (1h0m0s)", 122 | StartTime: GetRelativeTime(0), 123 | CloseTime: GetRelativeTime(10 * time.Minute), 124 | }, 125 | want: " ┼ ⧖ Timer (1h0m0s) (10m0s)\n", 126 | }, 127 | } 128 | for name, tt := range tests { 129 | t.Run(name, func(t *testing.T) { 130 | b := new(bytes.Buffer) 131 | tmpl, err := NewExecutionTemplate(foldStatus, false) 132 | require.NoError(t, err) 133 | 134 | require.NoError(t, tmpl.Execute(b, tt.state, tt.depth)) 135 | 136 | if runtime.GOOS == "windows" { 137 | tt.want = strings.ReplaceAll(tt.want, "\n", "\r\n") 138 | } 139 | 140 | assert.Equal(t, tt.want, b.String()) 141 | }) 142 | } 143 | } 144 | 145 | func TestShouldFold(t *testing.T) { 146 | tests := map[string]struct { 147 | displayAll bool 148 | currentDepth int 149 | state *WorkflowExecutionState 150 | want bool 151 | }{ 152 | "running": { 153 | state: &WorkflowExecutionState{ 154 | Status: enums.WORKFLOW_EXECUTION_STATUS_RUNNING, 155 | }, 156 | displayAll: false, 157 | currentDepth: 1, 158 | want: false, 159 | }, 160 | "completed": { 161 | state: &WorkflowExecutionState{ 162 | Status: enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 163 | }, 164 | displayAll: false, 165 | currentDepth: 1, 166 | want: true, 167 | }, 168 | "canceled": { 169 | state: &WorkflowExecutionState{ 170 | Status: enums.WORKFLOW_EXECUTION_STATUS_CANCELED, 171 | }, 172 | displayAll: false, 173 | currentDepth: 1, 174 | want: true, 175 | }, 176 | "terminate": { 177 | state: &WorkflowExecutionState{ 178 | Status: enums.WORKFLOW_EXECUTION_STATUS_TERMINATED, 179 | }, 180 | displayAll: false, 181 | currentDepth: 1, 182 | want: true, 183 | }, 184 | "not deep enough": { 185 | state: &WorkflowExecutionState{ 186 | Status: enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 187 | }, 188 | displayAll: false, 189 | currentDepth: 0, 190 | want: false, 191 | }, 192 | "infinite depth": { 193 | state: &WorkflowExecutionState{ 194 | Status: enums.WORKFLOW_EXECUTION_STATUS_COMPLETED, 195 | }, 196 | displayAll: true, 197 | currentDepth: 10, 198 | want: false, 199 | }, 200 | } 201 | 202 | for name, tt := range tests { 203 | t.Run(name, func(t *testing.T) { 204 | res := ShouldFoldStatus(foldStatus, tt.displayAll)(tt.state, tt.currentDepth) 205 | 206 | assert.Equal(t, tt.want, res) 207 | }) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/execution_tmpls.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "text/template" 9 | "time" 10 | 11 | "github.com/fatih/color" 12 | "go.temporal.io/api/enums/v1" 13 | ) 14 | 15 | const ( 16 | MinFoldingDepth = 1 17 | ) 18 | 19 | //go:embed templates/*.tmpl 20 | var templatesFS embed.FS 21 | 22 | var ( 23 | faint = color.New(color.Faint).SprintFunc() 24 | ) 25 | 26 | // ExecutionTemplate contains the necessary templates and utilities to render WorkflowExecutionState and its child states. 27 | type ExecutionTemplate struct { 28 | tmpl *template.Template 29 | shouldFold func(*WorkflowExecutionState, int) bool 30 | } 31 | 32 | // NewExecutionTemplate initializes the templates with the necessary functions. 33 | func NewExecutionTemplate(foldStatus []enums.WorkflowExecutionStatus, noFold bool) (*ExecutionTemplate, error) { 34 | shouldFold := ShouldFoldStatus(foldStatus, noFold) 35 | templateFunctions := template.FuncMap{ 36 | "statusIcon": ExecutionStatus, 37 | "blue": color.BlueString, 38 | "yellow": color.YellowString, 39 | "red": color.RedString, 40 | "faint": faint, 41 | "shouldFold": shouldFold, 42 | "indent": func(depths ...int) string { 43 | var sum int 44 | for _, d := range depths { 45 | sum += d 46 | } 47 | return faint(strings.Repeat(" │ ", sum)) 48 | }, 49 | "splitLines": func(str string) []string { 50 | return strings.Split(str, "\n") 51 | }, 52 | "timeSince": FmtTimeSince, 53 | } 54 | 55 | tmpl, err := template.New("output").Funcs(templateFunctions).ParseFS(templatesFS, "templates/*.tmpl") 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return &ExecutionTemplate{ 61 | tmpl: tmpl, 62 | shouldFold: shouldFold, 63 | }, nil 64 | } 65 | 66 | type StateTemplate struct { 67 | State ExecutionState 68 | Depth int 69 | } 70 | 71 | // Execute executes the templates for a given Execution state and writes it into the ExecutionTemplate's writer. 72 | func (t *ExecutionTemplate) Execute(writer io.Writer, state ExecutionState, depth int) error { 73 | if state == nil { 74 | return nil 75 | } 76 | 77 | var templateName string 78 | switch state.(type) { 79 | case *WorkflowExecutionState: 80 | templateName = "workflow.tmpl" 81 | case *ActivityExecutionState: 82 | templateName = "activity.tmpl" 83 | case *TimerExecutionState: 84 | templateName = "timer.tmpl" 85 | default: 86 | return fmt.Errorf("no template available for %s", state) 87 | } 88 | 89 | if err := t.tmpl.ExecuteTemplate(writer, templateName, &StateTemplate{ 90 | State: state, 91 | Depth: depth, 92 | }); err != nil { 93 | return err 94 | } 95 | 96 | if workflow, isWorkflow := state.(*WorkflowExecutionState); isWorkflow && !t.shouldFold(workflow, depth) { 97 | for _, child := range workflow.ChildStates { 98 | if err := t.Execute(writer, child, depth+1); err != nil { 99 | return err 100 | } 101 | 102 | } 103 | } 104 | 105 | return nil 106 | } 107 | 108 | // ShouldFoldStatus returns a predicate that will return true when the workflow status can be folded for a given depth. 109 | // NOTE: Depth starts at 0 (i.e. the root workflow will be at depth 0). 110 | func ShouldFoldStatus(foldStatus []enums.WorkflowExecutionStatus, noFold bool) func(*WorkflowExecutionState, int) bool { 111 | return func(state *WorkflowExecutionState, currentDepth int) bool { 112 | if noFold || currentDepth < MinFoldingDepth { 113 | return false 114 | } 115 | for _, s := range foldStatus { 116 | if s == state.Status { 117 | return true 118 | } 119 | } 120 | return false 121 | } 122 | } 123 | 124 | // FmtTimeSince returns a string representing the difference it time between start and close (or start and now). 125 | func FmtTimeSince(start time.Time, duration time.Duration) string { 126 | if start.IsZero() { 127 | return "" 128 | } 129 | if duration == 0 { 130 | return fmt.Sprintf("%s ago", FmtDuration(time.Since(start))) 131 | } 132 | return FmtDuration(duration) 133 | } 134 | 135 | // FmtDuration produces a string for a given duration, rounding to the most reasonable timeframe. 136 | func FmtDuration(duration time.Duration) string { 137 | if duration < time.Second { 138 | return duration.Round(time.Millisecond).String() 139 | } else if duration < time.Hour { 140 | return duration.Round(time.Second).String() 141 | } else if duration < 24*time.Hour { 142 | return duration.Round(time.Minute).String() 143 | } else if duration < 7*24*time.Hour { 144 | days := int(duration.Hours() / 24) 145 | hours := int(duration.Hours()) - days*24 // % doesn't work for float 146 | return fmt.Sprintf("%dd%dh", days, hours) 147 | } else { 148 | days := int(duration.Hours() / 24) 149 | weeks := int(duration.Hours() / (7 * 24)) 150 | return fmt.Sprintf("%dw%dd", weeks, days) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/tail_buffer.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | ) 7 | 8 | const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 9 | 10 | var re = regexp.MustCompile(ansi) 11 | 12 | // StripBytesAnsi removes all ansi codes from a byte array. 13 | func StripBytesAnsi(b []byte) []byte { 14 | return re.ReplaceAllLiteral(b, []byte{}) 15 | } 16 | 17 | // CountBytesPrintWidth counts the number of printed characters a byte array will take. 18 | func CountBytesPrintWidth(b []byte) int { 19 | return len(bytes.Runes(StripBytesAnsi(b))) 20 | } 21 | 22 | // LineHeight returns the number of lines a string is going to take 23 | func LineHeight(line []byte, maxWidth int) int { 24 | return 1 + (CountBytesPrintWidth(line)-1)/maxWidth 25 | } 26 | 27 | func ReverseLinesBuffer(buf *bytes.Buffer) [][]byte { 28 | lines := bytes.Split(buf.Bytes(), []byte("\n")) 29 | revBuf := make([][]byte, len(lines)) 30 | 31 | j := 0 32 | // TODO: we can probably stop sooner since we're definitely not going to do more than maxLines lines 33 | for i := len(lines) - 1; i >= 0; i-- { 34 | revBuf[j] = lines[i] 35 | j++ 36 | } 37 | return revBuf 38 | } 39 | 40 | // NewTailBoxBoundBuffer returns trims a buffer to fit into the box defined by maxLines and maxWidth and the number of lines printing the buffer will take. 41 | // For no limit on lines, use maxLines = 0. 42 | // NOTE: This is a best guess. It'll take ansi codes into account but some other chars might throw this off. 43 | func NewTailBoxBoundBuffer(buf *bytes.Buffer, maxLines int, maxWidth int) (*bytes.Buffer, int) { 44 | res := make([][]byte, 0) 45 | lines := 0 46 | 47 | for _, line := range ReverseLinesBuffer(buf) { 48 | lineHeight := 1 49 | // If we have a max width, we need to calculate the height of the line 50 | if maxWidth > 0 { 51 | lineHeight = LineHeight(line, maxWidth) 52 | } 53 | if lineHeight+lines > maxLines && maxLines > 0 { 54 | break 55 | } else { 56 | res = append([][]byte{line}, res...) 57 | lines += lineHeight 58 | } 59 | } 60 | 61 | return bytes.NewBuffer(bytes.Join(res, []byte{'\n'})), lines 62 | } 63 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/tail_buffer_test.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/fatih/color" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestStripBytesAnsi(t *testing.T) { 13 | tests := map[string]struct { 14 | b []byte 15 | want []byte 16 | }{ 17 | "no ansi": { 18 | b: []byte("foo"), 19 | want: []byte("foo"), 20 | }, 21 | "moving cursor": { 22 | b: []byte(fmt.Sprintf("%sfoo", MoveCursorUp(2))), 23 | want: []byte("foo"), 24 | }, 25 | "start of line": { 26 | b: []byte(fmt.Sprintf("%sfoo", AnsiMoveCursorStartLine)), 27 | want: []byte("foo"), 28 | }, 29 | } 30 | for name, tt := range tests { 31 | t.Run(name, func(t *testing.T) { 32 | assert.Equalf(t, tt.want, StripBytesAnsi(tt.b), "StripBytesAnsi(%v)", tt.b) 33 | }) 34 | } 35 | } 36 | 37 | func TestGetBufferPrintWidth(t *testing.T) { 38 | tests := map[string]struct { 39 | b []byte 40 | want int 41 | }{ 42 | "sanity": { 43 | b: []byte("test"), 44 | want: 4, 45 | }, 46 | "utf8 char": { 47 | b: []byte("a界c"), 48 | want: 3, 49 | }, 50 | "ansi codes": { 51 | b: []byte(color.RedString("red")), 52 | want: 3, 53 | }, 54 | "actual output": { 55 | b: []byte("\x1b[2m ╪\x1b[0m \x1b[34m▷\x1b[0m \x1b[2mcnab.CNAB_\x1b[0mRunTask \x1b[2m(5m47s ago)\x1b[0m"), 56 | want: 34, 57 | }, 58 | "many pipes": { 59 | b: []byte(" │ │ │ │ │ │ ┼ ✓ cnab.BundleExecutor_PullBundleActionWorker (119ms)"), 60 | want: 79, 61 | }, 62 | } 63 | for name, tt := range tests { 64 | t.Run(name, func(t *testing.T) { 65 | assert.Equalf(t, tt.want, CountBytesPrintWidth(tt.b), "CountBytesPrintWidth(%q)", tt.b) 66 | }) 67 | } 68 | } 69 | 70 | func TestNewTailBoxBoundBuffer(t *testing.T) { 71 | type args struct { 72 | buf *bytes.Buffer 73 | maxLines int 74 | maxWidth int 75 | } 76 | tests := map[string]struct { 77 | args args 78 | wantBuf *bytes.Buffer 79 | wantLines int 80 | }{ 81 | "sanity": { 82 | args: args{ 83 | buf: bytes.NewBufferString("foo\nbar\nbaz"), 84 | maxLines: 100, 85 | maxWidth: 100, 86 | }, 87 | wantBuf: bytes.NewBufferString("foo\nbar\nbaz"), 88 | wantLines: 3, 89 | }, 90 | "zero max lines": { 91 | args: args{ 92 | buf: bytes.NewBufferString("foo\nbar\nbaz"), 93 | maxLines: 0, 94 | maxWidth: 100, 95 | }, 96 | wantBuf: bytes.NewBufferString("foo\nbar\nbaz"), 97 | wantLines: 3, 98 | }, 99 | "too many lines": { 100 | args: args{ 101 | buf: bytes.NewBufferString("foo\nbar\nbaz"), 102 | maxLines: 2, 103 | maxWidth: 100, 104 | }, 105 | wantBuf: bytes.NewBufferString("bar\nbaz"), 106 | wantLines: 2, 107 | }, 108 | "too wide": { 109 | args: args{ 110 | buf: bytes.NewBufferString("foo\nbar\nbaz"), 111 | maxLines: 100, 112 | maxWidth: 2, 113 | }, 114 | wantBuf: bytes.NewBufferString("foo\nbar\nbaz"), 115 | wantLines: 6, 116 | }, 117 | "dont print unfinished lines": { 118 | // Since foo takes two lines and would be cut in half, we won't return it (since it might mangle some ansi codes). 119 | args: args{ 120 | buf: bytes.NewBufferString("foo\nbar\nbaz"), 121 | maxLines: 5, 122 | maxWidth: 2, 123 | }, 124 | wantBuf: bytes.NewBufferString("bar\nbaz"), 125 | wantLines: 4, 126 | }, 127 | "actual output": { 128 | // TODO: consider using snapshots for these tests 129 | args: args{ 130 | // 5 lines shorter than 70, 2 lines longer than 70: 131 | // │ ┼ ✓ cnab.bundleexecutor_experimentsisenabled (9ms) 132 | // │ ┼ ✓ cnab.bundleexecutor_experimentsisenabled (9ms) 133 | // │ ┼ ✓ cnab.bundleexecutor_experimentsisenabled (8ms) 134 | // │ ╪ ▷ cnab.cnabinternal_scheduleworkflowcleanup (15s ago) 135 | // │ │ wfid: becc1b71-1fed-4831-9ca6-05f4b212f602_51, runid: a248024d-1728-47b7-a877-ce6721aca5f7 136 | // │ ╪ ▷ cnab.cnab_checkworkflowauthorization (15s ago) 137 | // │ │ wfid: becc1b71-1fed-4831-9ca6-05f4b212f602_56, runid: 570863ad-4f66-4a03-9756-bb007eb7d3fa 138 | buf: bytes.NewBufferString("\x1b[2m │ ┼\x1b[0m \x1b[32m✓\x1b[0m \x1b[2mcnab.bundleexecutor_\x1b[0mexperimentsisenabled \x1b[2m(9ms)\x1b[0m\n\x1b[2m │ ┼\x1b[0m \x1b[32m✓\x1b[0m \x1b[2mcnab.bundleexecutor_\x1b[0mexperimentsisenabled \x1b[2m(9ms)\x1b[0m\n\x1b[2m │ ┼\x1b[0m \x1b[32m✓\x1b[0m \x1b[2mcnab.bundleexecutor_\x1b[0mexperimentsisenabled \x1b[2m(8ms)\x1b[0m\n\x1b[2m │ ╪\x1b[0m \x1b[34m▷\x1b[0m \x1b[2mcnab.cnabinternal_\x1b[0mscheduleworkflowcleanup \x1b[2m(15s ago)\x1b[0m\n\x1b[2m │ │\x1b[0m \x1b[2mwfid:\x1b[0m \x1b[34mbecc1b71-1fed-4831-9ca6-05f4b212f602_51\x1b[0m\x1b[2m, \x1b[0m\x1b[2mrunid:\x1b[0m \x1b[34ma248024d-1728-47b7-a877-ce6721aca5f7\x1b[0m\n\x1b[2m │ ╪\x1b[0m \x1b[34m▷\x1b[0m \x1b[2mcnab.cnab_\x1b[0mcheckworkflowauthorization \x1b[2m(15s ago)\x1b[0m\n\x1b[2m │ │\x1b[0m \x1b[2mwfid:\x1b[0m \x1b[34mbecc1b71-1fed-4831-9ca6-05f4b212f602_56\x1b[0m\x1b[2m, \x1b[0m\x1b[2mrunid:\x1b[0m \x1b[34m570863ad-4f66-4a03-9756-bb007eb7d3fa\x1b[0m"), 139 | maxLines: 6, 140 | maxWidth: 70, // This should only trigger newlines on the long lines but none of the others 141 | }, 142 | // Expected: 143 | // │ ╪ ▷ cnab.cnabinternal_scheduleworkflowcleanup (15s ago) 144 | // │ │ wfid: becc1b71-1fed-4831-9ca6-05f4b212f602_51, runid: a248024d-1728-47b7-a877-ce6721aca5f7 145 | // │ ╪ ▷ cnab.cnab_checkworkflowauthorization (15s ago) 146 | // │ │ wfid: becc1b71-1fed-4831-9ca6-05f4b212f602_56, runid: 570863ad-4f66-4a03-9756-bb007eb7d3fa 147 | wantBuf: bytes.NewBufferString("\x1b[2m │ ╪\x1b[0m \x1b[34m▷\x1b[0m \x1b[2mcnab.cnabinternal_\x1b[0mscheduleworkflowcleanup \x1b[2m(15s ago)\x1b[0m\n\x1b[2m │ │\x1b[0m \x1b[2mwfid:\x1b[0m \x1b[34mbecc1b71-1fed-4831-9ca6-05f4b212f602_51\x1b[0m\x1b[2m, \x1b[0m\x1b[2mrunid:\x1b[0m \x1b[34ma248024d-1728-47b7-a877-ce6721aca5f7\x1b[0m\n\x1b[2m │ ╪\x1b[0m \x1b[34m▷\x1b[0m \x1b[2mcnab.cnab_\x1b[0mcheckworkflowauthorization \x1b[2m(15s ago)\x1b[0m\n\x1b[2m │ │\x1b[0m \x1b[2mwfid:\x1b[0m \x1b[34mbecc1b71-1fed-4831-9ca6-05f4b212f602_56\x1b[0m\x1b[2m, \x1b[0m\x1b[2mrunid:\x1b[0m \x1b[34m570863ad-4f66-4a03-9756-bb007eb7d3fa\x1b[0m"), 148 | wantLines: 6, 149 | }, 150 | } 151 | for name, tt := range tests { 152 | t.Run(name, func(t *testing.T) { 153 | got, got1 := NewTailBoxBoundBuffer(tt.args.buf, tt.args.maxLines, tt.args.maxWidth) 154 | assert.Equalf(t, tt.wantBuf, got, "NewTailBoxBoundBuffer(%q, %v, %v)", tt.args.buf.String(), tt.args.maxLines, tt.args.maxWidth) 155 | // This one helps identify mismatches 156 | assert.Equalf(t, tt.wantBuf.String(), got.String(), "NewTailBoxBoundBuffer(%q, %v, %v)", tt.args.buf.String(), tt.args.maxLines, tt.args.maxWidth) 157 | assert.Equalf(t, tt.wantLines, got1, "NewTailBoxBoundBuffer(%q, %v, %v)", tt.args.buf.String(), tt.args.maxLines, tt.args.maxWidth) 158 | }) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/templates/activity.tmpl: -------------------------------------------------------------------------------- 1 | {{ indent .Depth }} {{ faint "┼" }} {{ statusIcon .State }} {{ .State.GetName }} {{ template "extras" . }} 2 | {{ template "failure" . -}} 3 | {{ template "retry" . -}} 4 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/templates/common.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "extras" }} 2 | {{- if not .State.GetStartTime.IsZero }}{{ faint "(" }}{{ timeSince .State.GetStartTime .State.GetDuration | faint }} 3 | {{- if gt (.State.GetAttempt) 1 }}{{ printf ", %d attempts" .State.GetAttempt | faint }}{{end}} 4 | {{- faint ")"}}{{ end }} 5 | {{- end }} 6 | 7 | {{ define "failure" }} 8 | {{- with .State.GetFailure }} 9 | {{- $lines := splitLines .Message }} 10 | {{- /* Print first error line with title */}} 11 | {{- indent $.Depth 1 }} {{ red "Failure:" }} {{ index $lines 0 | faint }} 12 | {{- /* Print other error lines, add extra 4 spaces to indent so it looks like it's part of the failure message */}} 13 | {{range $line := slice $lines 1 }} 14 | {{- indent $.Depth 1 }} {{ faint $line }} 15 | {{ end }} 16 | {{- end }} 17 | {{- end }} 18 | 19 | {{ define "retry" }} 20 | {{- with .State.RetryState }} 21 | {{- indent $.Depth 1 }} {{ red "Retry state:" }} {{ faint . }} 22 | {{ end }} 23 | {{- end }} 24 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/templates/timer.tmpl: -------------------------------------------------------------------------------- 1 | {{ indent .Depth }} {{ faint "┼" }} {{ statusIcon .State }} {{ .State.GetName }} {{ template "extras" . }} 2 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/templates/workflow.tmpl: -------------------------------------------------------------------------------- 1 | {{ indent .Depth }} {{ faint "╪" }} {{ statusIcon .State }} {{ .State.GetName }} {{ template "extras" . }} 2 | {{ indent .Depth 1 }} {{ with .State.Execution }}{{ faint "wfId:" }} {{ .WorkflowId | blue }} {{- faint ", runId:" }} {{ .RunId | blue }}{{ end }} 3 | {{ template "failure" . }}{{ template "retry" . }} 4 | 5 | {{- /* Termination Request */}} 6 | {{- with .State.Termination -}} 7 | {{ with .Reason }} 8 | {{- indent $.Depth 1 }} {{ "Termination reason:" | yellow }} {{ . | faint }} 9 | {{ end -}} 10 | {{ with .Identity }} 11 | {{- indent $.Depth 1 }} {{ "Termination request id:" | yellow }} {{ . | faint }} 12 | {{ end -}} 13 | {{ end -}} 14 | 15 | {{- /* Cancel Request */}} 16 | {{- with .State.CancelRequest -}} 17 | {{ with .Cause }} 18 | {{- indent $.Depth 1 }} {{ "Cancel cause:" | yellow }} {{ . | faint }} 19 | {{ end -}} 20 | {{ with .Identity }} 21 | {{- indent $.Depth 1 }} {{ "Cancel request id:" | yellow }} {{ . | faint }} 22 | {{ end -}} 23 | {{ end -}} 24 | 25 | {{- /* Folding */}} 26 | {{- if shouldFold .State .Depth }}{{ indent $.Depth 1 }} {{ faint "↳ execution folded" }} 27 | {{ end -}} 28 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/term_size_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package tracer 4 | 5 | import ( 6 | "syscall" 7 | "unsafe" 8 | ) 9 | 10 | // getTerminalSize returns the current number of columns and rows in the active console window. 11 | // The return value of this function is in the order of cols, rows. 12 | // Copied from https://github.com/nathan-fiscaletti/consolesize-go/blob/master/consolesize_unix.go 13 | func getTerminalSize() (width int, height int) { 14 | var size struct { 15 | rows uint16 16 | cols uint16 17 | xpixels uint16 18 | ypixels uint16 19 | } 20 | _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, 21 | uintptr(syscall.Stdout), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&size))) 22 | 23 | width = int(size.cols) 24 | height = int(size.rows) 25 | 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/term_size_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package tracer 4 | 5 | import ( 6 | "syscall" 7 | "unsafe" 8 | ) 9 | 10 | type ( 11 | SHORT int16 12 | WORD uint16 13 | 14 | COORD struct { 15 | X SHORT 16 | Y SHORT 17 | } 18 | 19 | SMALL_RECT struct { 20 | Left SHORT 21 | Top SHORT 22 | Right SHORT 23 | Bottom SHORT 24 | } 25 | 26 | CONSOLE_SCREEN_BUFFER_INFO struct { 27 | Size COORD 28 | CursorPosition COORD 29 | Attributes WORD 30 | Window SMALL_RECT 31 | MaximumWindowSize COORD 32 | } 33 | ) 34 | 35 | var ( 36 | kernel32 = syscall.NewLazyDLL("kernel32.dll") 37 | procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") 38 | ) 39 | 40 | func getTerminalSize() (width int, height int) { 41 | var csbi CONSOLE_SCREEN_BUFFER_INFO 42 | 43 | _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&csbi))) 44 | if err != syscall.Errno(0) { 45 | return 80, 25 // assume default terminal size 46 | } 47 | 48 | width = int(csbi.Size.X) 49 | height = int(csbi.Size.Y) 50 | 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/term_writer.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "sync" 8 | ) 9 | 10 | const ( 11 | AnsiMoveCursorUp = "\x1b[%dA" 12 | AnsiMoveCursorStartLine = "\x1b[0G" 13 | AnsiEraseToEnd = "\x1b[0J" 14 | ) 15 | 16 | func MoveCursorUp(lines int) string { 17 | return fmt.Sprintf(AnsiMoveCursorUp, lines) 18 | } 19 | 20 | type TermWriter struct { 21 | // out is the writer to write to 22 | out io.Writer 23 | 24 | buf *bytes.Buffer 25 | mtx sync.Mutex 26 | lineCount int 27 | 28 | termWidth int 29 | termHeight int 30 | } 31 | 32 | // NewTermWriter returns a new TermWriter set to output to Stdout. 33 | // TermWriter is a stateful writer designed to print into a terminal window by limiting the number of lines printed what fits and clearing them on new outputs. 34 | func NewTermWriter(out io.Writer) *TermWriter { 35 | tw := &TermWriter{buf: new(bytes.Buffer), out: out} 36 | return tw.WithTerminalSize() 37 | } 38 | 39 | // WithSize sets the size of TermWriter to the desired width and height. 40 | func (w *TermWriter) WithSize(width, height int) *TermWriter { 41 | if width <= 0 { 42 | // width is unknown and we should not limit the output. 43 | width = 0 44 | } 45 | if height <= 0 { 46 | // height is unknown and we should not limit the output. 47 | height = 0 48 | } 49 | w.termHeight = height 50 | w.termWidth = width 51 | return w 52 | } 53 | 54 | func (w *TermWriter) GetSize() (int, int) { 55 | return w.termWidth, w.termHeight 56 | } 57 | 58 | // WithTerminalSize sets the size of TermWriter to that of the terminal. 59 | func (w *TermWriter) WithTerminalSize() *TermWriter { 60 | termWidth, termHeight := getTerminalSize() 61 | return w.WithSize(termWidth, termHeight) 62 | } 63 | 64 | // Write save the contents of buf to the writer b. The only errors returned are ones encountered while writing to the underlying buffer. 65 | // TODO: Consider merging it into Flush since we might always want to write and flush. Alternatively, we can pass the writer to the Sprint functions and write (but we might run into issues if the normal writing is interrupted and the interrupt writing starts). 66 | func (w *TermWriter) Write(buf []byte) (n int, err error) { 67 | w.mtx.Lock() 68 | defer w.mtx.Unlock() 69 | return w.buf.Write(buf) 70 | } 71 | 72 | // WriteLine writes a string into TermWriter. 73 | func (w *TermWriter) WriteLine(s string) (n int, err error) { 74 | b := append([]byte(s), '\n') 75 | return w.Write(b) 76 | } 77 | 78 | // clearLines prints the ansi codes to clear a given number of lines. 79 | func (w *TermWriter) clearLines() error { 80 | if w.lineCount == 0 { 81 | return nil 82 | } 83 | b := bytes.NewBuffer([]byte{}) 84 | if w.lineCount > 1 { 85 | _, _ = b.Write([]byte(MoveCursorUp(w.lineCount - 1))) 86 | } 87 | _, _ = b.Write([]byte(AnsiMoveCursorStartLine)) 88 | _, _ = b.Write([]byte(AnsiEraseToEnd)) 89 | _, err := w.out.Write(b.Bytes()) 90 | return err 91 | } 92 | 93 | // Flush writes to the out and resets the buffer. It should be called after the last call to Write to ensure that any data buffered in the TermWriter is written to output. 94 | // Any incomplete escape sequence at the end is considered complete for formatting purposes. 95 | // An error is returned if the contents of the buffer cannot be written to the underlying output stream. 96 | func (w *TermWriter) Flush(trim bool) error { 97 | if w.termWidth < 0 { 98 | return fmt.Errorf("TermWriter cannot flush without a valid width (current: %d)", w.termWidth) 99 | } 100 | 101 | w.mtx.Lock() 102 | defer w.mtx.Unlock() 103 | 104 | // Do nothing if buffer is empty. 105 | if len(w.buf.Bytes()) == 0 { 106 | return nil 107 | } 108 | 109 | maxLines := 0 110 | if trim { 111 | maxLines = w.termHeight 112 | } 113 | // Tail the buffer (if necessary) and count the number of lines 114 | r, lines := NewTailBoxBoundBuffer(w.buf, maxLines, w.termWidth) 115 | 116 | // Clear the last printed lines and store the new amount of lines that are going to be printed. 117 | if err := w.clearLines(); err != nil { 118 | return err 119 | } 120 | w.lineCount = lines 121 | 122 | _, err := w.out.Write(r.Bytes()) 123 | w.buf.Reset() 124 | return err 125 | } 126 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/term_writer_test.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestTermWriter_WriteLine(t *testing.T) { 12 | tests := map[string]struct { 13 | width int 14 | height int 15 | trim bool 16 | content string 17 | want string 18 | }{ 19 | "sanity": { 20 | width: 10, 21 | height: 10, 22 | trim: true, 23 | content: "foobarbaz", 24 | want: "foobarbaz\n", 25 | }, 26 | "tail trimmed content": { 27 | width: 10, 28 | height: 2, 29 | trim: true, 30 | content: "foo\nbar", 31 | want: "bar\n", 32 | }, 33 | "trim wide content": { 34 | width: 3, 35 | height: 2, 36 | trim: true, 37 | content: "foo\nbarbaz", 38 | want: "", 39 | }, 40 | "trimmed content doesn't cut through a line": { 41 | width: 3, 42 | height: 2, 43 | trim: true, 44 | content: "foobarbaz", 45 | want: "", 46 | }, 47 | "no trimming": { 48 | width: 3, 49 | height: 2, 50 | trim: false, 51 | content: "foobarbaz", 52 | want: "foobarbaz\n", 53 | }, 54 | } 55 | for name, tt := range tests { 56 | t.Run(name, func(t *testing.T) { 57 | b := bytes.NewBufferString("") // Start with an empty string so we can test no content being written 58 | w := NewTermWriter(b).WithSize(tt.width, tt.height) 59 | 60 | _, _ = w.WriteLine(tt.content) 61 | err := w.Flush(tt.trim) 62 | 63 | require.NoError(t, err) 64 | require.Equalf(t, bytes.NewBufferString(tt.want), b, "flushed message doesn't match expected message") 65 | }) 66 | } 67 | } 68 | 69 | func TestTermWriter_MultipleFlushes(t *testing.T) { 70 | tests := map[string]struct { 71 | width int 72 | height int 73 | trim bool 74 | content []string 75 | want string 76 | }{ 77 | "sanity": { 78 | width: 10, 79 | height: 10, 80 | trim: true, 81 | content: []string{"foobarbaz"}, 82 | want: "foobarbaz\n", 83 | }, 84 | "write two single lines": { 85 | width: 10, 86 | height: 10, 87 | trim: true, 88 | content: []string{"foo", "bar"}, 89 | want: fmt.Sprintf("foo\n%s%s%sbar\n", MoveCursorUp(1), AnsiMoveCursorStartLine, AnsiEraseToEnd), 90 | }, 91 | "write two double lines": { 92 | width: 10, 93 | height: 10, 94 | trim: true, 95 | content: []string{"foo\nfoo", "bar\nbar"}, 96 | want: fmt.Sprintf("foo\nfoo\n%s%s%sbar\nbar\n", MoveCursorUp(2), AnsiMoveCursorStartLine, AnsiEraseToEnd), 97 | }, 98 | } 99 | for name, tt := range tests { 100 | t.Run(name, func(t *testing.T) { 101 | b := bytes.NewBufferString("") // Start with an empty string so we can test no content being written 102 | w := NewTermWriter(b).WithSize(tt.width, tt.height) 103 | 104 | for _, s := range tt.content { 105 | _, _ = w.WriteLine(s) 106 | err := w.Flush(tt.trim) 107 | require.NoError(t, err) 108 | } 109 | 110 | require.Equalf(t, bytes.NewBufferString(tt.want), b, "flushed message doesn't match expected message") 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/workflow_execution_update.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/alitto/pond" 8 | "go.temporal.io/api/enums/v1" 9 | sdkclient "go.temporal.io/sdk/client" 10 | ) 11 | 12 | type WorkflowExecutionUpdate struct { 13 | State *WorkflowExecutionState 14 | } 15 | 16 | func (update *WorkflowExecutionUpdate) GetState() *WorkflowExecutionState { 17 | if update == nil { 18 | return nil 19 | } 20 | return update.State 21 | } 22 | 23 | // WorkflowExecutionUpdateIterator is the interface the provides iterative updates, analogous to the HistoryEventIterator interface. 24 | type WorkflowExecutionUpdateIterator interface { 25 | HasNext() bool 26 | Next() (*WorkflowExecutionUpdate, error) 27 | } 28 | 29 | // WorkflowExecutionUpdateIteratorImpl implements the iterator interface. Keeps information about the last processed update and 30 | // receives new updates through the updateChan channel. 31 | type WorkflowExecutionUpdateIteratorImpl struct { 32 | updated bool 33 | nextUpdate *WorkflowExecutionUpdate 34 | state *WorkflowExecutionState 35 | err error 36 | updateChan <-chan struct{} 37 | doneChan <-chan struct{} 38 | errorChan <-chan error 39 | } 40 | 41 | // GetWorkflowExecutionUpdates gets workflow execution updates for a particular workflow 42 | // - workflow ID of the workflow 43 | // - runID can be default (empty string) 44 | // - depth of child workflows to request updates for (-1 for unlimited depth) 45 | // - concurrency of requests (non-zero positive integer) 46 | // Returns iterator (see client.GetWorkflowHistory) that provides updated WorkflowExecutionState snapshots. 47 | // Example: 48 | // To print a workflow's state whenever there's updates 49 | // 50 | // iter := GetWorkflowExecutionUpdates(ctx, client, wfId, runId, -1, 5) 51 | // var state *WorkflowExecutionState 52 | // for iter.HasNext() { 53 | // update = iter.Next() 54 | // PrintWorkflowState(update.State) 55 | // } 56 | func GetWorkflowExecutionUpdates(ctx context.Context, client sdkclient.Client, wfId, runId string, fetchAll bool, foldStatus []enums.WorkflowExecutionStatus, depth int, concurrency int) (WorkflowExecutionUpdateIterator, error) { 57 | if concurrency < 1 { 58 | return nil, fmt.Errorf("invalid value for concurrency (expected non-zero positive integer, got %d)", concurrency) 59 | } 60 | 61 | pool := pond.New(concurrency, 1000) 62 | 63 | // Using a GroupContext we can use pond's error handling and context cancelling. 64 | group, poolCtx := pool.GroupContext(ctx) 65 | updateChan := make(chan struct{}) 66 | doneChan := make(chan struct{}) 67 | errorChan := make(chan error) 68 | 69 | state := NewWorkflowExecutionState(wfId, runId) 70 | job, err := NewWorkflowStateJob(poolCtx, client, state, fetchAll, foldStatus, depth, updateChan) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | go func() { 76 | group.Submit(job.Run(group)) 77 | // Wait for all tasks to complete and signal done 78 | if err = group.Wait(); err != nil { 79 | errorChan <- err 80 | } else { 81 | doneChan <- struct{}{} 82 | } 83 | }() 84 | 85 | return &WorkflowExecutionUpdateIteratorImpl{ 86 | updateChan: updateChan, 87 | doneChan: doneChan, 88 | errorChan: errorChan, 89 | state: state, 90 | }, nil 91 | } 92 | 93 | // HasNext checks if there's any more updates in the updateChan channel. Returns false if the channel has been closed. 94 | func (iter *WorkflowExecutionUpdateIteratorImpl) HasNext() bool { 95 | iter.updated = true 96 | select { 97 | case <-iter.updateChan: 98 | iter.nextUpdate = &WorkflowExecutionUpdate{State: iter.state} 99 | return true 100 | case <-iter.doneChan: 101 | return false 102 | case err := <-iter.errorChan: 103 | iter.err = err 104 | return true 105 | } 106 | } 107 | 108 | // Next return the last processed execution update. HasNext has to be called first (following the HasNext/Next pattern). 109 | func (iter *WorkflowExecutionUpdateIteratorImpl) Next() (*WorkflowExecutionUpdate, error) { 110 | // Make sure HasNext() has been called first. 111 | if !iter.updated { 112 | return nil, fmt.Errorf("please call HasNext() first") 113 | } 114 | iter.updated = false 115 | 116 | if err := iter.err; err != nil { 117 | iter.err = nil 118 | return nil, err 119 | } 120 | 121 | update := iter.nextUpdate 122 | iter.nextUpdate = nil 123 | return update, nil 124 | } 125 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/workflow_state_worker.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/alitto/pond" 9 | "go.temporal.io/api/enums/v1" 10 | "go.temporal.io/api/history/v1" 11 | sdkclient "go.temporal.io/sdk/client" 12 | ) 13 | 14 | // WorkflowStateJob implements a WorkerJob to retrieve updates for a WorkflowExecutionState and its child workflows. 15 | type WorkflowStateJob struct { 16 | ctx context.Context 17 | client sdkclient.Client 18 | 19 | state *WorkflowExecutionState 20 | depth int 21 | fetchAll bool 22 | foldStatus []enums.WorkflowExecutionStatus 23 | childJobs []*WorkflowStateJob 24 | isUpToDate bool 25 | 26 | updateChan chan struct{} 27 | } 28 | 29 | // NewWorkflowStateJob returns a new WorkflowStateJob. It requires an updateChan to signal when there's updates. 30 | func NewWorkflowStateJob(ctx context.Context, client sdkclient.Client, state *WorkflowExecutionState, fetchAll bool, foldStatus []enums.WorkflowExecutionStatus, depth int, updateChan chan struct{}) (*WorkflowStateJob, error) { 31 | if state == nil { 32 | return nil, errors.New("workflow state cannot be nil for a workflow state job") 33 | } 34 | if updateChan == nil { 35 | return nil, errors.New("updateChan cannot be nil for a workflow state job") 36 | } 37 | 38 | // Get workflow execution's description, so we can know if we're up-to-date. Doing this synchronously will allow us to correctly 39 | // assess how many events need to be processed (otherwise only the ones from the root workflow will be counted). 40 | // We don't mind if this fails, since HistoryLength is used only to display event processing progress. 41 | if description, err := client.DescribeWorkflowExecution(ctx, state.Execution.GetWorkflowId(), state.Execution.GetRunId()); err != nil { 42 | return nil, err 43 | } else { 44 | execInfo := description.GetWorkflowExecutionInfo() 45 | state.HistoryLength = execInfo.HistoryLength 46 | state.IsArchived = execInfo.HistoryLength == 0 // TODO: Find a better way to identify archived workflows 47 | } 48 | 49 | return &WorkflowStateJob{ 50 | ctx: ctx, 51 | client: client, 52 | state: state, 53 | depth: depth, 54 | fetchAll: fetchAll, 55 | foldStatus: foldStatus, 56 | updateChan: updateChan, 57 | childJobs: []*WorkflowStateJob{}, 58 | }, nil 59 | } 60 | 61 | // Run starts the WorkflowStateJob, which retrieves the workflow's events and spawns new jobs for the child workflows once it's up-to-date. 62 | // New jobs are submitted to the pool when the job is up-to-date to reduce the amount of unnecessary history fetches (e.g. when the child workflow is already completed). 63 | func (job *WorkflowStateJob) Run(group *pond.TaskGroupWithContext) func() error { 64 | return func() error { 65 | state := job.state 66 | wfId := state.Execution.GetWorkflowId() 67 | runId := state.Execution.GetRunId() 68 | 69 | // Make sure to not long poll archived workflows since GetWorkflowHistory fails under those circumstances. 70 | isLongPoll := !state.IsArchived 71 | historyIterator := job.client.GetWorkflowHistory(job.ctx, wfId, runId, isLongPoll, enums.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT) 72 | 73 | for historyIterator.HasNext() { 74 | event, err := historyIterator.Next() 75 | if err != nil { 76 | return err 77 | } 78 | if event == nil { 79 | continue 80 | } 81 | 82 | // Update state with new event and signal 83 | job.state.Update(event) 84 | job.updateChan <- struct{}{} 85 | 86 | // Create child jobs if we're on a child workflow execution started event and we haven't hit depth 0. 87 | if event.EventType == enums.EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_STARTED && job.depth != 0 { 88 | childJob, err := job.GetChildJob(event) 89 | if err != nil { 90 | // TODO: Consider if we want to error out if a child workflow cannot be updated by itself. 91 | return err 92 | } 93 | job.childJobs = append(job.childJobs, childJob) 94 | 95 | if job.isUpToDate { 96 | group.Submit(childJob.Run(group)) 97 | } 98 | } 99 | 100 | // Start child jobs when we're up-to-date and if we haven't reached max depth. 101 | if !job.isUpToDate && event.EventId >= state.HistoryLength && job.depth != 0 { 102 | job.isUpToDate = true 103 | for _, childJob := range job.childJobs { 104 | if childJob.ShouldStart() { 105 | group.Submit(childJob.Run(group)) 106 | } else { 107 | // Consider the child job completed if it's not going to be started 108 | childJob.state.LastEventId = childJob.state.HistoryLength 109 | } 110 | } 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | } 117 | 118 | // GetChildJob gets a new child job and appends it to the list of childJobs. These jobs don't start until the parent is catched up. 119 | func (job *WorkflowStateJob) GetChildJob(event *history.HistoryEvent) (*WorkflowStateJob, error) { 120 | // Retrieve child workflow from parent and create a new job to fetch events for it 121 | childAttrs := event.GetChildWorkflowExecutionStartedEventAttributes() 122 | wf, ok := job.state.GetChildWorkflowByEventId(childAttrs.GetInitiatedEventId()) 123 | if !ok { 124 | exec := childAttrs.GetWorkflowExecution() 125 | return nil, fmt.Errorf("child workflow (%s, %s) initiated in event %d not found in parent workflow's events", exec.GetWorkflowId(), exec.GetRunId(), childAttrs.GetInitiatedEventId()) 126 | } 127 | 128 | // Create child job 129 | childJob, err := NewWorkflowStateJob(job.ctx, job.client, wf, job.fetchAll, job.foldStatus, job.depth-1, job.updateChan) 130 | if err != nil { 131 | return nil, err 132 | } 133 | return childJob, nil 134 | } 135 | 136 | // ShouldStart will return true if the state is in a status that requires requesting its event history. 137 | // This will help reduce the amount of event histories requested when they're not needed. 138 | func (job *WorkflowStateJob) ShouldStart() bool { 139 | if job.fetchAll { 140 | return true 141 | } 142 | for _, st := range job.foldStatus { 143 | if st == job.state.Status { 144 | return false 145 | } 146 | } 147 | return true 148 | } 149 | -------------------------------------------------------------------------------- /temporalcli/internal/tracer/workflow_tracer.go: -------------------------------------------------------------------------------- 1 | package tracer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/signal" 9 | "time" 10 | 11 | "go.temporal.io/api/enums/v1" 12 | "go.temporal.io/sdk/client" 13 | ) 14 | 15 | type WorkflowTracerOptions struct { 16 | NoFold bool 17 | FoldStatuses []enums.WorkflowExecutionStatus 18 | Depth int 19 | Concurrency int 20 | 21 | UpdatePeriod time.Duration 22 | } 23 | 24 | type WorkflowTracer struct { 25 | client client.Client 26 | update *WorkflowExecutionUpdate 27 | opts WorkflowTracerOptions 28 | writer *TermWriter 29 | output io.Writer 30 | 31 | interruptSignals []os.Signal 32 | 33 | doneChan chan bool 34 | sigChan chan os.Signal 35 | errChan chan error 36 | } 37 | 38 | func NewWorkflowTracer(client client.Client, options ...func(tracer *WorkflowTracer)) (*WorkflowTracer, error) { 39 | tracer := &WorkflowTracer{ 40 | client: client, 41 | doneChan: make(chan bool), 42 | errChan: make(chan error), 43 | sigChan: make(chan os.Signal, 1), 44 | output: os.Stdout, 45 | } 46 | for _, opt := range options { 47 | opt(tracer) 48 | } 49 | 50 | // Add terminal writer, which we'll to print and clear updates to the terminal 51 | tracer.writer = NewTermWriter(tracer.output) 52 | 53 | signal.Notify(tracer.sigChan, tracer.interruptSignals...) 54 | 55 | return tracer, nil 56 | } 57 | 58 | // WithInterrupts sets the signals that will interrupt the tracer 59 | func WithInterrupts(signals ...os.Signal) func(*WorkflowTracer) { 60 | return func(t *WorkflowTracer) { 61 | t.interruptSignals = signals 62 | } 63 | } 64 | 65 | // WithOptions sets the view options for the tracer 66 | func WithOptions(opts WorkflowTracerOptions) func(*WorkflowTracer) { 67 | return func(t *WorkflowTracer) { 68 | t.opts = opts 69 | } 70 | } 71 | 72 | func WithOutput(w io.Writer) func(*WorkflowTracer) { 73 | return func(t *WorkflowTracer) { 74 | t.output = w 75 | } 76 | } 77 | 78 | // GetExecutionUpdates gets workflow execution updates for a particular workflow 79 | func (t *WorkflowTracer) GetExecutionUpdates(ctx context.Context, wid, rid string) error { 80 | iter, err := GetWorkflowExecutionUpdates(ctx, t.client, wid, rid, t.opts.NoFold, t.opts.FoldStatuses, t.opts.Depth, t.opts.Concurrency) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // Start a goroutine to receive updates 86 | go func() { 87 | for iter.HasNext() { 88 | if t.update, err = iter.Next(); err != nil { 89 | t.errChan <- err 90 | } 91 | } 92 | t.doneChan <- true 93 | }() 94 | return nil 95 | } 96 | 97 | func (t *WorkflowTracer) PrintUpdates(tmpl *ExecutionTemplate, updatePeriod time.Duration) (int, error) { 98 | var currentEvents int64 99 | var totalEvents int64 100 | var isUpToDate bool 101 | 102 | ticker := time.NewTicker(updatePeriod).C 103 | 104 | for { 105 | select { 106 | case <-ticker: 107 | state := t.update.GetState() 108 | if state == nil { 109 | continue 110 | } 111 | 112 | if !isUpToDate { 113 | currentEvents, totalEvents = state.GetNumberOfEvents() 114 | // TODO: This will sometime leave the watch hanging on "Processing events" (usually when there's more childs that workers and they're not closing) 115 | // We could maybe set isUpToDate = true if we've seen the same number of events for a number of loops. 116 | isUpToDate = totalEvents > 0 && currentEvents >= totalEvents && !state.IsArchived 117 | _, _ = t.writer.WriteLine(ProgressString(currentEvents, totalEvents)) 118 | } else { 119 | err := tmpl.Execute(t.writer, t.update.GetState(), 0) 120 | if err != nil { 121 | return 1, err 122 | } 123 | } 124 | if err := t.writer.Flush(true); err != nil { 125 | return 1, err 126 | } 127 | case <-t.doneChan: 128 | return PrintAndExit(t.writer, tmpl, t.update) 129 | case <-t.sigChan: 130 | return PrintAndExit(t.writer, tmpl, t.update) 131 | case err := <-t.errChan: 132 | return 1, err 133 | } 134 | } 135 | } 136 | 137 | func ProgressString(currentEvents int64, totalEvents int64) string { 138 | if totalEvents == 0 { 139 | if currentEvents == 0 { 140 | return "Processing HistoryEvents" 141 | } 142 | return fmt.Sprintf("Processing HistoryEvents (%d)", currentEvents) 143 | } else { 144 | return fmt.Sprintf("Processing HistoryEvents (%d/%d)", currentEvents, totalEvents) 145 | } 146 | } 147 | 148 | func PrintAndExit(writer *TermWriter, tmpl *ExecutionTemplate, update *WorkflowExecutionUpdate) (int, error) { 149 | state := update.GetState() 150 | if state == nil { 151 | return 0, nil 152 | } 153 | if err := tmpl.Execute(writer, update.GetState(), 0); err != nil { 154 | return 1, err 155 | } 156 | if err := writer.Flush(false); err != nil { 157 | return 1, err 158 | } 159 | return GetExitCode(update.GetState()), nil 160 | } 161 | 162 | // GetExitCode returns the exit code for a given workflow execution status. 163 | func GetExitCode(exec *WorkflowExecutionState) int { 164 | if exec == nil { 165 | // Don't panic if the state is missing. 166 | return 0 167 | } 168 | switch exec.Status { 169 | case enums.WORKFLOW_EXECUTION_STATUS_FAILED: 170 | return 2 171 | case enums.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: 172 | return 3 173 | case enums.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED: 174 | return 4 175 | } 176 | return 0 177 | } 178 | -------------------------------------------------------------------------------- /temporalcli/payload.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "go.temporal.io/api/common/v1" 10 | ) 11 | 12 | func CreatePayloads(data [][]byte, metadata map[string][]byte, isBase64 bool) (*common.Payloads, error) { 13 | ret := &common.Payloads{Payloads: make([]*common.Payload, len(data))} 14 | for i, in := range data { 15 | // If it's JSON, validate it 16 | if strings.HasPrefix(string(metadata["encoding"]), "json/") && !json.Valid(in) { 17 | return nil, fmt.Errorf("input #%v is not valid JSON", i+1) 18 | } 19 | // Decode base64 if base64'd (std encoding only for now) 20 | if isBase64 { 21 | var err error 22 | if in, err = base64.StdEncoding.DecodeString(string(in)); err != nil { 23 | return nil, fmt.Errorf("input #%v is not valid base64", i+1) 24 | } 25 | } 26 | ret.Payloads[i] = &common.Payload{Data: in, Metadata: metadata} 27 | } 28 | return ret, nil 29 | } 30 | -------------------------------------------------------------------------------- /temporalcli/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | _ "modernc.org/sqlite" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | // Pinning modernc.org/sqlite to this version until https://gitlab.com/cznic/sqlite/-/issues/196 is resolved 11 | func TestSqliteVersion(t *testing.T) { 12 | content, err := os.ReadFile("../go.mod") 13 | if err != nil { 14 | t.Fatalf("Failed to read go.mod: %v", err) 15 | } 16 | contentStr := string(content) 17 | if !strings.Contains(contentStr, "modernc.org/sqlite v1.34.1") { 18 | t.Errorf("go.mod missing dependency modernc.org/sqlite v1.34.1") 19 | } 20 | if !strings.Contains(contentStr, "modernc.org/libc v1.55.3") { 21 | t.Errorf("go.mod missing dependency modernc.org/libc v1.55.3") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /temporalcli/strings.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | type StringEnum struct { 12 | Allowed []string 13 | Value string 14 | ChangedFromDefault bool 15 | } 16 | 17 | func NewStringEnum(allowed []string, value string) StringEnum { 18 | return StringEnum{Allowed: allowed, Value: value} 19 | } 20 | 21 | func (s *StringEnum) String() string { return s.Value } 22 | 23 | func (s *StringEnum) Set(p string) error { 24 | for _, allowed := range s.Allowed { 25 | if p == allowed { 26 | s.Value = p 27 | s.ChangedFromDefault = true 28 | return nil 29 | } 30 | } 31 | return fmt.Errorf("%v is not one of required values of %v", p, strings.Join(s.Allowed, ", ")) 32 | } 33 | 34 | func (*StringEnum) Type() string { return "string" } 35 | 36 | type StringEnumArray struct { 37 | // maps lower case value to original case 38 | Allowed map[string]string 39 | // values in original case 40 | Values []string 41 | } 42 | 43 | func NewStringEnumArray(allowed []string, values []string) StringEnumArray { 44 | // maps lower case value to original case so we can do case insensitive comparison, 45 | // while maintaining original case 46 | var allowedMap = make(map[string]string) 47 | for _, str := range allowed { 48 | allowedMap[strings.ToLower(str)] = str 49 | } 50 | 51 | return StringEnumArray{Allowed: allowedMap, Values: values} 52 | } 53 | 54 | func (s *StringEnumArray) String() string { return strings.Join(s.Values, ",") } 55 | 56 | func (s *StringEnumArray) Set(p string) error { 57 | val, ok := s.Allowed[strings.ToLower(p)] 58 | if !ok { 59 | values := make([]string, 0, len(s.Allowed)) 60 | for _, v := range s.Allowed { 61 | values = append(values, v) 62 | } 63 | return fmt.Errorf("invalid value: %s, allowed values are: %s", p, strings.Join(values, ", ")) 64 | } 65 | s.Values = append(s.Values, val) 66 | return nil 67 | } 68 | 69 | func (*StringEnumArray) Type() string { return "string" } 70 | 71 | func stringToProtoEnum[T ~int32](s string, maps ...map[string]int32) (T, error) { 72 | // Go over each map looking, if not there, use first map to build set of 73 | // strings required 74 | for _, m := range maps { 75 | for k, v := range m { 76 | if strings.EqualFold(k, s) { 77 | return T(v), nil 78 | } 79 | } 80 | } 81 | keys := make([]string, 0, len(maps[0])) 82 | for k := range maps[0] { 83 | keys = append(keys, k) 84 | } 85 | sort.Strings(keys) 86 | return 0, fmt.Errorf("unknown value %q, expected one of: %v", s, strings.Join(keys, ", ")) 87 | } 88 | 89 | func stringKeysValues(s []string) (map[string]string, error) { 90 | ret := make(map[string]string, len(s)) 91 | for _, item := range s { 92 | pieces := strings.SplitN(item, "=", 2) 93 | if len(pieces) != 2 { 94 | return nil, fmt.Errorf("missing expected '=' in %q", item) 95 | } 96 | ret[pieces[0]] = pieces[1] 97 | } 98 | return ret, nil 99 | } 100 | 101 | func stringKeysJSONValues(s []string, useJSONNumber bool) (map[string]any, error) { 102 | if len(s) == 0 { 103 | return nil, nil 104 | } 105 | ret := make(map[string]any, len(s)) 106 | for _, item := range s { 107 | pieces := strings.SplitN(item, "=", 2) 108 | if len(pieces) != 2 { 109 | return nil, fmt.Errorf("missing expected '=' in %q", item) 110 | } 111 | dec := json.NewDecoder(bytes.NewReader([]byte(pieces[1]))) 112 | if useJSONNumber { 113 | dec.UseNumber() 114 | } 115 | var v any 116 | if err := dec.Decode(&v); err != nil { 117 | return nil, fmt.Errorf("invalid JSON value for key %q: %w", pieces[0], err) 118 | } else if dec.InputOffset() != int64(len(pieces[1])) { 119 | return nil, fmt.Errorf("invalid JSON value for key %q: unexpected trailing data", pieces[0]) 120 | } 121 | ret[pieces[0]] = v 122 | } 123 | return ret, nil 124 | } 125 | -------------------------------------------------------------------------------- /temporalcli/timestamp.go: -------------------------------------------------------------------------------- 1 | package temporalcli 2 | 3 | import "time" 4 | 5 | type Timestamp time.Time 6 | 7 | func (t Timestamp) Time() time.Time { 8 | return time.Time(t) 9 | } 10 | 11 | func (t *Timestamp) String() string { 12 | return t.Time().Format(time.RFC3339) 13 | } 14 | 15 | func (t *Timestamp) Set(s string) error { 16 | p, err := time.Parse(time.RFC3339, s) 17 | if err != nil { 18 | return err 19 | } 20 | *t = Timestamp(p) 21 | return nil 22 | } 23 | 24 | func (t *Timestamp) Type() string { 25 | return "timestamp" 26 | } 27 | --------------------------------------------------------------------------------