From 7f4c17d058a5788a0efb3258bcd853834bacd1f3 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 7 Jan 2026 12:37:53 -0500 Subject: [PATCH 01/12] fix formatting directive for fmt.Errorf --- temporalcloudcli/cloud.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index c9029e4..501fb29 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -23,7 +23,7 @@ func (c *CloudCommand) GetAPIKey(ctx context.Context) (string, error) { token, refreshed, err := GetToken(ctx, loadClientOauthRes.OAuth.ClientConfig, loadClientOauthRes.OAuth.Token) if err != nil { if errors.Is(err, ErrLoginRequired) { - return "", fmt.Errorf("login session expired, please run `temporal cloud login`", err) + return "", fmt.Errorf("login session expired, please run `temporal cloud login`: %w", err) } return "", fmt.Errorf("failed to get access token: %w", err) } From c04bd8a018725501ab17214e622db799f35980a8 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Thu, 8 Jan 2026 16:03:47 -0500 Subject: [PATCH 02/12] add mise toml and add command test harness --- mise.toml | 17 +++ temporalcloudcli/commands_test.go | 195 ++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 mise.toml create mode 100644 temporalcloudcli/commands_test.go diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..63d7233 --- /dev/null +++ b/mise.toml @@ -0,0 +1,17 @@ +[tools] +# Note: Keep in sync with `toolchain` directive in `go.mod`. +go = "1.25.3" + +# needed for go backends +[settings] +experimental = true + +# Add .local/bin to PATH for sc binaries +[env] +_.path = [".local/bin"] +GOPRIVATE = "go.temporal.io/temporal,go.temporal.io/temporal-proto,github.com/temporalio" + +[tasks] +build = "make build" +gen = "make gen" +test = "make test" \ No newline at end of file diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go new file mode 100644 index 0000000..c2f966f --- /dev/null +++ b/temporalcloudcli/commands_test.go @@ -0,0 +1,195 @@ +package temporalcloudcli_test + +import ( + "bytes" + "context" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +type CommandHarness struct { + *require.Assertions + t *testing.T + Options temporalcloudcli.CommandOptions + // Defaults to a context closed on close or test complete + Context context.Context + // Can be used to cancel context given to commands (simulating interrupt) + CancelContext context.CancelFunc + Stdin bytes.Buffer +} + +func NewCommandHarness(t *testing.T) *CommandHarness { + h := &CommandHarness{Assertions: require.New(t), t: t} + h.Context, h.CancelContext = context.WithCancel(context.Background()) + t.Cleanup(h.Close) + return h +} + +// Reentrant, called after test by default, cancels context +func (h *CommandHarness) Close() { + // Cancel context + if h.CancelContext != nil { + h.CancelContext() + } +} + +// Pieces must appear in order on the line and not overlap +func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) { + h.NoError(AssertContainsOnSameLine(text, pieces...)) +} + +func AssertContainsOnSameLine(text string, pieces ...string) error { + // Build regex pattern based on pieces + pattern := "" + for _, piece := range pieces { + if pattern != "" { + pattern += ".*" + } + pattern += regexp.QuoteMeta(piece) + } + regex, err := regexp.Compile(pattern) + if err != nil { + return err + } + // Split into lines, then check each piece is present + lines := strings.Split(text, "\n") + for _, line := range lines { + if regex.MatchString(line) { + return nil + } + } + return fmt.Errorf("pieces not found in order on any line together") +} + +func TestAssertContainsOnSameLine(t *testing.T) { + require.Error(t, AssertContainsOnSameLine("a b c", "b", "a")) + require.Error(t, AssertContainsOnSameLine("a\nb c", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("aba", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("a b a", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("axb", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) +} + +func (h *CommandHarness) Eventually( + condition func() bool, + waitFor time.Duration, + tick time.Duration, + msgAndArgs ...interface{}, +) { + h.t.Helper() + // We cannot use require.Eventually because it was poorly developed to run the + // condition function in a goroutine which means it can run after complete or + // have other race conditions. Don't even need a complicated ticker because it + // doesn't need to be interruptible. + for start := time.Now(); time.Since(start) < waitFor; { + if condition() { + return + } + time.Sleep(tick) + } + h.Fail("condition did not evaluate to true within timeout", msgAndArgs...) +} + +func (h *CommandHarness) T() *testing.T { + return h.t +} + +type CommandResult struct { + Err error + Stdout bytes.Buffer + Stderr bytes.Buffer +} + +func (h *CommandHarness) Execute(args ...string) *CommandResult { + // Copy options, update as needed + res := &CommandResult{} + options := h.Options + // Set stdio + options.Stdin = &h.Stdin + options.Stdout = &res.Stdout + options.Stderr = &res.Stderr + // Set args + options.Args = args + // Capture error + options.Fail = func(err error) { + if res.Err != nil { + panic("fail called twice, just failed with " + err.Error()) + } + res.Err = err + } + + // Run + ctx, cancel := context.WithCancel(h.Context) + h.t.Cleanup(cancel) + defer cancel() + h.t.Logf("Calling: %v", strings.Join(args, " ")) + temporalcloudcli.Execute(ctx, options) + if res.Stdout.Len() > 0 { + h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) + } + if res.Stderr.Len() > 0 { + h.t.Logf("Stderr:\n-----\n%s\n-----", &res.Stderr) + } + return res +} + +type EnvLookupMap map[string]string + +func (e EnvLookupMap) Environ() []string { + ret := make([]string, 0, len(e)) + for k := range e { + ret = append(ret, k) + } + return ret +} + +func (e EnvLookupMap) LookupEnv(key string) (string, bool) { + v, ok := e[key] + return v, ok +} + +// Run shared server suite +func TestSharedServerSuite(t *testing.T) { + suite.Run(t, new(SharedServerSuite)) +} + +type SharedServerSuite struct { + // Replaced each test + *CommandHarness + + Suite suite.Suite + + apiKey string +} + +func (s *SharedServerSuite) SetupSuite() { + s.apiKey = os.Getenv("TEMPORAL_CLOUD_API_KEY") + s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_CLOUD_API_KEY are you running with `mise run test` and have you filled out your .env? See README.md for details.") +} + +func (s *SharedServerSuite) TearDownSuite() { +} + +func (s *SharedServerSuite) SetupTest() { + // Create new command harness + s.CommandHarness = NewCommandHarness(s.Suite.T()) +} + +func (s *SharedServerSuite) TearDownTest() { + if s.CommandHarness != nil { + s.CommandHarness.Close() + } + s.CommandHarness = nil +} + +func (s *SharedServerSuite) T() *testing.T { return s.Suite.T() } +func (s *SharedServerSuite) SetT(t *testing.T) { s.Suite.SetT(t) } +func (s *SharedServerSuite) SetS(suite suite.TestingSuite) { s.Suite.SetS(suite) } From 918fb67bf685862ef35a149216f014c61379b394 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Thu, 8 Jan 2026 16:07:08 -0500 Subject: [PATCH 03/12] remove a test that's broken we're likely going to use a common printer from cliext anyway --- .../internal/printer/printer_test.go | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/temporalcloudcli/internal/printer/printer_test.go b/temporalcloudcli/internal/printer/printer_test.go index 57ca88f..669d5ac 100644 --- a/temporalcloudcli/internal/printer/printer_test.go +++ b/temporalcloudcli/internal/printer/printer_test.go @@ -2,9 +2,6 @@ package printer_test import ( "bytes" - "os" - "os/exec" - "runtime" "strings" "testing" "unicode" @@ -143,40 +140,3 @@ func TestPrinter_JSONList(t *testing.T) { p.EndList() require.Equal(t, "", buf.String()) } - -// Asserts the printer package don't panic if the CLI is run without a STDOUT. -// This is a tricky thing to validate, as it must be done in a subprocess and as -// `go test` has its own internal fix for improper STDOUT. This was fixed in -// Go 1.22, but keeping this here as a regression test. -// See https://github.com/temporalio/cli/issues/544. -func TestPrinter_NoPanicIfNoStdout(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Skipped on Windows") - return - } - - goPath, err := exec.LookPath("go") - if err != nil { - t.Fatalf("Error finding go executable: %v", err) - } - // Don't use exec.Command here, as it silently replaces nil file descriptors - // with /dev/null on the parent side. We specifically want to test what - // happens when stdout is nil. - p, err := os.StartProcess( - goPath, - []string{"go", "run", "./test/main.go"}, - &os.ProcAttr{ - Files: []*os.File{os.Stdin, nil, os.Stderr}, - }, - ) - if err != nil { - t.Fatalf("Error running command: %v", err) - } - state, err := p.Wait() - if err != nil { - t.Fatalf("Error running command: %v", err) - } - if state.ExitCode() != 0 { - t.Fatalf("Error running command; exit code = %d", state.ExitCode()) - } -} From 413dfe1dcd4559d3888cb6503aea45e39ffa577b Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 14 Jan 2026 12:10:45 -0500 Subject: [PATCH 04/12] add integration test harness and fix bugs apply wasn't working correctly, fixed that, also add capabilities to use api key and server in the environment, add a prompt when deleting namespaces, refactor apply prompt... etc --- .github/workflows/test.yml | 68 +++++++ Makefile | 13 +- README.md | 12 ++ mise.toml | 3 +- temporalcloudcli/commands.gen.go | 2 + temporalcloudcli/commands.namespace.go | 48 +++-- temporalcloudcli/commands.namespace_test.go | 187 ++++++++++++++++++++ temporalcloudcli/commands.yml | 2 + temporalcloudcli/commands_test.go | 81 ++++++++- temporalcloudcli/common.go | 16 ++ temporalcloudcli/namespace.go | 66 ++++++- 11 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 temporalcloudcli/commands.namespace_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fbe4a3f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: test + +on: + push: + branches: + - develop + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Unit + Integration (mise) + runs-on: ubuntu-latest + + # GitHub Environment + environment: CICD + + env: + TEMPORAL_SERVER: ${{ vars.TEMPORAL_SERVER }} + TEMPORAL_ACCOUNT: ${{ vars.TEMPORAL_ACCOUNT }} + TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_API_KEY }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup mise + uses: jdx/mise-action@v2 + with: + install: true + + - name: Validate required environment variables and secrets + shell: bash + run: | + set -euo pipefail + + missing=0 + + if [[ -z "${TEMPORAL_SERVER:-}" ]]; then + echo "::error::TEMPORAL_SERVER is not set or is empty" + missing=1 + fi + + if [[ -z "${TEMPORAL_ACCOUNT:-}" ]]; then + echo "::error::TEMPORAL_ACCOUNT is not set or is empty" + missing=1 + fi + + # Secret: check presence ONLY, never print or echo + if [[ -z "${TEMPORAL_API_KEY:-}" ]]; then + echo "::error::TEMPORAL_API_KEY is not set or is empty" + missing=1 + fi + + if [[ "$missing" -ne 0 ]]; then + exit 1 + fi + + - name: Run unit tests + run: | + mise run test + + - name: Run integration tests + run: | + mise run integration diff --git a/Makefile b/Makefile index 2c1ac64..f9af4d8 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ -.PHONY: all gen build +.PHONY: all gen build test -all: gen build +include .env +export $(shell sed 's/=.*//' .env) + +all: gen build test gen: go tool gen-commands -input ./temporalcloudcli/commands.yml -pkg temporalcloudcli > ./temporalcloudcli/commands.gen.go build: go build ./cmd/temporal-cloud + +test-integration: + go test -tags=integration ./... + +test: + go test ./... diff --git a/README.md b/README.md index 818db9a..5a2f388 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # cloud-cli CLI Plugin for Temporal Cloud + +## Testing +In order to run the tests, you need a temporal cloud api key. Create one in the [cloud dashboard](https://cloud.temporal.io/) and place it in a .env file at the root directory of the repo as follows: +``` +TEMPORAL_API_KEY= +TEMPORAL_CLOUD_SERVER= +TEMPORAL_ACCOUNT= +``` + +*NOTE* This will create and delete resources on the account. + +Then run with `mise run test` to run the tests. \ No newline at end of file diff --git a/mise.toml b/mise.toml index 63d7233..b057b1a 100644 --- a/mise.toml +++ b/mise.toml @@ -14,4 +14,5 @@ GOPRIVATE = "go.temporal.io/temporal,go.temporal.io/temporal-proto,github.com/te [tasks] build = "make build" gen = "make gen" -test = "make test" \ No newline at end of file +test = "make test" +integration = "make test-integration" diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 8605cb9..31627f9 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -78,7 +78,9 @@ func NewCloudCommand(cctx *CommandContext) *CloudCommand { s.Command.PersistentFlags().StringVar(&s.ConfigDir, "config-dir", "", "Directory path where CLI configuration files are stored, including authentication tokens and settings.") s.Command.PersistentFlags().BoolVar(&s.DisablePopUp, "disable-pop-up", false, "Prevent the CLI from opening a browser window during authentication. Useful for headless environments or when using alternative auth methods.") s.Command.PersistentFlags().StringVar(&s.ApiKey, "api-key", "", "API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines.") + cctx.BindFlagEnvVar(s.Command.PersistentFlags().Lookup("api-key"), "TEMPORAL_API_KEY") s.Command.PersistentFlags().StringVar(&s.Server, "server", "saas-api.tmprl-test.cloud:443", "Override the Temporal Cloud API server address. Used for connecting to non-production environments.") + cctx.BindFlagEnvVar(s.Command.PersistentFlags().Lookup("server"), "TEMPORAL_CLOUD_SERVER") s.Command.PersistentFlags().BoolVar(&s.AutoConfirm, "auto-confirm", false, "Automatically confirm prompts and actions that require user confirmation. Useful for scripting and automation.") s.initCommand(cctx) return &s diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 2a5a08d..7eb8007 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -118,29 +118,32 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error client := newNamespaceClient(withCloudClient(cloudClient)) // Step 4: Retrieve existing namespace - existing, err := client.getNamespace(cctx.Context, c.Namespace) - if err != nil { + var found bool + existing, err := client.getNamespaceByName(cctx.Context, spec.Name) + if err != nil && !isNotFoundErr(err) { return err + } else if err == nil { + found = true + } + + existingResourceVersion := "" + var existingSpec *namespace.NamespaceSpec + if found { + existingResourceVersion = existing.ResourceVersion + existingSpec = existing.Spec } - cctx.Printer.PrintDiff(existing.Spec, spec, printer.DiffOptions{ - Verbose: c.VerboseDiff, - }) // Step 5: Confirm apply if not forced - yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + err = promptApplyResource(cctx, existingSpec, spec, c.VerboseDiff) if err != nil { return err } - if !yes { - fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") - return nil - } // Step 5: Apply the namespace (create or update) // Use provided resource version, or use fetched version resourceVersion := c.ResourceVersion if resourceVersion == "" { - resourceVersion = existing.ResourceVersion + resourceVersion = existingResourceVersion } params := applyNamespaceParams{ @@ -188,6 +191,15 @@ func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) erro client := newNamespaceClient(withCloudClient(cloudClient)) + yes, err := cctx.promptYes("Delete (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + + if !yes { + return fmt.Errorf("Aborting delete.") + } + asyncOp, err := client.deleteNamespace(cctx.Context, deleteNamespaceParams{ namespace: c.Namespace, idempotent: c.Idempotent, @@ -197,6 +209,19 @@ func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) erro if err != nil { return err } + + if asyncOp == nil { + // deleted already (idempotent case) + result := struct { + Status string + Namespace string + }{ + Status: "deleted", + Namespace: c.Namespace, + } + return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) + } + // Handle async flag if c.Async { // Return immediately with the async operation @@ -235,4 +260,3 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error printer.StructuredOptions{}, ) } - diff --git a/temporalcloudcli/commands.namespace_test.go b/temporalcloudcli/commands.namespace_test.go new file mode 100644 index 0000000..58ecaf7 --- /dev/null +++ b/temporalcloudcli/commands.namespace_test.go @@ -0,0 +1,187 @@ +//go:build integration +// +build integration + +package temporalcloudcli_test + +import ( + "fmt" + "io" + "strings" + + "go.temporal.io/api/temporalproto" + "go.temporal.io/cloud-sdk/api/cloudservice/v1" + namespace "go.temporal.io/cloud-sdk/api/namespace/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +const ( + e2eNamespacePrefix = "e2e-namespace" +) + +func (s *SharedServerSuite) generateRandomNamespaceName() string { + return fmt.Sprintf("%s-%s", e2eNamespacePrefix, s.generateRandomID()) +} + +func (s *SharedServerSuite) TestBasicNamespaceOperations() { + s.cleanupNamespaces() + s.testnamespaceCRUD() + s.cleanupNamespaces() +} + +func (s *SharedServerSuite) testnamespaceCRUD() { + // create a new namespace + newNamespaceName := s.generateRandomNamespaceName() + + namespaceSpec := &namespace.NamespaceSpec{ + Name: newNamespaceName, + Regions: []string{"aws-us-east-1"}, + RetentionDays: 30, + ApiKeyAuth: &namespace.ApiKeyAuthSpec{ + Enabled: true, + }, + SearchAttributes: map[string]namespace.NamespaceSpec_SearchAttributeType{ + "test": namespace.NamespaceSpec_SEARCH_ATTRIBUTE_TYPE_KEYWORD, + }, + Lifecycle: &namespace.LifecycleSpec{}, + } + + buf, err := temporalproto.CustomJSONMarshalOptions{}.Marshal(namespaceSpec) + s.Suite.Require().NoError(err) + + res := s.Execute( + "namespace", "apply", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "--auto-confirm=true", + "--spec", fmt.Sprintf(`%s`, string(buf)), + ) + + s.Suite.Require().NoError(res.Err) + + // get the namespace + res = s.Execute( + "namespace", "get", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "-o=json", + ) + s.Suite.Require().NoError(err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + readNamespace := &namespace.Namespace{} + err = protojson.Unmarshal(buf, readNamespace) + s.Suite.Require().NoError(err) + + // compare it to the inputted spec + s.Suite.Require().NotNil(readNamespace) + s.Suite.Require().NotNil(readNamespace.Spec) + s.Suite.Equal(namespaceSpec.Name, readNamespace.Spec.Name) + s.Suite.Equal(namespaceSpec.Regions, readNamespace.Spec.Regions) + s.Suite.Equal(namespaceSpec.SearchAttributes, readNamespace.Spec.SearchAttributes) + s.Suite.Equal(namespaceSpec.RetentionDays, readNamespace.Spec.RetentionDays) + + // get the namespace via listing + res = s.Execute( + "namespace", "list", + "-o=json", + ) + s.Suite.Require().NoError(err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + // simply assert that the list contains the namespace + s.Suite.Contains(string(buf), newNamespaceName) + + // update the namespace + namespaceSpec.RetentionDays-- + + buf, err = temporalproto.CustomJSONMarshalOptions{}.Marshal(namespaceSpec) + s.Suite.Require().NoError(err) + + res = s.Execute( + "namespace", "apply", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "--auto-confirm=true", + "--spec", fmt.Sprintf(`%s`, string(buf)), + ) + s.Suite.Require().NoError(res.Err) + + // get the namespace (after updating) + res = s.Execute( + "namespace", "get", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "-o=json", + ) + s.Suite.Require().NoError(err) + + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + fmt.Println(string(buf)) + + readNamespace = &namespace.Namespace{} + err = protojson.Unmarshal(buf, readNamespace) + s.Suite.Require().NoError(err) + + // compare it to the inputted spec + s.Suite.Require().NotNil(readNamespace) + s.Suite.Require().NotNil(readNamespace.Spec) + s.Suite.Equal(namespaceSpec.Name, readNamespace.Spec.Name) + s.Suite.Equal(namespaceSpec.Regions, readNamespace.Spec.Regions) + s.Suite.Equal(namespaceSpec.SearchAttributes, readNamespace.Spec.SearchAttributes) + s.Suite.Equal(namespaceSpec.RetentionDays, readNamespace.Spec.RetentionDays) + + // delete the namespace + res = s.Execute( + "namespace", "delete", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "--idempotent", "--auto-confirm=true", + ) + s.Suite.Require().NoError(err) + + // try to get the namespace + res = s.Execute( + "namespace", "get", + "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + ) + + s.Suite.Require().NoError(err) + // should say not found + stdOut, err := io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + + s.Suite.NotContains(strings.ToLower(string(stdOut)), newNamespaceName) +} + +func (s *SharedServerSuite) cleanupNamespaces() { + cloudClient := s.getCloudClient() + + pageToken := "" + namespacesToClean := []*namespace.Namespace{} + for { + res, err := cloudClient.CloudService().GetNamespaces(s.Context, &cloudservice.GetNamespacesRequest{ + PageToken: pageToken, + }) + s.Suite.Require().NoError(err) + for _, ns := range res.Namespaces { + if strings.HasPrefix(ns.Namespace, e2eNamespacePrefix) { + namespacesToClean = append(namespacesToClean, ns) + } + } + if res.NextPageToken == "" { + break + } + pageToken = res.NextPageToken + } + for _, ns := range namespacesToClean { + res, err := cloudClient.CloudService().DeleteNamespace(s.Context, &cloudservice.DeleteNamespaceRequest{ + ResourceVersion: ns.ResourceVersion, + Namespace: ns.Namespace, + }) + s.Suite.NoError(err) + if err == nil { + pollErr := s.pollAsyncOperation(cloudClient, res.AsyncOperation.Id) + s.Suite.NoError(pollErr) + } + } +} diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 252478b..c37be99 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -124,11 +124,13 @@ commands: Useful for headless environments or when using alternative auth methods. - name: api-key type: string + env: TEMPORAL_API_KEY description: | API key for authenticating with Temporal Cloud. Can be used instead of interactive login for automation and CI/CD pipelines. - name: server type: string + env: TEMPORAL_CLOUD_SERVER description: | Override the Temporal Cloud API server address. Used for connecting to non-production environments. diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go index c2f966f..e72db84 100644 --- a/temporalcloudcli/commands_test.go +++ b/temporalcloudcli/commands_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math/rand" "os" "regexp" "strings" @@ -13,6 +14,9 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/temporalio/cloud-cli/temporalcloudcli" + "go.temporal.io/cloud-sdk/api/cloudservice/v1" + operation "go.temporal.io/cloud-sdk/api/operation/v1" + "go.temporal.io/cloud-sdk/cloudclient" ) type CommandHarness struct { @@ -167,12 +171,18 @@ type SharedServerSuite struct { Suite suite.Suite - apiKey string + apiKey string + server string + testAccount string } func (s *SharedServerSuite) SetupSuite() { - s.apiKey = os.Getenv("TEMPORAL_CLOUD_API_KEY") - s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_CLOUD_API_KEY are you running with `mise run test` and have you filled out your .env? See README.md for details.") + s.apiKey = os.Getenv("TEMPORAL_API_KEY") + s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_API_KEY. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") + s.server = os.Getenv("TEMPORAL_CLOUD_SERVER") + s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_CLOUD_SERVER. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") + s.testAccount = os.Getenv("TEMPORAL_ACCOUNT") + s.Suite.Require().NotEmpty(s.testAccount, "Could not load TEMPORAL_ACCOUNT. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") } func (s *SharedServerSuite) TearDownSuite() { @@ -193,3 +203,68 @@ func (s *SharedServerSuite) TearDownTest() { func (s *SharedServerSuite) T() *testing.T { return s.Suite.T() } func (s *SharedServerSuite) SetT(t *testing.T) { s.Suite.SetT(t) } func (s *SharedServerSuite) SetS(suite suite.TestingSuite) { s.Suite.SetS(suite) } + +func (s *SharedServerSuite) generateRandomID() string { + letters := "abcdefghijklmnopqrstuvwxyz123456789" + b := make([]byte, 10) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func (s *SharedServerSuite) getCloudClient() *cloudclient.Client { + opts := cloudclient.Options{ + APIKey: s.apiKey, + HostPort: s.server, + } + + cloudClient, err := cloudclient.New(opts) + s.Suite.Require().NoError(err) + return cloudClient +} + +// pollAsyncOperation polls an async operation until it reaches a terminal state. +// It prints status updates every second and returns the final AsyncOperation. +func (s *SharedServerSuite) pollAsyncOperation( + cloudClient *cloudclient.Client, + operationID string, +) error { + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-s.Context.Done(): + return fmt.Errorf("operation polling cancelled: %w", s.Context.Err()) + case <-ticker.C: + // Get the current state of the operation + resp, err := cloudClient.CloudService().GetAsyncOperation(s.Context, &cloudservice.GetAsyncOperationRequest{ + AsyncOperationId: operationID, + }) + if err != nil { + return fmt.Errorf("failed to get async operation status: %w", err) + } + + asyncOp := resp.GetAsyncOperation() + if asyncOp == nil { + return fmt.Errorf("async operation not found") + } + + // Print current state + switch asyncOp.State { + case operation.AsyncOperation_STATE_PENDING, operation.AsyncOperation_STATE_IN_PROGRESS: + case operation.AsyncOperation_STATE_FULFILLED: + return nil + case operation.AsyncOperation_STATE_FAILED: + return fmt.Errorf("async operation failed: %s", asyncOp.FailureReason) + case operation.AsyncOperation_STATE_CANCELLED: + return fmt.Errorf("async operation cancelled") + case operation.AsyncOperation_STATE_REJECTED: + return fmt.Errorf("async operation rejected") + default: + } + } + } +} diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index cb8600f..07fa8b1 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -124,6 +124,22 @@ func runEditor(existing []byte) ([]byte, error) { return updated, nil } +func promptApplyResource(cctx *CommandContext, existing, actual proto.Message, verboseDiff bool) error { + cctx.Printer.PrintDiff(existing, actual, printer.DiffOptions{ + Verbose: verboseDiff, + }) + + yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + if err != nil { + return err + } + + if !yes { + return fmt.Errorf("Aborting apply.") + } + return nil +} + // pollAsyncOperation polls an async operation until it reaches a terminal state. // It prints status updates every second and returns the final AsyncOperation. func pollAsyncOperation( diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index cdcc4fa..ef0ace6 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -8,6 +8,8 @@ import ( namespace "go.temporal.io/cloud-sdk/api/namespace/v1" "go.temporal.io/cloud-sdk/api/operation/v1" "go.temporal.io/cloud-sdk/cloudclient" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type namespaceClient struct { @@ -116,9 +118,23 @@ type deleteNamespaceParams struct { } func (c *namespaceClient) deleteNamespace(ctx context.Context, params deleteNamespaceParams) (*operation.AsyncOperation, error) { + // get the namespace to get its resource version + ns, err := c.getNamespace(ctx, params.namespace) + if err != nil { + if isNotFoundErr(err) && params.idempotent { + return nil, nil + } + return nil, err + } + + if params.resourceVersion == "" { + params.resourceVersion = ns.ResourceVersion + } + res, err := c.client.CloudService().DeleteNamespace(ctx, &cloudservice.DeleteNamespaceRequest{ AsyncOperationId: params.asyncOperationID, Namespace: params.namespace, + ResourceVersion: params.resourceVersion, }) if err != nil { if isNotFoundErr(err) && params.idempotent { @@ -140,12 +156,16 @@ type applyNamespaceParams struct { } func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamespaceParams) (*operation.AsyncOperation, error) { + existing, err := c.getNamespaceByName(ctx, params.spec.Name) + if err != nil && !isNotFoundErr(err) { + return nil, err + } else if err != nil && isNotFoundErr(err) { + // create the namespace + return c.createNamespace(ctx, params.spec, params) + } + + // update the namespace if params.resourceVersion == "" { - // Try to get the existing namespace - existing, err := c.getNamespace(ctx, params.namespace) - if err != nil { - return nil, err - } params.resourceVersion = existing.ResourceVersion } @@ -162,3 +182,39 @@ func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamesp return c.updateNamespace(ctx, updateParams) } + +func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string) (*namespace.Namespace, error) { + namespaces, err := c.listNamespacesWithName(ctx, name, true) + if err != nil { + return nil, err + } else if len(namespaces) > 1 { + return nil, fmt.Errorf("multiple namespaces match namespace name: %s", name) + } else if len(namespaces) == 0 { + return nil, status.Errorf(codes.NotFound, "namespace not found") + } + return namespaces[0], nil +} + +func (c *namespaceClient) listNamespacesWithName(ctx context.Context, name string, shortCircuit bool) ([]*namespace.Namespace, error) { + namespaces := []*namespace.Namespace{} + pageToken := "" + for { + res, err := c.client.CloudService().GetNamespaces(ctx, &cloudservice.GetNamespacesRequest{ + Name: name, + PageToken: pageToken, + }) + if err != nil { + return nil, err + } + namespaces = append(namespaces, res.Namespaces...) + if shortCircuit { + return namespaces, nil + } + // Check if we should continue paging + pageToken = res.NextPageToken + if len(pageToken) == 0 { + break + } + } + return namespaces, nil +} From 24f812e76f2009d877a596f4b493f5bc704bba19 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 14 Jan 2026 12:27:18 -0500 Subject: [PATCH 05/12] fix env var name --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbe4a3f..0997bab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: environment: CICD env: - TEMPORAL_SERVER: ${{ vars.TEMPORAL_SERVER }} + TEMPORAL_CLOUD_SERVER: ${{ vars.TEMPORAL_CLOUD_SERVER }} TEMPORAL_ACCOUNT: ${{ vars.TEMPORAL_ACCOUNT }} TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_API_KEY }} @@ -39,8 +39,8 @@ jobs: missing=0 - if [[ -z "${TEMPORAL_SERVER:-}" ]]; then - echo "::error::TEMPORAL_SERVER is not set or is empty" + if [[ -z "${TEMPORAL_CLOUD_SERVER:-}" ]]; then + echo "::error::TEMPORAL_CLOUD_SERVER is not set or is empty" missing=1 fi From 7301f3df49fb1c9c24ec85e2f8f8b6dce7081ee4 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 14 Jan 2026 12:35:07 -0500 Subject: [PATCH 06/12] modify makefile to make .env not required --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f9af4d8..6e0197a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,11 @@ .PHONY: all gen build test -include .env +# Load .env file if it exists (for local development) +# In CI/CD, environment variables are provided by the environment +-include .env +ifneq (,$(wildcard .env)) export $(shell sed 's/=.*//' .env) +endif all: gen build test From 2974b5c25696cb1c2d0cd416ab28233ff0efd7f5 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 14 Jan 2026 12:47:11 -0500 Subject: [PATCH 07/12] remove environment from cicd --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0997bab..b2f2a9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,6 @@ jobs: name: Unit + Integration (mise) runs-on: ubuntu-latest - # GitHub Environment - environment: CICD - env: TEMPORAL_CLOUD_SERVER: ${{ vars.TEMPORAL_CLOUD_SERVER }} TEMPORAL_ACCOUNT: ${{ vars.TEMPORAL_ACCOUNT }} From aa87efd69a9b93abcc38db9ab5050968de56a7ab Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Wed, 14 Jan 2026 13:15:13 -0500 Subject: [PATCH 08/12] add build tags to commands_test --- temporalcloudcli/commands_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go index e72db84..9a9f0c0 100644 --- a/temporalcloudcli/commands_test.go +++ b/temporalcloudcli/commands_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + package temporalcloudcli_test import ( From 2c9a6980be9ecbc3753fea75231aa72e38e86ba9 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Fri, 16 Jan 2026 13:55:08 -0500 Subject: [PATCH 09/12] fix json printing, add consistency across the board --- README.md | 1 - temporalcloudcli/commands.gen.go | 9 +-- temporalcloudcli/commands.namespace.go | 43 ++++++++------- .../commands.namespace.lifecycle.go | 22 +++----- .../commands.namespace.retention.go | 21 +++---- temporalcloudcli/commands.namespace_test.go | 25 ++++++--- temporalcloudcli/commands.yml | 10 ---- temporalcloudcli/commands_test.go | 12 ++-- temporalcloudcli/common.go | 52 +++++++++++++----- temporalcloudcli/namespace.go | 55 +++++++++++++++---- 10 files changed, 150 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 5a2f388..2c6501b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ In order to run the tests, you need a temporal cloud api key. Create one in the ``` TEMPORAL_API_KEY= TEMPORAL_CLOUD_SERVER= -TEMPORAL_ACCOUNT= ``` *NOTE* This will create and delete resources on the account. diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 31627f9..0bb9959 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -173,7 +173,6 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud type CloudNamespaceApplyCommand struct { Parent *CloudNamespaceCommand Command cobra.Command - Namespace string Spec string AsyncOperationId string Idempotent bool @@ -194,8 +193,6 @@ func NewCloudNamespaceApplyCommand(cctx *CommandContext, parent *CloudNamespaceC s.Command.Long = "Apply a namespace configuration to Temporal Cloud. Creates a new namespace\nif it doesn't exist, or updates an existing one to match the specification.\n\nThe specification can be provided as inline JSON or loaded from a file\nby prefixing the path with '@'.\n\nExample with inline JSON:\n\n```\ncloud namespace apply --spec '{\"name\": \"namespace-name\", \"region\": \"us-west-2\", \"retention_days\": 7}'\n```\n\nExample with file path:\n\n```\ncloud namespace apply --spec @namespace-spec.json\n```" } s.Command.Args = cobra.NoArgs - s.Command.Flags().StringVarP(&s.Namespace, "namespace", "n", "", "The fully qualified namespace name in the format 'namespace.account' (e.g., 'my-namespace.my-account'). Required.") - _ = cobra.MarkFlagRequired(s.Command.Flags(), "namespace") s.Command.Flags().StringVar(&s.Spec, "spec", "", "Namespace configuration in JSON format. Provide inline JSON directly, or use '@path/to/file.json' to load from a file. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "spec") s.Command.Flags().StringVar(&s.AsyncOperationId, "async-operation-id", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") @@ -388,7 +385,7 @@ func NewCloudNamespaceLifecycleSetCommand(cctx *CommandContext, parent *CloudNam s.Command.Flags().StringVar(&s.AsyncOperationId, "async-operation-id", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") s.Command.Flags().BoolVar(&s.Async, "async", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") s.Command.Flags().BoolVar(&s.Idempotent, "idempotent", false, "Succeed silently if the lifecycle configuration is already set to the specified value. Without this flag, the command errors when no change is needed.") - s.Command.Flags().StringVarP(&s.ResourceVersion, "resource-version", "v", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.Command.Flags().StringVar(&s.ResourceVersion, "resource-version", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) @@ -501,9 +498,9 @@ func NewCloudNamespaceRetentionSetCommand(cctx *CommandContext, parent *CloudNam s.Command.Flags().StringVar(&s.AsyncOperationId, "async-operation-id", "", "Custom identifier for tracking this async operation. If not provided, a unique ID is generated automatically.") s.Command.Flags().BoolVar(&s.Async, "async", false, "Return immediately after initiating the operation instead of waiting for completion. Use the returned operation ID to check status later.") s.Command.Flags().BoolVar(&s.Idempotent, "idempotent", false, "Succeed silently if the retention period is already set to the specified value. Without this flag, the command errors when no change is needed.") - s.Command.Flags().IntVarP(&s.RetentionDays, "retention-days", "r", 0, "New retention period in days for closed workflow history data. Required.") + s.Command.Flags().IntVar(&s.RetentionDays, "retention-days", 0, "New retention period in days for closed workflow history data. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "retention-days") - s.Command.Flags().StringVarP(&s.ResourceVersion, "resource-version", "v", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") + s.Command.Flags().StringVar(&s.ResourceVersion, "resource-version", "", "Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically.") s.Command.Run = func(c *cobra.Command, args []string) { if err := s.run(cctx, args); err != nil { cctx.Options.Fail(err) diff --git a/temporalcloudcli/commands.namespace.go b/temporalcloudcli/commands.namespace.go index 7eb8007..2e8fb5d 100644 --- a/temporalcloudcli/commands.namespace.go +++ b/temporalcloudcli/commands.namespace.go @@ -46,16 +46,10 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error return err } - cctx.Printer.PrintDiff(ns.Spec, newSpec, printer.DiffOptions{}) - // Step 5: Confirm apply if not forced - yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + err = promptApplyResource(cctx, ns.Spec, newSpec, cctx.RootCommand.AutoConfirm) if err != nil { return err } - if !yes { - fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") - return nil - } // Use provided resource version, or fall back to the fetched namespace's resource version. resourceVersion := c.ResourceVersion @@ -63,7 +57,7 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error resourceVersion = ns.ResourceVersion } - asyncOp, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ + res, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ namespace: c.Namespace, spec: newSpec, asyncOperationID: c.AsyncOperationId, @@ -75,7 +69,7 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error } // TODO: (gmankes) remove this -- clean up and make shareable - if asyncOp == nil { + if res.asyncOp == nil { // Nothing changed (idempotent case) result := struct { Status string @@ -90,11 +84,14 @@ func (c *CloudNamespaceEditCommand) run(cctx *CommandContext, _ []string) error // Handle async flag if c.Async { // Return immediately with the async operation - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: res.asyncOp, + ID: res.Namespace, + }, printer.StructuredOptions{}) } // Poll for completion - return pollAsyncOperation(cctx, asyncOp.Id) + return pollAsyncOperation(cctx, res.asyncOp.Id, res.Namespace) } func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error { @@ -128,9 +125,11 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error existingResourceVersion := "" var existingSpec *namespace.NamespaceSpec + existingNamespaceIdentifier := "" if found { existingResourceVersion = existing.ResourceVersion existingSpec = existing.Spec + existingNamespaceIdentifier = existing.Namespace } // Step 5: Confirm apply if not forced @@ -147,7 +146,7 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error } params := applyNamespaceParams{ - namespace: c.Namespace, + namespace: existingNamespaceIdentifier, spec: spec, resourceVersion: resourceVersion, @@ -155,20 +154,20 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error idempotent: c.Idempotent, // Use the flag value } - asyncOp, err := client.applyNamespace(cctx.Context, params) + res, err := client.applyNamespace(cctx.Context, params) if err != nil { return fmt.Errorf("failed to apply namespace: %w", err) } // Step 5: Handle result - if asyncOp == nil { + if res.asyncOp == nil { // Nothing changed (idempotent case) result := struct { Status string Namespace string }{ Status: "unchanged", - Namespace: c.Namespace, + Namespace: existingNamespaceIdentifier, } return cctx.Printer.PrintStructured(result, printer.StructuredOptions{}) } @@ -176,11 +175,14 @@ func (c *CloudNamespaceApplyCommand) run(cctx *CommandContext, _ []string) error // Step 6: Handle async flag if c.Async { // Return immediately with the async operation - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: res.asyncOp, + ID: res.Namespace, + }, printer.StructuredOptions{}) } // Step 7: Poll for completion - return pollAsyncOperation(cctx, asyncOp.Id) + return pollAsyncOperation(cctx, res.asyncOp.Id, res.Namespace) } func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) error { @@ -225,11 +227,14 @@ func (c *CloudNamespaceDeleteCommand) run(cctx *CommandContext, _ []string) erro // Handle async flag if c.Async { // Return immediately with the async operation - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: asyncOp, + ID: c.Namespace, + }, printer.StructuredOptions{}) } // Poll for completion - return pollAsyncOperation(cctx, asyncOp.Id) + return pollAsyncOperation(cctx, asyncOp.Id, c.Namespace) } func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error { diff --git a/temporalcloudcli/commands.namespace.lifecycle.go b/temporalcloudcli/commands.namespace.lifecycle.go index b01b05a..1a7d112 100644 --- a/temporalcloudcli/commands.namespace.lifecycle.go +++ b/temporalcloudcli/commands.namespace.lifecycle.go @@ -1,8 +1,6 @@ package temporalcloudcli import ( - "fmt" - namespace "go.temporal.io/cloud-sdk/api/namespace/v1" "google.golang.org/protobuf/proto" @@ -63,17 +61,10 @@ func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string newSpec.Lifecycle.EnableDeleteProtection = c.EnableDeleteProtection // Show diff - cctx.Printer.PrintDiff(ns.Spec, newSpec, printer.DiffOptions{}) - - // Confirm apply - yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + err = promptApplyResource(cctx, ns.Spec, newSpec, cctx.RootCommand.AutoConfirm) if err != nil { return err } - if !yes { - fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") - return nil - } // Use provided resource version, or fetch from current namespace resourceVersion := c.ResourceVersion @@ -81,7 +72,7 @@ func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string resourceVersion = ns.ResourceVersion } - asyncOp, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ + res, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ namespace: c.Namespace, spec: newSpec, asyncOperationID: c.AsyncOperationId, @@ -92,7 +83,7 @@ func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string return err } - if asyncOp == nil { + if res.asyncOp == nil { // Nothing changed (idempotent case) result := struct { Status string @@ -107,9 +98,12 @@ func (c *CloudNamespaceLifecycleSetCommand) run(cctx *CommandContext, _ []string // Handle async flag if c.Async { // Return immediately with the async operation - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: res.asyncOp, + ID: res.Namespace, + }, printer.StructuredOptions{}) } // Poll for completion - return pollAsyncOperation(cctx, asyncOp.Id) + return pollAsyncOperation(cctx, res.asyncOp.Id, res.Namespace) } diff --git a/temporalcloudcli/commands.namespace.retention.go b/temporalcloudcli/commands.namespace.retention.go index 2910bed..f87489b 100644 --- a/temporalcloudcli/commands.namespace.retention.go +++ b/temporalcloudcli/commands.namespace.retention.go @@ -1,8 +1,6 @@ package temporalcloudcli import ( - "fmt" - namespace "go.temporal.io/cloud-sdk/api/namespace/v1" "google.golang.org/protobuf/proto" @@ -25,16 +23,10 @@ func (c *CloudNamespaceRetentionSetCommand) run(cctx *CommandContext, _ []string newSpec := proto.Clone(ns.Spec).(*namespace.NamespaceSpec) newSpec.RetentionDays = int32(c.RetentionDays) - cctx.Printer.PrintDiff(ns.Spec, newSpec, printer.DiffOptions{}) - // Confirm apply if not forced - yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) + err = promptApplyResource(cctx, ns.Spec, newSpec, cctx.RootCommand.AutoConfirm) if err != nil { return err } - if !yes { - fmt.Fprintln(cctx.Printer.Output, "Aborting apply.") - return nil - } // Use provided resource version, or fetch from current namespace resourceVersion := c.ResourceVersion @@ -42,7 +34,7 @@ func (c *CloudNamespaceRetentionSetCommand) run(cctx *CommandContext, _ []string resourceVersion = ns.ResourceVersion } - asyncOp, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ + res, err := client.applyNamespace(cctx.Context, applyNamespaceParams{ namespace: c.Namespace, spec: newSpec, asyncOperationID: c.AsyncOperationId, @@ -52,7 +44,7 @@ func (c *CloudNamespaceRetentionSetCommand) run(cctx *CommandContext, _ []string if err != nil { return err } - if asyncOp == nil { + if res.asyncOp == nil { // Nothing changed (idempotent case) result := struct { Status string @@ -67,11 +59,14 @@ func (c *CloudNamespaceRetentionSetCommand) run(cctx *CommandContext, _ []string // Handle async flag if c.Async { // Return immediately with the async operation - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + return cctx.Printer.PrintStructured(MutationResult{ + AsyncOp: res.asyncOp, + ID: res.Namespace, + }, printer.StructuredOptions{}) } // Poll for completion - return pollAsyncOperation(cctx, asyncOp.Id) + return pollAsyncOperation(cctx, res.asyncOp.Id, res.Namespace) } func (c *CloudNamespaceRetentionGetCommand) run(cctx *CommandContext, _ []string) error { diff --git a/temporalcloudcli/commands.namespace_test.go b/temporalcloudcli/commands.namespace_test.go index 58ecaf7..633ed16 100644 --- a/temporalcloudcli/commands.namespace_test.go +++ b/temporalcloudcli/commands.namespace_test.go @@ -4,10 +4,12 @@ package temporalcloudcli_test import ( + "encoding/json" "fmt" "io" "strings" + "github.com/temporalio/cloud-cli/temporalcloudcli" "go.temporal.io/api/temporalproto" "go.temporal.io/cloud-sdk/api/cloudservice/v1" namespace "go.temporal.io/cloud-sdk/api/namespace/v1" @@ -50,17 +52,24 @@ func (s *SharedServerSuite) testnamespaceCRUD() { res := s.Execute( "namespace", "apply", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), "--auto-confirm=true", "--spec", fmt.Sprintf(`%s`, string(buf)), + "-o=json", ) s.Suite.Require().NoError(res.Err) + buf, err = io.ReadAll(&res.Stdout) + s.Suite.Require().NoError(err) + result := &temporalcloudcli.MutationResult{} + err = json.Unmarshal(buf, result) + s.Suite.Require().NoError(err) + + namespaceID := result.ID // get the namespace res = s.Execute( "namespace", "get", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "-n", namespaceID, "-o=json", ) s.Suite.Require().NoError(err) @@ -101,16 +110,16 @@ func (s *SharedServerSuite) testnamespaceCRUD() { res = s.Execute( "namespace", "apply", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), "--auto-confirm=true", "--spec", fmt.Sprintf(`%s`, string(buf)), + "-o=json", ) s.Suite.Require().NoError(res.Err) // get the namespace (after updating) res = s.Execute( "namespace", "get", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "-n", namespaceID, "-o=json", ) s.Suite.Require().NoError(err) @@ -134,15 +143,17 @@ func (s *SharedServerSuite) testnamespaceCRUD() { // delete the namespace res = s.Execute( "namespace", "delete", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), - "--idempotent", "--auto-confirm=true", + "-n", namespaceID, + "--idempotent", + "--auto-confirm=true", + "-o=json", ) s.Suite.Require().NoError(err) // try to get the namespace res = s.Execute( "namespace", "get", - "-n", fmt.Sprintf("%s.%s", newNamespaceName, s.testAccount), + "-n", namespaceID, ) s.Suite.Require().NoError(err) diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index c37be99..40b1334 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -294,13 +294,6 @@ commands: ``` has-init: false options: - - name: namespace - type: string - description: | - The fully qualified namespace name in the format 'namespace.account' - (e.g., 'my-namespace.my-account'). - short: n - required: true - name: spec type: string description: | @@ -513,11 +506,9 @@ commands: type: int description: | New retention period in days for closed workflow history data. - short: r required: true - name: resource-version type: string - short: v description: | Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. @@ -593,7 +584,6 @@ commands: is needed. - name: resource-version type: string - short: v description: | Resource version for optimistic concurrency control. If not provided, the current version is fetched automatically. diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go index 9a9f0c0..8e40585 100644 --- a/temporalcloudcli/commands_test.go +++ b/temporalcloudcli/commands_test.go @@ -174,9 +174,8 @@ type SharedServerSuite struct { Suite suite.Suite - apiKey string - server string - testAccount string + apiKey string + server string } func (s *SharedServerSuite) SetupSuite() { @@ -184,8 +183,6 @@ func (s *SharedServerSuite) SetupSuite() { s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_API_KEY. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") s.server = os.Getenv("TEMPORAL_CLOUD_SERVER") s.Suite.Require().NotEmpty(s.apiKey, "Could not load TEMPORAL_CLOUD_SERVER. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") - s.testAccount = os.Getenv("TEMPORAL_ACCOUNT") - s.Suite.Require().NotEmpty(s.testAccount, "Could not load TEMPORAL_ACCOUNT. Are you running with `mise run test` and have you filled out your .env? See README.md for details.") } func (s *SharedServerSuite) TearDownSuite() { @@ -271,3 +268,8 @@ func (s *SharedServerSuite) pollAsyncOperation( } } } + +type mutationResult struct { + asyncOp *operation.AsyncOperation + ID string +} diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index 07fa8b1..5add511 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -125,9 +125,11 @@ func runEditor(existing []byte) ([]byte, error) { } func promptApplyResource(cctx *CommandContext, existing, actual proto.Message, verboseDiff bool) error { - cctx.Printer.PrintDiff(existing, actual, printer.DiffOptions{ - Verbose: verboseDiff, - }) + if !cctx.JSONOutput { + cctx.Printer.PrintDiff(existing, actual, printer.DiffOptions{ + Verbose: verboseDiff, + }) + } yes, err := cctx.promptYes("Apply (y/yes)?", cctx.RootCommand.AutoConfirm) if err != nil { @@ -145,6 +147,7 @@ func promptApplyResource(cctx *CommandContext, existing, actual proto.Message, v func pollAsyncOperation( cctx *CommandContext, operationID string, + id string, ) error { cloudClient, err := newCloudClient(cctx) if err != nil { @@ -173,26 +176,47 @@ func pollAsyncOperation( } // Print current state + var progressString string switch asyncOp.State { case operation.AsyncOperation_STATE_PENDING: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) case operation.AsyncOperation_STATE_IN_PROGRESS: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation in progress...\n", time.Now().Format("15:04:05")) + progressString = fmt.Sprintf("[%s] Operation in progress...\n", time.Now().Format("15:04:05")) case operation.AsyncOperation_STATE_FULFILLED: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + progressString = fmt.Sprintf("[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}) case operation.AsyncOperation_STATE_FAILED: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + progressString = fmt.Sprintf("[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) + return cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}) case operation.AsyncOperation_STATE_CANCELLED: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation cancelled\n", time.Now().Format("15:04:05")) - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + progressString = fmt.Sprintf("[%s] Operation cancelled\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}) case operation.AsyncOperation_STATE_REJECTED: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation rejected\n", time.Now().Format("15:04:05")) - return cctx.Printer.PrintStructured(asyncOp, printer.StructuredOptions{}) + progressString = fmt.Sprintf("[%s] Operation rejected\n", time.Now().Format("15:04:05")) + return cctx.Printer.PrintStructured(MutationResult{ + ID: id, + AsyncOp: asyncOp, + }, printer.StructuredOptions{}) default: - fmt.Fprintf(cctx.Printer.Output, "[%s] Operation pending...\n", time.Now().Format("15:04:05")) + progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) + } + if !cctx.JSONOutput { + cctx.Printer.Print(progressString) } } } } + +type MutationResult struct { + AsyncOp *operation.AsyncOperation `json:"asyncOperation"` + ID string `json:"id"` +} diff --git a/temporalcloudcli/namespace.go b/temporalcloudcli/namespace.go index ef0ace6..8d671c0 100644 --- a/temporalcloudcli/namespace.go +++ b/temporalcloudcli/namespace.go @@ -94,19 +94,30 @@ func (c *namespaceClient) updateNamespace(ctx context.Context, params updateName return res.AsyncOperation, nil } -func (c *namespaceClient) createNamespace(ctx context.Context, n *namespace.NamespaceSpec, params applyNamespaceParams) (*operation.AsyncOperation, error) { +type createNamespaceParams struct { + spec *namespace.NamespaceSpec + + asyncOperationID string +} + +type createNamespaceResponse struct { + asyncOp *operation.AsyncOperation + Namespace string +} + +func (c *namespaceClient) createNamespace(ctx context.Context, params createNamespaceParams) (createNamespaceResponse, error) { res, err := c.client.CloudService().CreateNamespace(ctx, &cloudservice.CreateNamespaceRequest{ AsyncOperationId: params.asyncOperationID, - Spec: n, + Spec: params.spec, }) if err != nil { - if isNothingChangedErr(params.idempotent, err) { - return nil, nil - } - return nil, err + return createNamespaceResponse{}, err } - return res.AsyncOperation, nil + return createNamespaceResponse{ + asyncOp: res.GetAsyncOperation(), + Namespace: res.GetNamespace(), + }, nil } type deleteNamespaceParams struct { @@ -155,13 +166,28 @@ type applyNamespaceParams struct { idempotent bool } -func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamespaceParams) (*operation.AsyncOperation, error) { +type applyNamespaceResponse struct { + asyncOp *operation.AsyncOperation + Namespace string +} + +func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamespaceParams) (applyNamespaceResponse, error) { existing, err := c.getNamespaceByName(ctx, params.spec.Name) if err != nil && !isNotFoundErr(err) { - return nil, err + return applyNamespaceResponse{}, err } else if err != nil && isNotFoundErr(err) { // create the namespace - return c.createNamespace(ctx, params.spec, params) + res, err := c.createNamespace(ctx, createNamespaceParams{ + spec: params.spec, + asyncOperationID: params.asyncOperationID, + }) + if err != nil { + return applyNamespaceResponse{}, err + } + return applyNamespaceResponse{ + asyncOp: res.asyncOp, + Namespace: res.Namespace, + }, nil } // update the namespace @@ -180,7 +206,14 @@ func (c *namespaceClient) applyNamespace(ctx context.Context, params applyNamesp resourceVersion: params.resourceVersion, } - return c.updateNamespace(ctx, updateParams) + res, err := c.updateNamespace(ctx, updateParams) + if err != nil { + return applyNamespaceResponse{}, err + } + return applyNamespaceResponse{ + asyncOp: res, + Namespace: existing.Namespace, + }, nil } func (c *namespaceClient) getNamespaceByName(ctx context.Context, name string) (*namespace.Namespace, error) { From 710368634b410fdeabf07832f4280f4a1623fd9d Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Sat, 17 Jan 2026 08:44:35 -0500 Subject: [PATCH 10/12] Update test.yml Co-authored-by: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2f2a9f..11c3e1a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,6 @@ jobs: env: TEMPORAL_CLOUD_SERVER: ${{ vars.TEMPORAL_CLOUD_SERVER }} - TEMPORAL_ACCOUNT: ${{ vars.TEMPORAL_ACCOUNT }} TEMPORAL_API_KEY: ${{ secrets.TEMPORAL_API_KEY }} steps: From 09e5c18cf604fe444a44424528ddd81e6203bc01 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Sat, 17 Jan 2026 08:44:49 -0500 Subject: [PATCH 11/12] Update test.yml Co-authored-by: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> --- .github/workflows/test.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11c3e1a..6790412 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,10 +40,6 @@ jobs: missing=1 fi - if [[ -z "${TEMPORAL_ACCOUNT:-}" ]]; then - echo "::error::TEMPORAL_ACCOUNT is not set or is empty" - missing=1 - fi # Secret: check presence ONLY, never print or echo if [[ -z "${TEMPORAL_API_KEY:-}" ]]; then From 955167f140b72f640f8c015688ced6608088b320 Mon Sep 17 00:00:00 2001 From: Gregory Mankes Date: Tue, 20 Jan 2026 09:57:37 -0500 Subject: [PATCH 12/12] Update temporalcloudcli/commands.namespace_test.go Co-authored-by: Abhinav Nekkanti <10552725+anekkanti@users.noreply.github.com> --- temporalcloudcli/commands.namespace_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/temporalcloudcli/commands.namespace_test.go b/temporalcloudcli/commands.namespace_test.go index 633ed16..b9a9eaf 100644 --- a/temporalcloudcli/commands.namespace_test.go +++ b/temporalcloudcli/commands.namespace_test.go @@ -92,6 +92,7 @@ func (s *SharedServerSuite) testnamespaceCRUD() { // get the namespace via listing res = s.Execute( "namespace", "list", + "--name", newNamespaceName, "-o=json", ) s.Suite.Require().NoError(err)