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
48 changes: 46 additions & 2 deletions agent-skills/signadot-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: signadot-cli
description: Manage Signadot sandboxes, route groups, clusters, resource plugins, jobs, and smart tests using the signadot CLI. Use when a developer or platform engineer needs to create, update, inspect, or delete Signadot resources.
argument-hint: "[resource: sandbox|routegroup|cluster|job|resourceplugin|smart-test]"
description: Manage Signadot sandboxes, route groups, clusters, resource plugins, secrets, jobs, and smart tests using the signadot CLI. Use when a developer or platform engineer needs to create, update, inspect, or delete Signadot resources.
argument-hint: "[resource: sandbox|routegroup|cluster|job|resourceplugin|secret|smart-test]"
---

# Signadot CLI
Expand Down Expand Up @@ -272,6 +272,50 @@ signadot resourceplugin get my-plugin
signadot resourceplugin delete my-plugin
```

## Secrets (alias: secrets)

Org-level encrypted secrets. The plaintext value is **write-only** — `get`/`list` return metadata only (`name`, `description`, `createdAt`, `updatedAt`) and never expose the value.

```bash
# Create — value can come from a literal, a file, or stdin
signadot secret create my-db-password --value 'hunter2'
signadot secret create my-db-password --value-file ./password.txt
echo -n 'hunter2' | signadot secret create my-db-password --value-stdin

# Or from a flat YAML/JSON file with optional --set expansion
signadot secret create -f secret.yaml --set VALUE=hunter2

# Update — value is required (same flag set as create)
signadot secret update my-db-password --value 'newpass'

# Inspect (metadata only)
signadot secret get my-db-password
signadot secret list
signadot secret list -o json

