diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..e430a962 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,54 @@ +name: Continuous Integration +on: + pull_request: + push: + branches: + - main + +jobs: + build-test: + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + checkGenCodeTarget: true + cloudTestTarget: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Test + run: go test -v ./... + + - name: Regen code, confirm unchanged + if: ${{ matrix.checkGenCodeTarget }} + run: | + go run ./temporalcli/internal/cmd/gen-commands + git diff --exit-code + + - name: Test cloud + # Only supported in non-fork runs, since secrets are not available in forks + if: ${{ matrix.cloudTestTarget && (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/cli') }} + env: + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_TLS_CERT: client.crt + TEMPORAL_TLS_CERT_CONTENT: ${{ secrets.TEMPORAL_CLIENT_CERT }} + TEMPORAL_TLS_KEY: client.key + TEMPORAL_TLS_KEY_CONTENT: ${{ secrets.TEMPORAL_CLIENT_KEY }} + shell: bash + run: | + echo $TEMPORAL_TLS_CERT_CONTENT >> client.crt + echo $TEMPORAL_TLS_KEY_CONTENT >> client.key + cat client.crt + go run ./cmd/temporal workflow list --limit 2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..018d18f8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +## Building + +With the latest `go` version installed, simply run the following: + + go build ./cmd/temporal + +## Testing + +Uses normal `go test`, e.g.: + + go test ./... + +See other tests for how to leverage things like the command harness and dev server suite. + +## Adding/updating commands + +First, update [commands.md](temporalcli/commandsmd/commands.md) following the rules in that file. Then to regenerate the +[commands.gen.go](temporalcli/commands.gen.go) file from code, simply run: + + go run ./temporalcli/internal/cmd/gen-commands + +This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement +`run` on the new command in a separate file before it will compile. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8c951263..2fcc64db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2023 Temporal Technologies Inc. All rights reserved. +Copyright (c) 2024 Temporal Technologies Inc. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7dfc8b97..17315daf 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,8 @@ -(under development) +# Temporal CLI -To regen command code: +Temporal command-line interface and development server. -``` -go run ./temporalcli/internal/cmd/gen-commands -``` +⚠️ Under active development and inputs/outputs may change ⚠️ -Known incompatibilities: - -NOTE: All of these incompatibilities are intentional and almost all decisions can be reverted if decided. - -* Removed `--memo-file` from workflow args -* `--color` not currently implemented everywhere (like for logs) -* Removed paging by default (i.e. basically `--no-pager` behavior) -* Duration arguments require trailing unit (i.e. `--workflow-timeout 5` is now `--workflow-timeout 5s`) -* `--output table` and `--output card` blended to `--output text` (the default), but we may let table options be applied - as separate params -* `TEMPORAL_CLI_SHOW_STACKS` - no stack trace-based errors -* `--tls-ca-path` cannot be a URL -* Not explicitly setting TLS SNI name from host of URL -* JSON output for things like workflow start use more JSON-like field names -* Workflow history JSON not dumped by default as part of `workflow execute` when JSON set -* Concept of `--fields long` is gone, now whether some more verbose fields are emitted is controlled more specifically -* To get accurate workflow result, workflow follows continue as new for `workflow execute` -* Removed the `-f` alias for `--follow` on `workflow show` -* `server start-dev` will reuse the root logger which means: - * Default is text (or "pretty") instead JSON - * No way to set level to "fatal" only - * All panic and fatal logs are just error logs - * Goes to stderr instead of stdout -* `server start-dev --db-filename` no longer auto-creates directory path of 0777 dirs if not present -* `workflow execute` when using `--event-details` (equivalent of `--fields long`) now shows full proto JSON attributes - instead of wrapped partial table -* `workflow start` and `workflow execute` no longer succeeds by default if the workflow exists already, but - `--allow-existing` exists -* The text version of `workflow describe` does not have all the things the JSON version does (and in the past there was - only JSON) -* `workflow list` in JSON now does one object at a time instead of before where it was 100 objects at a time -* `workflow list` in text now does a page at a time before realigning table (sans headers) instead of 100 at a time -* `workflow list` in text no longer includes `--fields long` -* Removed `--context-timeout` since it is confusing when you might want to customize it (can re-add timeout concepts if - needed) - -Known improvements: - -* Cobra (any arg at any place) -* Customize path to env file -* Global log-level customization -* Global json vs text data output customization -* Markdown-based code generation -* Solid test framework -* Added `--input-encoding` to support more payload types (e.g. can support proto JSON and even proto binary) -* Library available for docs team to write doc generator with -* JSON output is reasonable for tool use -* Properly gives failing status code if workflow fails on "execute" but JSON output is set -* `--color` is available to disable any coloring -* Dev server reuses logger meaning it is on stderr by default -* `workflow execute` now streams the event table instead of waiting -* Use shorthand JSON payloads by default on (non-history) JSON output which makes payloads much more readable -* `workflow describe` now doesn't force users to see JSON, there is a non-JSON text form - -Notes about approach taken: - -* Did not spend time trying to improve documentation, so all of the inconsistent documentation remains and should be - cleaned up separately -* Did not spend (much) time trying to completely change behavior or commands -* Compatibility intentionally retained in most reasonable ways -* File-level copyright notices retained on places with DataDog -* Expecting better formatting to come later - -Contribution rules: - -* Follow rules in commands.md -* Refactoring and reuse is welcome -* Avoid package sprawl and complication -* Try to only use logger and printer for output -* Command testing (does not apply to unit tests that are not testing commands) - * Use the command harness (create a new one for each different option if needed) if server not needed, or add new test - to `SharedServerSuite` if server needed - * Name command tests as `Test[_]_`, e.g. a simple - "temporal server start dev" test may be named `TestServer_StartDev_Simple`. Can test multiple subcommands at once - and `CamelCaseCommand` can just be the parent, e.g. a simple test of different "temporal env" commands may be named - `TestEnv_Simple`. Often the `Qualifier` is just `Simple`. - -TODO: - -* Version via goreleaser -* Env variables -* Workflow show max-field-length? -* Workflow start delay: https://github.com/temporalio/cli/pull/402 -* Enhance task queue describe: https://github.com/temporalio/cli/pull/399 -* Consider having a common dev-server suite for sharing a common dev server across several tests -* Show result in `workflow describe` the same way it's shown in `workflow execute` \ No newline at end of file +See [the documentation](https://docs.temporal.io/cli) for install and usage information. See +[CONTRIBUTING.md](CONTRIBUTING.md) for build and development information. \ No newline at end of file diff --git a/go.mod b/go.mod index 8141c79c..93f1b18b 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,8 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 - github.com/temporalio/ui-server/v2 v2.21.3 + // TODO: Waiting on ui-server to have gogo removed + github.com/temporalio/ui-server/v2 v2.21.4-0.20240111221047-91eab86e3734 go.temporal.io/api v1.26.2-0.20231129165614-630d88440548 go.temporal.io/sdk v1.25.2-0.20231204212658-5fdbecc56c8c go.temporal.io/server v1.23.0-rc2.0.20231212000105-51ea367f9f37 @@ -146,5 +147,3 @@ require ( modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.1.0 // indirect ) - -replace github.com/temporalio/ui-server/v2 => ../ui-server diff --git a/go.sum b/go.sum index d3739366..20dcaabb 100644 --- a/go.sum +++ b/go.sum @@ -339,6 +339,8 @@ github.com/temporalio/sqlparser v0.0.0-20231115171017-f4060bcfa6cb/go.mod h1:143 github.com/temporalio/tchannel-go v1.22.1-0.20220818200552-1be8d8cffa5b/go.mod h1:c+V9Z/ZgkzAdyGvHrvC5AsXgN+M9Qwey04cBdKYzV7U= github.com/temporalio/tchannel-go v1.22.1-0.20231116015023-bd4fb7678499 h1:PUclzwrSQgakQIiEvvgVdcfulynpX0JM1f716pEcCTU= github.com/temporalio/tchannel-go v1.22.1-0.20231116015023-bd4fb7678499/go.mod h1:ezRQRwu9KQXy8Wuuv1aaFFxoCNz5CeNbVOOkh3xctbY= +github.com/temporalio/ui-server/v2 v2.21.4-0.20240111221047-91eab86e3734 h1:kpKxIS3Nt3qNafNP2A1GUwzGjPqWn69xyCJUcLf+G1A= +github.com/temporalio/ui-server/v2 v2.21.4-0.20240111221047-91eab86e3734/go.mod h1:n7Sl9Zygt0ZGaQHi+OBQMj0l0b00MIOf+gvoU7KLImM= github.com/twmb/murmur3 v1.1.5/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= diff --git a/temporalcli/client.go b/temporalcli/client.go index 7c614817..8e608267 100644 --- a/temporalcli/client.go +++ b/temporalcli/client.go @@ -64,7 +64,7 @@ func (c *ClientOptions) dialClient(cctx *CommandContext) (client.Client, error) func (c *ClientOptions) tlsConfig() (*tls.Config, error) { // We need TLS if any of these TLS options are set - if !c.Tls || c.TlsCaPath != "" || c.TlsCertPath != "" || c.TlsKeyPath != "" { + if !c.Tls && c.TlsCaPath == "" && c.TlsCertPath == "" && c.TlsKeyPath == "" { return nil, nil } diff --git a/temporalcli/commands.taskqueue_test.go b/temporalcli/commands.taskqueue_test.go index 33fce239..0246595e 100644 --- a/temporalcli/commands.taskqueue_test.go +++ b/temporalcli/commands.taskqueue_test.go @@ -1,8 +1,25 @@ package temporalcli_test -import "encoding/json" +import ( + "encoding/json" + "time" + + "go.temporal.io/api/enums/v1" +) func (s *SharedServerSuite) TestTaskQueue_Describe_Simple() { + // Wait until the poller appears + s.Eventually(func() bool { + desc, err := s.Client.DescribeTaskQueue(s.Context, s.Worker.Options.TaskQueue, enums.TASK_QUEUE_TYPE_WORKFLOW) + s.NoError(err) + for _, poller := range desc.Pollers { + if poller.Identity == s.DevServer.Options.ClientOptions.Identity { + return true + } + } + return false + }, 5*time.Second, 100*time.Millisecond, "Worker never appeared") + // Text res := s.Execute( "task-queue", "describe", diff --git a/temporalcli/commands_test.go b/temporalcli/commands_test.go index 7af45e41..99595af0 100644 --- a/temporalcli/commands_test.go +++ b/temporalcli/commands_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "log/slog" - "os" "slices" "strings" "sync" @@ -31,8 +30,6 @@ type CommandHarness struct { Context context.Context // Can be used to cancel context given to commands (simulating interrupt) CancelContext context.CancelFunc - - previousEnv map[string]*string } func NewCommandHarness(t *testing.T) *CommandHarness { @@ -48,14 +45,6 @@ func (h *CommandHarness) Close() { if h.CancelContext != nil { h.CancelContext() } - // Put env changes back - for k, v := range h.previousEnv { - if v == nil { - os.Unsetenv(k) - } else { - os.Setenv(k, *v) - } - } } func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) { diff --git a/temporalcli/internal/stringify.go b/temporalcli/internal/stringify.go deleted file mode 100644 index d28636e9..00000000 --- a/temporalcli/internal/stringify.go +++ /dev/null @@ -1,250 +0,0 @@ -package stringify - -// This was taken verbatim from old CLI -// TODO(cretz): Replace colorer? - -import ( - "encoding/base64" - "fmt" - "reflect" - "sort" - "strconv" - "strings" - "time" - "unicode" - - "github.com/fatih/color" - commonpb "go.temporal.io/api/common/v1" - enumspb "go.temporal.io/api/enums/v1" - "go.temporal.io/sdk/converter" -) - -const ( - maxWordLength = 120 // if text length is larger than maxWordLength, it will be inserted spaces -) - -func AnyToString(val interface{}, printFully bool, maxFieldLength int, dc converter.DataConverter) string { - v := reflect.ValueOf(val) - if val == nil || (v.Kind() == reflect.Ptr && v.IsNil()) { - return "" - } - - // Special types - switch tVal := val.(type) { - case string: - return tVal - case time.Time: - if tVal.IsZero() { - return "" - } - return tVal.String() - case *commonpb.Payload: - return dc.ToString(tVal) - case *commonpb.Payloads: - return fmt.Sprintf("[%s]", strings.Join(dc.ToStrings(tVal), ", ")) - case int: - return strconv.FormatInt(int64(tVal), 10) - case int64: - return strconv.FormatInt(tVal, 10) - case int32: - return strconv.FormatInt(int64(tVal), 10) - case float64: - return strconv.FormatFloat(tVal, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(tVal), 'f', -1, 64) - case bool: - return strconv.FormatBool(tVal) - case byte: - return strconv.FormatInt(int64(tVal), 10) - case []byte: - if len(tVal) == 0 { - return "" - } - return fmt.Sprintf("[%v]", bytesToString(tVal)) - } - - switch v.Kind() { - case reflect.Invalid: - return "" - case reflect.Slice: - // All but []byte which is already handled. - return sliceToString(v, printFully, maxFieldLength, dc) - case reflect.Ptr: - return AnyToString(v.Elem().Interface(), printFully, maxFieldLength, dc) - case reflect.Map: - type keyValuePair struct { - key string - value string - } - - kvPairs := make([]keyValuePair, 0, v.Len()) - iter := v.MapRange() - for iter.Next() { - mapKey := iter.Key() - mapVal := iter.Value() - if !mapKey.CanInterface() || !mapVal.CanInterface() { - continue - } - mapKeyStr := AnyToString(mapKey.Interface(), true, 0, dc) - if mapKeyStr == "" { - continue - } - mapValStr := AnyToString(mapVal.Interface(), true, 0, dc) - if mapValStr == "" { - continue - } - kvPairs = append(kvPairs, keyValuePair{key: mapKeyStr, value: mapValStr}) - } - - if len(kvPairs) == 0 { - return "" - } - - sort.Slice(kvPairs, func(i, j int) bool { - return strings.Compare(kvPairs[i].key, kvPairs[j].key) < 0 - }) - var b strings.Builder - b.WriteString("map{") - for i, kvPair := range kvPairs { - b.WriteString(kvPair.key) - b.WriteRune(':') - b.WriteString(kvPair.value) - if i != len(kvPairs)-1 { - b.WriteString(", ") - } - } - b.WriteRune('}') - return b.String() - case reflect.Struct: - var b strings.Builder - t := reflect.TypeOf(val) - b.WriteRune('{') - for i := 0; i < v.NumField(); i++ { - f := v.Field(i) - if f.Kind() == reflect.Invalid { - continue - } - // Filter out private fields. - if !f.CanInterface() { - continue - } - - fieldName := t.Field(i).Name - fieldStr := AnyToString(f.Interface(), printFully, maxFieldLength, dc) - if fieldStr == "" { - continue - } - if !isAttributeName(fieldName) && !strings.HasSuffix(fieldName, "Failure") { - if !printFully { - fieldStr = trimTextAndBreakWords(fieldStr, maxFieldLength) - } else if maxFieldLength != 0 { // for command run workflow and observe history - fieldStr = trimText(fieldStr, maxFieldLength) - } - } - - if b.Len() > 1 { - b.WriteString(", ") - } - if strings.HasSuffix(fieldName, "Reason") || - strings.HasSuffix(fieldName, "Cause") || - strings.HasSuffix(fieldName, "Details") { - b.WriteString(color.MagentaString(fieldName)) - } else if strings.HasSuffix(fieldName, "Input") || - strings.HasSuffix(fieldName, "Result") { - b.WriteString(color.CyanString(fieldName)) - } else if strings.HasSuffix(fieldName, "Failure") || - strings.HasSuffix(fieldName, "Error") { - b.WriteString(color.RedString(fieldName)) - } else { - b.WriteString(fieldName) - } - b.WriteRune(':') - b.WriteString(fieldStr) - } - if b.Len() == 1 { // '{' only - return "" - } - b.WriteRune('}') - return b.String() - default: - return fmt.Sprint(val) - } -} - -func sliceToString(slice reflect.Value, printFully bool, maxFieldLength int, dc converter.DataConverter) string { - var b strings.Builder - b.WriteRune('[') - for i := 0; i < slice.Len(); i++ { - if i == 0 || printFully { - b.WriteString(AnyToString(slice.Index(i).Interface(), printFully, maxFieldLength, dc)) - if i < slice.Len()-1 { - b.WriteRune(',') - } - if !printFully && slice.Len() > 1 { - b.WriteString(fmt.Sprintf("...%d more]", slice.Len()-1)) - return b.String() - } - } - } - b.WriteRune(']') - return b.String() -} - -func bytesToString(val []byte) string { - s := string(val) - isPrintable := true - for _, r := range s { - if !unicode.IsPrint(r) { - isPrintable = false - break - } - } - - if isPrintable { - return strings.TrimSpace(s) - } - - return base64.StdEncoding.EncodeToString(val) -} - -// limit the maximum length for each field -func trimText(input string, maxFieldLength int) string { - if len(input) > maxFieldLength { - input = fmt.Sprintf("%s ... %s", input[:maxFieldLength/2], input[(len(input)-maxFieldLength/2):]) - } - return input -} - -// limit the maximum length for each field, and break long words for table item correctly wrap words -func trimTextAndBreakWords(input string, maxFieldLength int) string { - input = trimText(input, maxFieldLength) - return breakLongWords(input, maxWordLength) -} - -// long words will make output in table cell looks bad, -// break long text "ltltltltllt..." to "ltlt ltlt lt..." will make use of table autowrap so that output is pretty. -func breakLongWords(input string, maxWordLength int) string { - if len(input) <= maxWordLength { - return input - } - - cnt := 0 - for i := 0; i < len(input); i++ { - if cnt == maxWordLength { - cnt = 0 - input = input[:i] + " " + input[i:] - continue - } - cnt++ - if input[i] == ' ' { - cnt = 0 - } - } - return input -} - -func isAttributeName(name string) bool { - eventType := strings.TrimSuffix(name, "EventAttributes") - _, ok := enumspb.EventType_value[eventType] - return ok -}