Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: test

on:
push:
branches:
- develop
- main
pull_request:

permissions:
contents: read

jobs:
test:
name: Unit + Integration (mise)
runs-on: ubuntu-latest

env:
TEMPORAL_CLOUD_SERVER: ${{ vars.TEMPORAL_CLOUD_SERVER }}
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_CLOUD_SERVER:-}" ]]; then
echo "::error::TEMPORAL_CLOUD_SERVER 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
17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
.PHONY: all gen build
.PHONY: all gen build test

all: gen build
# 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

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 ./...
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
# 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=<api key>
TEMPORAL_CLOUD_SERVER=<server>
```

*NOTE* This will create and delete resources on the account.

Then run with `mise run test` to run the tests.
18 changes: 18 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[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"
integration = "make test-integration"
2 changes: 1 addition & 1 deletion temporalcloudcli/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 5 additions & 6 deletions temporalcloudcli/commands.gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,7 +173,6 @@ func NewCloudNamespaceCommand(cctx *CommandContext, parent *CloudCommand) *Cloud
type CloudNamespaceApplyCommand struct {
Parent *CloudNamespaceCommand
Command cobra.Command
Namespace string
Comment thread
gregmankes marked this conversation as resolved.
Spec string
AsyncOperationId string
Idempotent bool
Expand All @@ -192,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.")
Expand Down Expand Up @@ -386,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)
Expand Down Expand Up @@ -499,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)
Expand Down
91 changes: 60 additions & 31 deletions temporalcloudcli/commands.namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,18 @@ 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
if resourceVersion == "" {
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,
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -118,66 +115,74 @@ 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
existingNamespaceIdentifier := ""
if found {
existingResourceVersion = existing.ResourceVersion
existingSpec = existing.Spec
existingNamespaceIdentifier = existing.Namespace
}
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{
namespace: c.Namespace,
namespace: existingNamespaceIdentifier,
spec: spec,

resourceVersion: resourceVersion,
asyncOperationID: c.AsyncOperationId, // Use the flag value if provided
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{})
}

// 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 {
Expand All @@ -188,6 +193,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,
Expand All @@ -197,14 +211,30 @@ 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
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 {
Expand Down Expand Up @@ -235,4 +265,3 @@ func (c *CloudNamespaceListCommand) run(cctx *CommandContext, _ []string) error
printer.StructuredOptions{},
)
}

Loading