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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- **threescale-export** — `--toolbox-platform` / `THREESCALE_TOOLBOX_PLATFORM` opt-in container platform for toolbox `run` (e.g. `linux/amd64` on Apple Silicon). Empty/whitespace omits `--platform` (no auto-default). Ignored when `--toolbox-binary` is set.

## [0.4.5] - 2026-09-25

### Added
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ You should see a version string (for example `v0.4.4`). If you get `Permission d
1. Download and extract **`*-darwin-arm64.tar.gz`** for the CLI you need (export, seed, and/or visualize).
2. Use **Docker Desktop** or a **Podman machine** with **qemu/binfmt** (AMD64 emulation) enabled.
3. The Red Hat toolbox image is **`linux/amd64`**. Product YAML export still pulls/runs that AMD64 image under emulation — the native Mac CLI alone is not enough.
4. Until an explicit toolbox platform flag ships, nested PATH-wrapper workarounds for forcing AMD64 on toolbox `pull`/`run`/`create` are documented in [issue #65](https://github.com/Everything-is-Code/3scaleextract/issues/65).
4. Prefer **`--toolbox-platform linux/amd64`** (or `THREESCALE_TOOLBOX_PLATFORM=linux/amd64`) so the exporter passes `--platform` on toolbox `run`. The exporter issues `run` only (no separate `pull`/`create`); if you pre-pull the image yourself, use the same `--platform` value. Nested PATH-wrapper workarounds remain documented historically in [issue #65](https://github.com/Everything-is-Code/3scaleextract/issues/65).

```bash
export THREESCALE_TOOLBOX_PLATFORM=linux/amd64
# or: ./threescale-export --toolbox-platform linux/amd64 ...
```

---

Expand Down Expand Up @@ -234,6 +239,7 @@ export THREESCALE_OUTPUT_DIR="./export" # alternative to --output
export THREESCALE_TOOLBOX_IMAGE="registry.redhat.io/3scale-amp2/toolbox-rhel9:3scale2.16"
export THREESCALE_TOOLBOX_RUNTIME="docker"
export THREESCALE_TOOLBOX_TLS_CERT="/path/to/ca.pem" # self-signed TLS on Admin Portal
export THREESCALE_TOOLBOX_PLATFORM="linux/amd64" # opt-in; empty omits --platform on toolbox run
```

### Flags
Expand All @@ -256,6 +262,7 @@ export THREESCALE_TOOLBOX_TLS_CERT="/path/to/ca.pem" # self-signed TLS on Admi
| `--toolbox-image` | Toolbox image (default Red Hat 2.16) |
| `--toolbox-runtime` | `docker` or `podman` (auto-detect if empty) |
| `--toolbox-tls-cert` | CA certificate mounted in the toolbox container |
| `--toolbox-platform` | Container platform for toolbox `run` (e.g. `linux/amd64`); empty omits `--platform`. Container path only — ignored when `--toolbox-binary` is set. Exporter issues `run` only; pre-pull with the same `--platform` if you pull manually. |
| `--quiet` | Suppress progress output on stderr |
| `--verbose` | Show detailed progress (e.g. toolbox invocations; credentials redacted) |

Expand Down
1 change: 1 addition & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func RunExport(ctx context.Context, cfg config.ExportConfig) error {
Image: cfg.ToolboxImage,
NativeBinary: cfg.ToolboxNativeBinary,
CertFile: cfg.ToolboxCertFile,
Platform: cfg.ToolboxPlatform,
Insecure: cfg.InsecureTLS,
OnVerbose: verboseHook(rep, cfg.Verbose),
})
Expand Down
19 changes: 11 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,22 @@ type ExportConfig struct {
ToolboxRuntime string
ToolboxNativeBinary string
ToolboxCertFile string
ToolboxPlatform string
Quiet bool
Verbose bool
}