# Delete
signadot secret delete my-db-password
signadot secret delete -f secret.yaml
```

Secret file shape (no `spec:` stanza):

```yaml
name: my-db-password
description: Prod DB password
value: '@{VALUE}'
```

### Binding secrets to plan params

Plan runs pull a secret value into a parameter via `--param-secret param-name=secret-name` (parallel to `--param`, repeatable):

```bash
signadot plan run my-plan --param-secret db_pass=my-db-password
```

A given param name must use either `--param` or `--param-secret`, not both.

## Job Management

Jobs run tests or tasks in the context of a sandbox.
Expand Down
2 changes: 2 additions & 0 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/signadot/cli/internal/command/resourceplugin"
"github.com/signadot/cli/internal/command/routegroup"
"github.com/signadot/cli/internal/command/sandbox"
"github.com/signadot/cli/internal/command/secret"
"github.com/signadot/cli/internal/command/smarttest"
"github.com/signadot/cli/internal/command/traffic"
"github.com/signadot/cli/internal/config"
Expand Down Expand Up @@ -61,6 +62,7 @@ func New() *cobra.Command {
smarttest.New(cfg),
traffic.New(cfg),
plan.New(cfg),
secret.New(cfg),

// hidden commands
hostedtest.New(cfg),
Expand Down
26 changes: 26 additions & 0 deletions internal/command/secret/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package secret

import (
"github.com/signadot/cli/internal/config"
"github.com/spf13/cobra"
)

func New(api *config.API) *cobra.Command {
cfg := &config.Secret{API: api}

cmd := &cobra.Command{
Use: "secret",
Short: "Manage org-level secrets",
Aliases: []string{"secrets"},
}

cmd.AddCommand(
newCreate(cfg),
newUpdate(cfg),
newGet(cfg),
newList(cfg),
newDelete(cfg),
)

return cmd
}
171 changes: 171 additions & 0 deletions internal/command/secret/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package secret

import (
"errors"
"fmt"
"io"
"os"

"github.com/signadot/cli/internal/config"
"github.com/signadot/cli/internal/print"
sdksecrets "github.com/signadot/go-sdk/client/secrets"
"github.com/signadot/go-sdk/models"
"github.com/spf13/cobra"
)

func newCreate(secret *config.Secret) *cobra.Command {
cfg := &config.SecretCreate{Secret: secret}

cmd := &cobra.Command{
Use: "create { NAME --value VALUE | NAME --value-file PATH | NAME --value-stdin | -f FILENAME [--set var=val ...] } [--description TEXT]",
Short: "Create a new secret",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return create(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr(), args)
},
}

cfg.AddFlags(cmd)
return cmd
}

func create(cfg *config.SecretCreate, out, log io.Writer, args []string) error {
if err := cfg.InitAPIConfig(); err != nil {
return err
}

s, err := buildSecretFromInputs(secretInputs{
Args: args,
Filename: cfg.Filename,
TplVals: cfg.TemplateVals,
Value: cfg.Value,
ValueFile: cfg.ValueFile,
ValueStdin: cfg.ValueStdin,
Description: cfg.Description,
Log: log,
})
if err != nil {
return err
}
if s.Name == "" {
return errors.New("secret name is required")
}
if s.Value == "" {
return errors.New("value is required; supply one of --value / --value-file / --value-stdin, or a file with -f")
}

params := sdksecrets.NewCreateSecretParams().
WithOrgName(cfg.Org).
WithData(s)
resp, err := cfg.Client.Secrets.CreateSecret(params, nil)
if err != nil {
return err
}

fmt.Fprintf(log, "Created secret %q\n\n", s.Name)
return writeSecretOutput(cfg.OutputFormat, out, resp.Payload)
}

type secretInputs struct {
Args []string
Filename string
TplVals config.TemplateVals
Value string
ValueFile string
ValueStdin bool
Description string
Log io.Writer
}

// buildSecretFromInputs resolves a *models.Secret from the combination of a
// positional NAME, value flags, and optional -f file. It enforces mutual
// exclusion between the file mode and the flat-CLI mode.
func buildSecretFromInputs(in secretInputs) (*models.Secret, error) {
if in.Filename != "" {
if len(in.Args) != 0 {
return nil, errors.New("must not provide NAME positional when -f is specified")
}
if in.Value != "" || in.ValueFile != "" || in.ValueStdin {
return nil, errors.New("must not combine -f with --value / --value-file / --value-stdin")
}
if in.Description != "" {
return nil, errors.New("must not combine -f with --description")
}
if len(in.TplVals) != 0 && in.Filename == "" {
return nil, errors.New("--set requires -f")
}
return loadSecretFile(in.Filename, in.TplVals, false /* forDelete */)
}

if len(in.Args) == 0 {
return nil, errors.New("must specify NAME or -f FILENAME")
}
if len(in.TplVals) != 0 {
return nil, errors.New("--set requires -f")
}

value, err := resolveValue(in.Value, in.ValueFile, in.ValueStdin, in.Log)
if err != nil {
return nil, err
}
return &models.Secret{
Name: in.Args[0],
Description: in.Description,
Value: value,
}, nil
}

// resolveValue reads the secret value from exactly one of the three flag sources.
// Returns "" when none are set; callers decide whether that's an error.
func resolveValue(literal, path string, fromStdin bool, log io.Writer) (string, error) {
n := 0
if literal != "" {
n++
}
if path != "" {
n++
}
if fromStdin {
n++
}
if n > 1 {
return "", errors.New("--value, --value-file, and --value-stdin are mutually exclusive")
}

switch {
case literal != "":
fmt.Fprintln(log, "warning: --value leaks the secret into shell history; prefer --value-file or --value-stdin")
return literal, nil
case path != "":
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading --value-file: %w", err)
}
return string(data), nil
case fromStdin:
fi, err := os.Stdin.Stat()
if err == nil && (fi.Mode()&os.ModeCharDevice) != 0 {
return "", errors.New("--value-stdin was given but stdin is a terminal")
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("reading stdin: %w", err)
}
return string(data), nil
default:
return "", nil
}
}

func writeSecretOutput(format config.OutputFormat, out io.Writer, s *models.Secret) error {
switch format {
case config.OutputFormatDefault:
return nil
case config.OutputFormatJSON:
return print.RawJSON(out, s)
case config.OutputFormatYAML:
return print.RawYAML(out, s)
default:
return fmt.Errorf("unsupported output format: %q", format)
}
}
66 changes: 66 additions & 0 deletions internal/command/secret/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package secret

import (
"errors"
"fmt"
"io"

"github.com/signadot/cli/internal/config"
sdksecrets "github.com/signadot/go-sdk/client/secrets"
"github.com/spf13/cobra"
)

func newDelete(secret *config.Secret) *cobra.Command {
cfg := &config.SecretDelete{Secret: secret}

cmd := &cobra.Command{
Use: "delete { NAME | -f FILENAME [--set var=val ...] }",
Short: "Delete a secret",
Aliases: []string{"rm"},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return deleteSecret(cfg, cmd.ErrOrStderr(), args)
},
}

cfg.AddFlags(cmd)
return cmd
}

func deleteSecret(cfg *config.SecretDelete, log io.Writer, args []string) error {
if err := cfg.InitAPIConfig(); err != nil {
return err
}

var name string
if cfg.Filename == "" {
if len(args) == 0 {
return errors.New("must specify NAME or -f FILENAME")
}
if len(cfg.TemplateVals) != 0 {
return errors.New("--set requires -f")
}
name = args[0]
} else {
if len(args) != 0 {
return errors.New("must not provide NAME positional when -f is specified")
}
s, err := loadSecretFile(cfg.Filename, cfg.TemplateVals, true /* forDelete */)
if err != nil {
return err
}
name = s.Name
}
if name == "" {
return errors.New("secret name is required")
}

params := sdksecrets.NewDeleteSecretParams().
WithOrgName(cfg.Org).
WithSecretName(name)
if _, err := cfg.Client.Secrets.DeleteSecret(params, nil); err != nil {
return err
}
fmt.Fprintf(log, "Deleted secret %q.\n\n", name)
return nil
}
50 changes: 50 additions & 0 deletions internal/command/secret/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package secret

import (
"fmt"
"io"

"github.com/signadot/cli/internal/config"
"github.com/signadot/cli/internal/print"
sdksecrets "github.com/signadot/go-sdk/client/secrets"
"github.com/spf13/cobra"
)

func newGet(secret *config.Secret) *cobra.Command {
cfg := &config.SecretGet{Secret: secret}

cmd := &cobra.Command{
Use: "get NAME",
Short: "Get secret metadata (plaintext value is never returned)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return get(cfg, cmd.OutOrStdout(), args[0])
},
}

return cmd
}

func get(cfg *config.SecretGet, out io.Writer, name string) error {
if err := cfg.InitAPIConfig(); err != nil {
return err
}
params := sdksecrets.NewGetSecretParams().
WithOrgName(cfg.Org).
WithSecretName(name)
resp, err := cfg.Client.Secrets.GetSecret(params, nil)
if err != nil {
return err
}

switch cfg.OutputFormat {
case config.OutputFormatDefault:
return printSecretDetails(out, resp.Payload)
case config.OutputFormatJSON:
return print.RawJSON(out, resp.Payload)
case config.OutputFormatYAML:
return print.RawYAML(out, resp.Payload)
default:
return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat)
}
}
Loading