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
42 changes: 30 additions & 12 deletions internal/export/toolbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,39 @@ type ProductExporter interface {
ExportProduct(ctx context.Context, adminURL, token, systemName string) ([]byte, error)
}

// CommandRunner executes external processes. Inject a mock in tests to avoid real exec.
type CommandRunner interface {
Run(ctx context.Context, command string, args []string) (stdout, stderr []byte, err error)
}

type execCommandRunner struct{}

func (execCommandRunner) Run(ctx context.Context, command string, args []string) ([]byte, []byte, error) {
cmd := exec.CommandContext(ctx, command, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.Bytes(), stderr.Bytes(), err
}

type ToolboxOptions struct {
Runtime string // podman or docker; empty auto-detects
Image string
// NativeBinary, if set, runs the local 3scale binary instead of a container.
NativeBinary string
// CertFile mounts a CA/cert for toolbox TLS (SSL_CERT_FILE in container).
CertFile string
// CommandRunner overrides process execution (defaults to os/exec).
CommandRunner CommandRunner
}

type Toolbox struct {
runtime string
image string
nativeBinary string
certFile string
runner CommandRunner
}

func NewToolbox(opts ToolboxOptions) (*Toolbox, error) {
Expand All @@ -45,6 +64,10 @@ func NewToolbox(opts ToolboxOptions) (*Toolbox, error) {
image: strings.TrimSpace(opts.Image),
nativeBinary: strings.TrimSpace(opts.NativeBinary),
certFile: strings.TrimSpace(opts.CertFile),
runner: opts.CommandRunner,
}
if t.runner == nil {
t.runner = execCommandRunner{}
}
if t.image == "" {
t.image = DefaultToolboxImage
Expand Down Expand Up @@ -105,9 +128,8 @@ func buildRemoteURL(adminURL, token string) (string, error) {
}

func (t *Toolbox) runNative(ctx context.Context, remoteURL, systemName string) ([]byte, error) {
// Official syntax: 3scale product export <remote> <product>
args := []string{"product", "export", remoteURL, systemName}
return runCommand(ctx, t.nativeBinary, args)
return t.runCommand(ctx, t.nativeBinary, args)
}

func (t *Toolbox) runContainer(ctx context.Context, remoteURL, systemName string) ([]byte, error) {
Expand All @@ -119,24 +141,20 @@ func (t *Toolbox) runContainer(ctx context.Context, remoteURL, systemName string
)
}
args = append(args, t.image, "3scale", "product", "export", remoteURL, systemName)
return runCommand(ctx, t.runtime, args)
return t.runCommand(ctx, t.runtime, args)
}

func runCommand(ctx context.Context, command string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, command, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
func (t *Toolbox) runCommand(ctx context.Context, command string, args []string) ([]byte, error) {
stdout, stderr, err := t.runner.Run(ctx, command, args)
if err != nil {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("%w: %s", ErrToolboxFailed, msg)
}

out := bytes.TrimSpace(stdout.Bytes())
out := bytes.TrimSpace(stdout)
if len(out) == 0 {
return nil, fmt.Errorf("%w: empty output from %s", ErrToolboxFailed, command)
}
Expand Down
116 changes: 109 additions & 7 deletions internal/export/toolbox_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package export

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -104,15 +106,115 @@ func TestNewToolboxNativeMode(t *testing.T) {
}

func TestRunContainerArgs(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("apiVersion: v1\nkind: Product\n"), nil, nil
},
}
tb := &Toolbox{
runtime: "podman",
image: DefaultToolboxImage,
runtime: "podman",
image: DefaultToolboxImage,
certFile: "/etc/ssl/certs/custom.pem",
runner: runner,
}
out, err := tb.ExportProduct(context.Background(), "https://admin.example.com", "tok", "payments")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "apiVersion") {
t.Fatalf("output = %q", out)
}
if captured.command != "podman" {
t.Fatalf("command = %q", captured.command)
}
joined := strings.Join(captured.args, " ")
for _, want := range []string{
"run", "--rm",
"SSL_CERT_FILE=/tmp/3scale-toolbox-cert.pem",
"/etc/ssl/certs/custom.pem:/tmp/3scale-toolbox-cert.pem:ro",
DefaultToolboxImage,
"3scale", "product", "export",
"payments",
} {
if !strings.Contains(joined, want) {
t.Fatalf("args missing %q: %v", want, captured.args)
}
}
if !strings.Contains(joined, "tok@admin.example.com") {
t.Fatalf("remote URL not embedded: %v", captured.args)
}
}

func TestExportProductNativeUsesRunner(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",
runner: runner,
}
if _, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo_api"); err != nil {
t.Fatal(err)
}
// exercise internal path via ExportProduct would need mock exec; verify URL builder instead
remote, err := buildRemoteURL("https://admin.example.com", "tok")
if err != nil || !strings.Contains(remote, "tok@") {
t.Fatalf("remote = %q err = %v", remote, err)
if captured.command != "/usr/bin/3scale" {
t.Fatalf("command = %q", captured.command)
}
if len(captured.args) != 4 || captured.args[0] != "product" || captured.args[3] != "demo_api" {
t.Fatalf("args = %v", captured.args)
}
}

func TestRunCommandEmptyOutput(t *testing.T) {
tb := &Toolbox{
nativeBinary: "/usr/bin/3scale",
runner: &mockCommandRunner{
fn: func(string, []string) ([]byte, []byte, error) {
return nil, nil, nil
},
},
}
_, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo")
if err == nil || !errors.Is(err, ErrToolboxFailed) {
t.Fatalf("err = %v", err)
}
}

func TestRunCommandStderrError(t *testing.T) {
tb := &Toolbox{
nativeBinary: "/usr/bin/3scale",
runner: &mockCommandRunner{
fn: func(string, []string) ([]byte, []byte, error) {
return nil, []byte("connection refused"), errors.New("exit 1")
},
},
}
_, err := tb.ExportProduct(context.Background(), "https://tenant.example.com", "secret", "demo")
if err == nil || !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("err = %v", err)
}
}

type mockCommandRunner struct {
fn func(command string, args []string) (stdout, stderr []byte, err error)
}

func (m *mockCommandRunner) Run(_ context.Context, command string, args []string) ([]byte, []byte, error) {
if m.fn == nil {
return nil, nil, errors.New("mock not configured")
}
_ = tb
return m.fn(command, args)
}
Loading