Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
78f87ff
test: dump output when testing
avirtopeanu-ionos May 6, 2026
6168c69
test: assert stderr
avirtopeanu-ionos May 6, 2026
763f722
redact stderr too
avirtopeanu-ionos May 6, 2026
2ea8313
fix: redact full flag values including PEM, silence setup_file noise
avirtopeanu-ionos May 6, 2026
6de3e6f
feat: add object-storage bucket get command
cristiGuranIonos Mar 30, 2026
a6ac60a
bats: add e2e test
cristiGuranIonos Mar 30, 2026
b3c42b0
go: remove replace
cristiGuranIonos Mar 30, 2026
cfc21f2
go mod vendor
cristiGuranIonos Mar 30, 2026
c46f235
license
cristiGuranIonos Mar 30, 2026
e8dd8f2
fmt: changes
cristiGuranIonos Mar 30, 2026
b2eb13d
fmt: make docs
cristiGuranIonos Mar 30, 2026
a6e4396
feat: add object-storage bucket create and delete commands
cristiGuranIonos Mar 30, 2026
606da36
fmt: fail if not exist bats
cristiGuranIonos Mar 30, 2026
238d7b6
fix: get and tests
cristiGuranIonos Mar 31, 2026
859400f
fix: docs
cristiGuranIonos Mar 31, 2026
a293ef8
fix: docs and remove region
cristiGuranIonos Mar 31, 2026
60aa642
fix: claude
cristiGuranIonos Mar 31, 2026
69051bf
feat: add list
cristiGuranIonos Mar 31, 2026
e2238e9
feat: head bucket
cristiGuranIonos Mar 31, 2026
1d54f71
feat: get versioning
cristiGuranIonos Mar 31, 2026
e86b6a7
feat: add list objects command
cristiGuranIonos Mar 31, 2026
c174df1
feat: add recursive to delete bucket with objects
cristiGuranIonos Mar 31, 2026
cac0292
feat: code review, remove unused
cristiGuranIonos Mar 31, 2026
08bed97
feat: fix bats, move versioning to subcommand
cristiGuranIonos Mar 31, 2026
1d63ed4
feat: align with repo
cristiGuranIonos Mar 31, 2026
86c73bc
feat: simplify, add tests
cristiGuranIonos Mar 31, 2026
f8ffcf5
feat: set bucket versioning
cristiGuranIonos Apr 1, 2026
4f3f75e
add bucket policy command tree (#660)
glimberea Apr 1, 2026
0fec8f4
implement object subcommands (#661)
glimberea Apr 1, 2026
607a7ce
add cors subcommands (#662)
glimberea Apr 1, 2026
62f46bb
feat: use GetRegionalObjectStorageClient
cristiGuranIonos Apr 1, 2026
4665304
feat: add tagging
cristiGuranIonos Apr 1, 2026
8f5c84d
feat: fix ci
cristiGuranIonos Apr 2, 2026
2de2511
add encryption subcommands
Apr 1, 2026
b5bb7c0
autocomplete on flags and object list
Apr 1, 2026
00ea23b
fix: transport rewrite xml, use context
cristiGuranIonos Apr 2, 2026
d0d737f
use client Get/Must model, add location flag with fileconfig support …
glimberea Apr 16, 2026
182ad5c
add --all flags to bucket and object delection, add commands for obje…
glimberea Apr 20, 2026
48f1af3
fix tests cleanup
glimberea Apr 20, 2026
42a8ba8
fix cols flags
glimberea Apr 20, 2026
01464ba
remove single-purpose variable declarations
glimberea Apr 20, 2026
feaa4ef
refactor and add s3 credentials provenance
glimberea Apr 20, 2026
333150a
add object tagging, lifecycle and public access block commands. move …
glimberea Apr 21, 2026
0f103a9
still show provenance sources even when creds are not set
glimberea Apr 21, 2026
bde2545
remove mentions of s3
glimberea Apr 21, 2026
b51742e
make docs
glimberea Apr 21, 2026
7903e85
comments
glimberea Apr 29, 2026
9d99213
test: move object-storage tests to suites, use setup.bats conventions
avirtopeanu-ionos May 7, 2026
8b0d7c9
doc: changelog
avirtopeanu-ionos May 7, 2026
0d7c8b3
test: add object storage unit tests
avirtopeanu-ionos May 7, 2026
a19b617
fix: copilot review
avirtopeanu-ionos May 7, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ jobs:
BASE_BRANCH: ${{ github.base_ref }}
IONOS_USERNAME: ${{ secrets.IONOS_USERNAME }}
IONOS_PASSWORD: ${{ secrets.IONOS_PASSWORD }}
IONOS_S3_ACCESS_KEY: ${{ secrets.IONOS_S3_ACCESS_KEY }}
IONOS_S3_SECRET_KEY: ${{ secrets.IONOS_S3_SECRET_KEY }}
run: make test
if: matrix.os == 'ubuntu-latest'

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Changelog

## [v6.9.10] – May 2026
## [v6.10.1] – May 2026

### Added
- Added `object-storage` support, with `whoami --provenance` credentials resolution
- Added `--ftp-port` on `image upload` which is usable in combination with `--ftp-url`.
- Improve `ionosctl shell` interactive shell prompt:
- Removed beta warnings and notes. Interactive prompts (e.g. delete confirmations) now work natively instead of requiring `--force`.
Expand Down
144 changes: 144 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# CLAUDE.md

## Git Workflow (MANDATORY)

**Never work directly on `master` or `<base-branch>`.** Always create a feature branch and open a PR.

```bash
# Before starting any work:
git checkout master && git pull # git checkout <base-branch> && git pull
git checkout -b feat/<short-description> # or fix:/, doc:/, test:/, refactor:/

# When done:
git push -u origin feat/<short-description>
gh pr create --title "feat: <description>" --body "..."
```

- **Never commit directly to `master` or `<base-branch>`** — all changes go through a PR reviewed and merged by a human.
- Branch naming: `feat/<name>`, `fix/<name>`, `doc/<name>`, `test/<name>`, `refactor/<name>`.
- One logical change per branch. Keep PRs focused.

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

`ionosctl` is a Go CLI tool for managing IONOS Cloud resources. It uses Cobra for the CLI framework, Viper for configuration, and multiple IONOS SDK packages for different services (Cloud API v6, DBaaS, DNS, CDN, VPN, Kafka, etc.).

## Common Commands

```bash
make build # Build the binary (runs tools/build.sh build)
make install # Install locally
make utest # Run unit tests with coverage
make itest # Run integration + unit tests (requires build tag: integration)
make test # Full test suite: bats-core shell tests + go tests
make lint # Run golangci-lint
make gofmt_check # Check code formatting
make gofmt # Format code
make mocks # Regenerate mocks (uses golang/mock)
make vendor # Update vendor dependencies
```
## Testing (MANDATORY)

**YOU ARE NOT ALLOWED TO RUN TESTS WITHOUT MY EXPLICIT APPROVAL.**

Run a single Go test:
```bash
go test ./commands/cloudapi-v6/... -run TestFunctionName -v
go test ./internal/printer/... -run TestFunctionName -v
```

Run tests with integration tag:
```bash
go test -tags integration ./...
```

## Architecture

### Layer Structure

```
main.go → commands/ → internal/core/ → services/ → IONOS SDKs
↘ internal/printer/
↘ internal/client/
```

1. **`commands/`** — Command definitions organized by service (`cloudapi-v6/`, `dns/`, `cdn/`, `dbaas/`, etc.)
2. **`internal/core/`** — Command framework wrapping Cobra (`Command`, `CommandBuilder`, `CommandConfig`)
3. **`services/cloudapi-v6/resources/`** — Service layer wrapping Cloud API v6 SDK calls
4. **`internal/printer/jsontabwriter/`** — Output formatting (text tables, JSON, api-json)
5. **`internal/client/`** — API client initialization, credentials, multi-SDK support
6. **`internal/constants/`** — Flag name constants and shared strings

### Command Structure Pattern

Every command follows a **Namespace.Resource.Verb** hierarchy and is built with `core.CommandBuilder`:

```go
core.NewCommand(ctx, parentCmd, core.CommandBuilder{
Namespace: "datacenter",
Resource: "datacenter",
Verb: "list",
Aliases: []string{"l", "ls"},
ShortDesc: "List Data Centers",
PreCmdRun: core.NoPreRun, // validation (returns error to abort)
CmdRun: RunDataCenterList, // main logic
InitClient: true, // creates API client before CmdRun
})
```

- **`PreCmdRun`** validates flags and preconditions; return an error to abort execution
- **`CmdRun`** receives `*core.CommandConfig` which provides access to services, viper config, and the cobra command

### Adding a New Command

1. Create `commands/{service}/{resource}.go`
2. Define a root function returning `*core.Command` (see `commands/cloudapi-v6/location.go` as a reference)
3. Add subcommands via `core.NewCommand` with a `CommandBuilder`
4. Implement `PreCmdRun` (validation) and `CmdRun` (logic) functions
5. Use `jsontabwriter.GenerateOutput()` for output; define column headers with `tabheaders`
6. Register in `commands/root.go` → `addCommands()` function

### Flag Naming Convention

Flag names are constants in `internal/constants/`. Access flag values in `CmdRun` via:
```go
viper.GetString(core.GetFlagName(c.NS, constants.FlagDatacenterId))
```

### Authentication & Configuration

Credentials are loaded in priority order:
1. Environment variables: `IONOS_USERNAME`, `IONOS_PASSWORD`, `IONOS_TOKEN`, `IONOS_API_URL`
2. Config file: `~/.config/ionosctl/config.json` (Linux)

### Output Formatting

The `--output` global flag controls format: `text` (default table), `json`, or `api-json` (raw API response).
The `--cols` flag controls which columns to display.
The `--filters` flag supports case-insensitive key filtering.

### Services (Cloud API v6)

Services are lazy-loaded in `services/cloudapi-v6/services.go`:
```go
type Services struct {
Locations func() resources.LocationsService
DataCenters func() resources.DatacentersService
// ...
}
```

Each resource service implements CRUD operations and is accessible via `c.CloudApiV6Services` in `CmdRun`.

### Mocks

Mocks for service interfaces are generated using `golang/mock`. Regenerate with `make mocks`. Mock files live alongside their interfaces, typically in `services/cloudapi-v6/resources/mocks/`.

### Prerequisites
- Remember to run make gofmt after implementing changes
- Remember to run make docs after implementing changes
- ionosctl uses bats for e2e tests, implement e2e tests for new features
- Remember to run gofmt on the files to correctly import tests
- Cyclomatic complexity for new functions should not exceed 15; refactor into smaller functions if necessary. Only for new code.
- Add bats test if adding a new feature
26 changes: 24 additions & 2 deletions commands/cfg/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@ ionosctl cfg whoami --provenance`,
InitClient: false,
})

cmd.AddBoolFlag(constants.FlagProvenance, constants.FlagProvenanceShort, false, "If set, the command prints the layers of authentication sources, their order of priority, and which one was used. It also tells you if a token or username and password are being used for authentication.")
cmd.AddBoolFlag(constants.FlagProvenance, constants.FlagProvenanceShort, false, "If set, the command prints the layers of authentication sources (including Object Storage credentials), their order of priority, and which one was used.")

return core.WithConfigOverride(cmd, []string{"auth"}, constants.DefaultApiURL+"/auth/v1")
}

// handleProvenance prints out all authentication layers in priority order,
// marks which one was actually used, and shows whether it’s token vs. user/pass
// plus the effective API URL.
// plus the effective API URL. It also shows Object Storage credential sources.
func handleProvenance(c *core.CommandConfig, cl *client.Client, authErr error) error {
var b strings.Builder

Expand All @@ -73,6 +73,28 @@ func handleProvenance(c *core.CommandConfig, cl *client.Client, authErr error) e
}
}

// Object Storage credential provenance
_, _, akSrc, skSrc, _ := client.ResolveObjectStorageCredentials()

b.WriteString("\nFor Object Storage, in order of priority:\n")

type osPair struct {
label string
akSrc client.ObjectStorageAccessKeySource
skSrc client.ObjectStorageSecretKeySource
}
pairs := []osPair{
{"environment variables: IONOS_S3_ACCESS_KEY, IONOS_S3_SECRET_KEY", client.ObjectStorageAccessKeyEnv, client.ObjectStorageSecretKeyEnv},
{"credentials from config file: s3AccessKey, s3SecretKey", client.ObjectStorageAccessKeyCfg, client.ObjectStorageSecretKeyCfg},
}
for i, p := range pairs {
if akSrc == p.akSrc && skSrc == p.skSrc {
b.WriteString(fmt.Sprintf("* [%d] %s (USED)\n", i+1, p.label))
} else {
b.WriteString(fmt.Sprintf(" [%d] %s\n", i+1, p.label))
}
}

Comment thread
glimberea marked this conversation as resolved.
// Finally, print it all out
_, err := fmt.Fprintln(c.Command.Command.OutOrStdout(), b.String())
if err != nil {
Expand Down
57 changes: 57 additions & 0 deletions commands/object-storage/bucket/bucket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package bucket

import (
"github.com/spf13/cobra"

"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/cors"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/encryption"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/lifecycle"
objectlock "github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/object-lock"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/policy"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/publicaccessblock"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/tagging"
"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/bucket/versioning"
"github.com/ionos-cloud/ionosctl/v6/internal/constants"
"github.com/ionos-cloud/ionosctl/v6/internal/core"
"github.com/ionos-cloud/ionosctl/v6/internal/printer/table"
)

var allCols = []table.Column{
{Name: "Name", JSONPath: "Name", Default: true},
{Name: "CreationDate", JSONPath: "CreationDate", Default: true},
{Name: "Region", JSONPath: "Region", Default: true},
Comment thread
avirtopeanu-ionos marked this conversation as resolved.
}

func BucketCommand() *core.Command {
cmd := &core.Command{
Command: &cobra.Command{
Use: "bucket",
Aliases: []string{"b"},
Short: "Bucket operations for contract-owned object storage",
TraverseChildren: true,
},
}

cmd.Command.PersistentFlags().StringSlice(constants.ArgCols, nil, table.ColsMessage(allCols))
_ = cmd.Command.RegisterFlagCompletionFunc(
constants.ArgCols,
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return table.AllCols(allCols), cobra.ShellCompDirectiveNoFileComp
},
)
Comment thread
glimberea marked this conversation as resolved.

cmd.AddCommand(ListBucketsCmd())
cmd.AddCommand(CreateBucketCmd())
cmd.AddCommand(GetBucketCmd())
cmd.AddCommand(HeadBucketCmd())
cmd.AddCommand(DeleteBucketCmd())
cmd.AddCommand(versioning.Root())
cmd.AddCommand(objectlock.Root())
cmd.AddCommand(cors.CorsCmd())
cmd.AddCommand(encryption.EncryptionCmd())
cmd.AddCommand(tagging.TaggingCmd())
cmd.AddCommand(policy.PolicyCmd())
cmd.AddCommand(lifecycle.LifecycleCmd())
cmd.AddCommand(publicaccessblock.PublicAccessBlockCmd())
return cmd
}
51 changes: 51 additions & 0 deletions commands/object-storage/bucket/cors/cors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cors

import (
"github.com/spf13/cobra"

"github.com/ionos-cloud/ionosctl/v6/internal/constants"
"github.com/ionos-cloud/ionosctl/v6/internal/core"
"github.com/ionos-cloud/ionosctl/v6/internal/printer/table"
)

var allCols = []table.Column{
{Name: "AllowedOrigins", JSONPath: "AllowedOrigins", Default: true},
{Name: "AllowedMethods", JSONPath: "AllowedMethods", Default: true},
{Name: "AllowedHeaders", JSONPath: "AllowedHeaders", Default: true},
{Name: "ExposeHeaders", JSONPath: "ExposeHeaders"},
{Name: "MaxAgeSeconds", JSONPath: "MaxAgeSeconds"},
{Name: "ID", JSONPath: "ID"},
}

type corsRuleInfo struct {
AllowedOrigins string `json:"AllowedOrigins"`
AllowedMethods string `json:"AllowedMethods"`
AllowedHeaders string `json:"AllowedHeaders"`
ExposeHeaders string `json:"ExposeHeaders"`
MaxAgeSeconds string `json:"MaxAgeSeconds"`
ID string `json:"ID"`
}

func CorsCmd() *core.Command {
cmd := &core.Command{
Command: &cobra.Command{
Use: "cors",
Short: "Bucket CORS operations for contract-owned object storage",
TraverseChildren: true,
},
}

cmd.Command.PersistentFlags().StringSlice(constants.ArgCols, nil, table.ColsMessage(allCols))
_ = cmd.Command.RegisterFlagCompletionFunc(
constants.ArgCols,
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return table.AllCols(allCols), cobra.ShellCompDirectiveNoFileComp
},
)

cmd.AddCommand(GetCmd())
cmd.AddCommand(PutCmd())
cmd.AddCommand(DeleteCmd())

return cmd
}
51 changes: 51 additions & 0 deletions commands/object-storage/bucket/cors/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cors

import (
"context"
"fmt"

"github.com/spf13/viper"

"github.com/ionos-cloud/ionosctl/v6/commands/object-storage/completer"
"github.com/ionos-cloud/ionosctl/v6/internal/client"
"github.com/ionos-cloud/ionosctl/v6/internal/constants"
"github.com/ionos-cloud/ionosctl/v6/internal/core"
"github.com/ionos-cloud/ionosctl/v6/pkg/confirm"
)

func DeleteCmd() *core.Command {
cmd := core.NewCommand(context.Background(), nil, core.CommandBuilder{
Namespace: "object-storage",
Resource: "cors",
Verb: "delete",
Aliases: []string{"d"},
ShortDesc: "Delete the CORS configuration for a bucket",
Example: "ionosctl object-storage bucket cors delete --name my-bucket\nionosctl object-storage bucket cors delete --name my-bucket -f",
PreCmdRun: func(c *core.PreCommandConfig) error {
return core.CheckRequiredFlags(c.Command, c.NS, constants.FlagName)
},
CmdRun: func(c *core.CommandConfig) error {
name := viper.GetString(core.GetFlagName(c.NS, constants.FlagName))

if !confirm.FAsk(c.Command.Command.InOrStdin(), fmt.Sprintf("delete CORS configuration for bucket %q", name), viper.GetBool(constants.ArgForce)) {
return fmt.Errorf(confirm.UserDenied)
}

_, err := client.MustObjectStorage().ObjectStorageClient.CORSApi.DeleteBucketCors(c.Context, name).Execute()
if err != nil {
return err
}

fmt.Fprintf(c.Command.Command.OutOrStdout(), "CORS configuration for %q deleted successfully\n", name)
return nil
},
InitClient: false,
})

cmd.AddStringFlag(constants.FlagName, constants.FlagNameShort, "", "Name of the bucket", core.RequiredFlagOption(),
core.WithCompletion(completer.BucketNames, constants.ObjectStorageApiRegionalURL, constants.ObjectStorageLocations))

cmd.Command.SilenceUsage = true
cmd.Command.Flags().SortFlags = false
return cmd
}
Loading
Loading