func LoadExportFromEnv() (ExportConfig, error) {
cfg := ExportConfig{
AuthConfig: LoadAuthFromEnv(),
OutDir: strings.TrimSpace(os.Getenv("THREESCALE_OUTPUT_DIR")),
PerPage: DefaultPerPage,
MaxConcurrent: DefaultMaxConcurrent,
ToolboxImage: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_IMAGE")),
ToolboxRuntime: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_RUNTIME")),
ToolboxNativeBinary: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_BINARY")),
ToolboxCertFile: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_TLS_CERT")),
AuthConfig: LoadAuthFromEnv(),
OutDir: strings.TrimSpace(os.Getenv("THREESCALE_OUTPUT_DIR")),
PerPage: DefaultPerPage,
MaxConcurrent: DefaultMaxConcurrent,
ToolboxImage: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_IMAGE")),
ToolboxRuntime: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_RUNTIME")),
ToolboxNativeBinary: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_BINARY")),
ToolboxCertFile: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_TLS_CERT")),
ToolboxPlatform: strings.TrimSpace(os.Getenv("THREESCALE_TOOLBOX_PLATFORM")),
}
return cfg, cfg.ValidateAuth()
}
Expand Down Expand Up @@ -80,6 +82,7 @@ func BindExportFlags(fs *pflag.FlagSet, cfg *ExportConfig) {
fs.StringVar(&cfg.ToolboxRuntime, "toolbox-runtime", cfg.ToolboxRuntime, "container runtime for toolbox (docker or podman; auto-detects if empty)")
fs.StringVar(&cfg.ToolboxNativeBinary, "toolbox-binary", cfg.ToolboxNativeBinary, "optional local 3scale binary instead of container")
fs.StringVar(&cfg.ToolboxCertFile, "toolbox-tls-cert", cfg.ToolboxCertFile, "CA/cert file mounted into toolbox container for TLS")
fs.StringVar(&cfg.ToolboxPlatform, "toolbox-platform", cfg.ToolboxPlatform, "container platform for toolbox run (e.g. linux/amd64); empty omits --platform")
fs.BoolVar(&cfg.Quiet, "quiet", cfg.Quiet, "suppress progress output on stderr")
fs.BoolVar(&cfg.Verbose, "verbose", cfg.Verbose, "show detailed progress (e.g. toolbox invocations)")
if cfg.ToolboxImage == "" {
Expand Down
32 changes: 32 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,34 @@ func TestLoadExportFromEnv(t *testing.T) {
}
}

func TestLoadExportFromEnvToolboxPlatform(t *testing.T) {
t.Setenv("THREESCALE_ADMIN_URL", "https://tenant.example.com")
t.Setenv("THREESCALE_ACCESS_TOKEN", "secret")
t.Setenv("THREESCALE_TOOLBOX_PLATFORM", "linux/amd64")

cfg, err := LoadExportFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.ToolboxPlatform != "linux/amd64" {
t.Fatalf("ToolboxPlatform = %q", cfg.ToolboxPlatform)
}
}

func TestLoadExportFromEnvToolboxPlatformWhitespace(t *testing.T) {
t.Setenv("THREESCALE_ADMIN_URL", "https://tenant.example.com")
t.Setenv("THREESCALE_ACCESS_TOKEN", "secret")
t.Setenv("THREESCALE_TOOLBOX_PLATFORM", " ")

cfg, err := LoadExportFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.ToolboxPlatform != "" {
t.Fatalf("whitespace ToolboxPlatform must be empty, got %q", cfg.ToolboxPlatform)
}
}

func TestBindExportFlags(t *testing.T) {
cfg := ExportConfig{PerPage: DefaultPerPage, MaxConcurrent: DefaultMaxConcurrent}
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
Expand All @@ -131,6 +159,7 @@ func TestBindExportFlags(t *testing.T) {
"--insecure",
"--toolbox-runtime", "docker",
"--toolbox-binary", "/usr/bin/3scale",
"--toolbox-platform", "linux/amd64",
}); err != nil {
t.Fatal(err)
}
Expand All @@ -146,6 +175,9 @@ func TestBindExportFlags(t *testing.T) {
if cfg.ToolboxRuntime != "docker" || cfg.ToolboxNativeBinary != "/usr/bin/3scale" {
t.Fatalf("toolbox cfg = %#v", cfg)
}
if cfg.ToolboxPlatform != "linux/amd64" {
t.Fatalf("ToolboxPlatform = %q", cfg.ToolboxPlatform)
}
}

func TestBindExportFlagsDefaultToolboxImage(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/export/toolbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type ToolboxOptions struct {
NativeBinary string
// CertFile mounts a CA/cert for toolbox TLS (SSL_CERT_FILE in container).
CertFile string
// Platform is passed to docker/podman run --platform when non-empty (container path only).
Platform string
// Insecure passes -k to toolbox to skip TLS verification (lab tenants).
Insecure bool
// CommandRunner overrides process execution (defaults to os/exec).
Expand All @@ -59,6 +61,7 @@ type Toolbox struct {
image string
nativeBinary string
certFile string
platform string
insecure bool
runner CommandRunner
onVerbose func(string)
Expand All @@ -70,6 +73,7 @@ func NewToolbox(opts ToolboxOptions) (*Toolbox, error) {
image: strings.TrimSpace(opts.Image),
nativeBinary: strings.TrimSpace(opts.NativeBinary),
certFile: strings.TrimSpace(opts.CertFile),
platform: strings.TrimSpace(opts.Platform),
insecure: opts.Insecure,
runner: opts.CommandRunner,
onVerbose: opts.OnVerbose,
Expand Down Expand Up @@ -141,6 +145,9 @@ func (t *Toolbox) runNative(ctx context.Context, remoteURL, systemName string) (

func (t *Toolbox) runContainer(ctx context.Context, remoteURL, systemName string) ([]byte, error) {
args := []string{"run", "--rm"}
if p := strings.TrimSpace(t.platform); p != "" {
args = append(args, "--platform", p)
}
if t.certFile != "" {
args = append(args,
"--env", "SSL_CERT_FILE=/tmp/3scale-toolbox-cert.pem",
Expand Down
136 changes: 136 additions & 0 deletions internal/export/toolbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,142 @@ func TestRunContainerArgs(t *testing.T) {
}
}

func TestRunContainerPlatformLinuxAmd64(t *testing.T) {
var captured []string
runner := &mockCommandRunner{
fn: func(_ string, args []string) ([]byte, []byte, error) {
captured = append([]string(nil), args...)
return []byte("apiVersion: v1\nkind: Product\n"), nil, nil
},
}
tb := &Toolbox{
runtime: "podman",
image: DefaultToolboxImage,
platform: "linux/amd64",
runner: runner,
}
if _, err := tb.ExportProduct(context.Background(), "https://admin.example.com", "tok", "payments"); err != nil {
t.Fatal(err)
}
if len(captured) < 4 {
t.Fatalf("args too short: %v", captured)
}
if captured[0] != "run" || captured[1] != "--rm" || captured[2] != "--platform" || captured[3] != "linux/amd64" {
t.Fatalf("expected run --rm --platform linux/amd64…, got %v", captured)
}
}

func TestRunContainerPlatformEmptyOmitsFlag(t *testing.T) {
for _, platform := range []string{"", " ", "\t"} {
t.Run("platform="+platform, func(t *testing.T) {
var captured []string
runner := &mockCommandRunner{
fn: func(_ string, args []string) ([]byte, []byte, error) {
captured = append([]string(nil), args...)
return []byte("apiVersion: v1\nkind: Product\n"), nil, nil
},
}
tb := &Toolbox{
runtime: "podman",
image: DefaultToolboxImage,
platform: platform,
runner: runner,
}
if _, err := tb.ExportProduct(context.Background(), "https://admin.example.com", "tok", "payments"); err != nil {
t.Fatal(err)
}
if len(captured) < 2 || captured[0] != "run" || captured[1] != "--rm" {
t.Fatalf("expected run --rm…, got %v", captured)
}
for _, arg := range captured {
if arg == "--platform" {
t.Fatalf("--platform must be omitted for empty/whitespace platform, got %v", captured)
}
}
})
}
}

func TestRunContainerPlatformBeforeCertMounts(t *testing.T) {
var captured []string
runner := &mockCommandRunner{
fn: func(_ string, args []string) ([]byte, []byte, error) {
captured = append([]string(nil), args...)
return []byte("apiVersion: v1\nkind: Product\n"), nil, nil
},
}
tb := &Toolbox{
runtime: "podman",
image: DefaultToolboxImage,
platform: "linux/amd64",
certFile: "/etc/ssl/certs/custom.pem",
runner: runner,
}
if _, err := tb.ExportProduct(context.Background(), "https://admin.example.com", "tok", "payments"); err != nil {
t.Fatal(err)
}
wantPrefix := []string{
"run", "--rm", "--platform", "linux/amd64",
"--env", "SSL_CERT_FILE=/tmp/3scale-toolbox-cert.pem",
"-v", "/etc/ssl/certs/custom.pem:/tmp/3scale-toolbox-cert.pem:ro",
DefaultToolboxImage,
}
if len(captured) < len(wantPrefix) {
t.Fatalf("args too short: %v", captured)
}
for i, want := range wantPrefix {
if captured[i] != want {
t.Fatalf("args[%d]=%q want %q; full=%v", i, captured[i], want, captured)
}
}
}

func TestExportProductNativeIgnoresPlatform(t *testing.T) {
var captured struct {
command string
args []string
}
runner := &mockCommandRunner{
fn: func(command string, args []string) ([]byte, []byte, error) {
captured.command = command
captured.args = append([]string(nil), args...)
return []byte("kind: Product\n"), nil, nil
},
}
tb := &Toolbox{
nativeBinary: "/usr/bin/3scale",
platform: "linux/amd64",
runner: runner,
}
if _, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo_api"); err != nil {
t.Fatal(err)
}
if captured.command != "/usr/bin/3scale" {
t.Fatalf("command = %q", captured.command)
}
for _, arg := range captured.args {
if arg == "run" || arg == "--platform" || arg == "linux/amd64" {
t.Fatalf("native argv must not include container platform tokens: %v", captured.args)
}
}
if len(captured.args) != 4 || captured.args[0] != "product" || captured.args[3] != "demo_api" {
t.Fatalf("args = %v", captured.args)
}
}

func TestNewToolboxPlatformTrimSpace(t *testing.T) {
tb, err := NewToolbox(ToolboxOptions{
NativeBinary: "/usr/bin/3scale",
Platform: " linux/amd64 ",
})
if err != nil {
t.Fatal(err)
}
if tb.platform != "linux/amd64" {
t.Fatalf("platform after TrimSpace = %q", tb.platform)
}
}

func TestRunContainerArgsInsecure(t *testing.T) {
var captured []string
runner := &mockCommandRunner{
Expand Down
Loading