From 373b8502cf9e55fb4c822013fbcf3f90a8543950 Mon Sep 17 00:00:00 2001 From: Super User Date: Fri, 25 Sep 2026 17:47:09 -0300 Subject: [PATCH] feat(export): add --toolbox-platform for container run Opt-in THREESCALE_TOOLBOX_PLATFORM / --toolbox-platform inserts --platform after run --rm so Apple Silicon hosts can force linux/amd64 without auto-defaulting empty values. Co-authored-by: Cursor --- CHANGELOG.md | 4 + README.md | 9 ++- internal/cli/cli.go | 1 + internal/config/config.go | 19 +++-- internal/config/config_test.go | 32 ++++++++ internal/export/toolbox.go | 7 ++ internal/export/toolbox_test.go | 136 ++++++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f4839..c888585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5c161f8..b49b770 100644 --- a/README.md +++ b/README.md @@ -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 ... +``` --- @@ -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 @@ -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) | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 56dae16..e82582a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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), }) diff --git a/internal/config/config.go b/internal/config/config.go index dc4c806..40e9088 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() } @@ -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 == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9556b87..0de7859 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) @@ -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) } @@ -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) { diff --git a/internal/export/toolbox.go b/internal/export/toolbox.go index fc3d753..382f16b 100644 --- a/internal/export/toolbox.go +++ b/internal/export/toolbox.go @@ -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). @@ -59,6 +61,7 @@ type Toolbox struct { image string nativeBinary string certFile string + platform string insecure bool runner CommandRunner onVerbose func(string) @@ -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, @@ -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", diff --git a/internal/export/toolbox_test.go b/internal/export/toolbox_test.go index c9f0fe5..974473a 100644 --- a/internal/export/toolbox_test.go +++ b/internal/export/toolbox_test.go @@ -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{