Skip to content
Open
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
5 changes: 4 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ builds:
-
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -X "github.com/latitudesh/lsh/internal/version.Version={{ .Tag }}"
# -s -w strips symbol and DWARF tables (~30% smaller binaries).
- -s -w -X "github.com/latitudesh/lsh/internal/version.Version={{ .Tag }}"
goos:
- linux
- windows
Expand Down
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,77 @@ sudo lsh volume mount --id vol_abc123
- The CLI automatically finds your credentials when you run commands with sudo
- Volume mount needs sudo for nvme-cli installation and NVMe operations

`lsh volumes` and `lsh filesystems` are aliases of `lsh volume` and `lsh storage-filesystems`.

## Object storage

`lsh s3` manages buckets, objects, access keys and lifecycle rules, addressing
buckets and objects as `s3://<bucket>/<key>`. Bucket administration goes through the
Latitude API; object operations talk to the bucket's S3 endpoint directly, so
endpoint and signing region never have to be configured by hand.

`<bucket>` accepts the display name, the `bkt_` ID or the backend bucket name. If
the same display name exists in more than one place, the command lists the
candidates and you narrow it with `--project`, `-c`/`--storage-class`, `--site`
(e.g. `lsh s3 get s3://backups -c high_performance --site TYO4`), or the `bkt_` ID.

Create a bucket, upload, list and delete (in a terminal, `mb` offers to create
an S3 access key and saves it to your profile):

```bash
lsh s3 create-bucket s3://backups --region DAL --project <PROJECT_ID_OR_SLUG>
lsh s3 copy ./dump.sql s3://backups/2026/09/
lsh s3 list s3://backups/2026/09/ --human-readable --summarize
lsh s3 copy s3://backups/2026/09/dump.sql ./restore/
lsh s3 delete s3://backups/2026/09/dump.sql
```

Give an application or CI job its own scoped access key (the secret is shown
once; `-o text --query` extracts it for a secret store):

```bash
lsh s3 access-keys create --bucket backups=rw --bucket logs=readonly --name ci-deploy
lsh s3 access-keys create --bucket backups=rw --name ci-deploy -o text --query "[0].secret_access_key" | gh secret set LSH_S3_SECRET_ACCESS_KEY
lsh s3 access-keys list
lsh s3 access-keys rotate ci-deploy --delete-old
```

Expire objects automatically with lifecycle rules:

```bash
lsh s3 lifecycle create s3://logs --prefix tmp/ --expiration-days 7
lsh s3 lifecycle list s3://logs
lsh s3 lifecycle delete s3://logs expire-7d-tmp
```

Use the same buckets from rclone, mc, s3cmd or any other S3 client:

```bash
lsh s3 configure export s3://backups --format env # also: aws | rclone | mc | s3cmd | process
```

Clean up safely (`--dry-run` only reads; multi-object deletes ask for
confirmation in a terminal and require `--yes` in CI):

```bash
lsh s3 delete s3://logs/tmp/ --recursive --dry-run
lsh s3 delete s3://logs/tmp/ --recursive --yes
lsh s3 delete-bucket s3://logs --force --yes
```

In CI, object commands authenticate with an S3 access key from the environment
instead of a saved profile:

| Variable | Purpose |
| --- | --- |
| `LSH_S3_ACCESS_KEY_ID` / `LSH_S3_SECRET_ACCESS_KEY` | S3 access key used by `cp`, `ls`, `rm`, `stat` and `presign` (both required). |
| `LSH_S3_ENDPOINT_URL` | Talk to this S3 endpoint without the API; buckets are then addressed by their backend `bucket_name`. |
| `LSH_S3_SIGNING_REGION` | SigV4 signing region when it cannot be derived from the endpoint. |
| `LSH_S3_USE_AWS_ENV` | Set to `1` to reuse `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`. |

`lsh help exit-codes` documents the exit codes (0-7, 130) that `lsh s3` returns
for scripts.

## Output formats & automation

Every `list` command can render its results in different formats, so the output
Expand All @@ -143,11 +214,12 @@ lsh servers list -o table # human-readable table (default)
lsh servers list -o json # JSON
lsh servers list -o yaml # YAML
lsh servers list -o csv # CSV (header + one row per item)
lsh servers list -o text # raw values, tab-separated
lsh servers list --json # shortcut for -o json
```

Filter the structured output with a [JMESPath](https://jmespath.org/) expression
via `--query` (works with json/yaml/csv):
via `--query` (works with json/yaml/csv/text):

```bash
lsh servers list --query "[?status=='on'].id" -o json
Expand All @@ -165,8 +237,8 @@ lsh servers list --no-paginate # first page only; next page printed to std

| Variable | Purpose |
| --- | --- |
| `LSH_OUTPUT` | Default output format (`table`/`json`/`yaml`/`csv`). Precedence: `--output` flag > `LSH_OUTPUT` > config file > default. |
| `LSH_CLASSIC_OUTPUT` | Set to `true` to force the legacy plain-ASCII table. An explicit `-o json/yaml/csv` still wins over it. |
| `LSH_OUTPUT` | Default output format (`table`/`json`/`yaml`/`csv`/`text`). Precedence: `--output` flag > `LSH_OUTPUT` > config file > default. |
| `LSH_CLASSIC_OUTPUT` | Set to `true` to force the legacy plain-ASCII table. An explicit `-o json/yaml/csv/text` still wins over it. |
| `LATITUDESH_TOKEN` | API token; bypasses any stored profile (see `lsh help authentication`). |
| `LSH_PROFILE` | Use the named profile for the command. |
| `LSH_PROJECT` | Pre-fill `--project` so list commands don't prompt. |
Expand Down
44 changes: 37 additions & 7 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/latitudesh/lsh/client"
"github.com/latitudesh/lsh/cmd/lsh"
servers "github.com/latitudesh/lsh/cmd/servers"
"github.com/latitudesh/lsh/internal/exitcode"
"github.com/latitudesh/lsh/internal/pagination"
"github.com/latitudesh/lsh/internal/renderer"
"github.com/latitudesh/lsh/internal/version"
Expand Down Expand Up @@ -70,10 +71,15 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) {

// Dedicated group so help topics show up clearly in `lsh --help`.
rootCmd.AddGroup(&cobra.Group{ID: helpTopicsGroupID, Title: "Help topics:"})
// Storage products (object storage, filesystems, volumes) share a section
// so they stay discoverable together in `lsh --help`.
rootCmd.AddGroup(&cobra.Group{ID: StorageGroupID, Title: "Storage:"})
rootCmd.AddCommand(makeHelpAuthenticationCmd())
rootCmd.AddCommand(makeHelpProfilesCmd())
rootCmd.AddCommand(makeHelpAutomationCmd())
rootCmd.AddCommand(makeHelpOutputFormatsCmd())
rootCmd.AddCommand(makeHelpExitCodesCmd())
rootCmd.AddCommand(makeHelpS3Cmd())

// Re-resolve the active profile once flags have been parsed so that
// `--profile <name>` overrides LSH_PROFILE / default_profile for the
Expand All @@ -88,11 +94,20 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) {
// Validate output/query/pagination selection up front so commands fail
// fast with an actionable message (and a non-zero exit) instead of
// silently falling back or clamping.
// These are command-line errors. Groups that opted into the documented
// exit codes report them as usage (2); the older groups keep exiting 1
// for every failure, so their scripts are unaffected.
usageCode := func(err error) error {
if err == nil || !usesExitCodes(cmd) {
return err
}
return exitcode.New(exitcode.Usage, err)
}
if err := renderer.ValidateOutputSelection(); err != nil {
return err
return usageCode(err)
}
if err := pagination.Validate(); err != nil {
return err
return usageCode(err)
}
// Hydrate the active profile into viper for commands that authenticate
// against the API. Skip the login/auth/profile subtree: there --profile
Expand All @@ -119,7 +134,7 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) {
viper.BindPFlag("base_path", rootCmd.PersistentFlags().Lookup("base-path"))

var outputFlag string
rootCmd.PersistentFlags().StringVarP(&outputFlag, "output", "o", "table", "output format: table | json | yaml | csv")
rootCmd.PersistentFlags().StringVarP(&outputFlag, "output", "o", "table", "output format: table | json | yaml | csv | text")
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
// LSH_OUTPUT sets a per-user default format. viper precedence is
// flag > env > config > default, which is exactly what PD-6072 requires.
Expand All @@ -131,7 +146,7 @@ func MakeRootCmd(rootCmd *cobra.Command) (*cobra.Command, error) {

// Global automation controls. --query post-processes structured output with
// a JMESPath expression; the pagination flags govern every `list` command.
rootCmd.PersistentFlags().String("query", "", "filter json/yaml/csv output with a JMESPath expression (see 'lsh help output-formats')")
rootCmd.PersistentFlags().String("query", "", "filter json/yaml/csv/text output with a JMESPath expression (see 'lsh help output-formats')")
viper.BindPFlag("query", rootCmd.PersistentFlags().Lookup("query"))

rootCmd.PersistentFlags().Int64("page-size", pagination.DefaultPageSize, "items to request per API page")
Expand Down Expand Up @@ -561,9 +576,11 @@ func makeOperationGroupVirtualNetworksCmd() (*cobra.Command, error) {

func makeOperationGroupVolumeCmd() (*cobra.Command, error) {
operationGroupVolumeCmd := &cobra.Command{
Use: "volume",
Short: "Manage volumes",
Long: `Commands to manage volume operations such as listing, mounting, creating, and deleting volumes`,
Use: "volume",
Aliases: []string{"volumes"},
GroupID: StorageGroupID,
Short: "Manage volumes",
Long: `Commands to manage volume operations such as listing, mounting, creating, and deleting volumes`,
}

operationVolumeListCmd, err := makeOperationVolumeListCmd()
Expand Down Expand Up @@ -598,3 +615,16 @@ func makeOperationGroupVolumeCmd() (*cobra.Command, error) {

return operationGroupVolumeCmd, nil
}

// usesExitCodes reports whether cmd belongs to a subtree that opted into the
// documented exit codes (see exitcode.OptInAnnotation). The annotation is set
// on every command of such a group, but the walk keeps it working if only the
// group root carries it.
func usesExitCodes(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations[exitcode.OptInAnnotation] == "true" {
return true
}
}
return false
}
Loading