diff --git a/client_methods.go b/client_methods.go index 4169aa81..05ddec19 100644 --- a/client_methods.go +++ b/client_methods.go @@ -247,7 +247,9 @@ func (c *Client) Download(ctx context.Context, pkg PkgInfo) (io.ReadCloser, erro } // Install installs a driver with the given name to the specified configuration. -func (c *Client) Install(ctx context.Context, cfg config.Config, driverName string) (*config.Manifest, error) { +// platform selects the driver package platform tuple; when empty, the host +// platform is used. +func (c *Client) Install(ctx context.Context, cfg config.Config, driverName string, platform config.Platform) (*config.Manifest, error) { drivers, err := c.Search(ctx, driverName) // Only fail if the driver wasn't found in any registry; partial registry errors // are acceptable as long as we can still locate the target driver. @@ -267,7 +269,9 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri return nil, fmt.Errorf("driver %q not found", driverName) } - pkg, err := found.GetPackage(nil, config.PlatformTuple(), false) + targetPlatform := platform.Resolve() + + pkg, err := found.GetPackage(nil, targetPlatform, false) if err != nil { return nil, fmt.Errorf("failed to get package for driver %s: %w", driverName, err) } @@ -278,7 +282,7 @@ func (c *Client) Install(ctx context.Context, cfg config.Config, driverName stri } defer os.RemoveAll(filepath.Dir(f.Name())) - manifest, err := config.InstallDriver(cfg, driverName, f) + manifest, err := config.InstallDriver(cfg, driverName, f, targetPlatform) if err != nil { return nil, fmt.Errorf("failed to install driver %s: %w", driverName, err) } diff --git a/client_methods_test.go b/client_methods_test.go index 744a685b..2fb30a66 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -119,15 +119,25 @@ func TestClientInstall(t *testing.T) { } t.Run("installs driver successfully", func(t *testing.T) { - manifest, err := c.Install(t.Context(), cfg, "test-driver-1") + manifest, err := c.Install(t.Context(), cfg, "test-driver-1", "") require.NoError(t, err) require.NotNil(t, manifest) assert.Equal(t, "test-driver-1", manifest.DriverInfo.ID) assert.NotNil(t, manifest.DriverInfo.Version) }) + t.Run("installs driver for explicit platform", func(t *testing.T) { + const platform = "linux_amd64" + manifest, err := c.Install(t.Context(), cfg, "test-driver-1", config.Platform(platform)) + require.NoError(t, err) + require.NotNil(t, manifest) + sharedPath := manifest.DriverInfo.Driver.Shared.Get(platform) + assert.NotEmpty(t, sharedPath) + assert.FileExists(t, sharedPath) + }) + t.Run("returns error for nonexistent driver", func(t *testing.T) { - _, err := c.Install(t.Context(), cfg, "nonexistent-driver") + _, err := c.Install(t.Context(), cfg, "nonexistent-driver", "") assert.Error(t, err) assert.Contains(t, err.Error(), "nonexistent-driver") }) @@ -144,7 +154,7 @@ func TestClientUninstall(t *testing.T) { Location: tmpDir, } - _, err := c.Install(t.Context(), cfg, "test-driver-1") + _, err := c.Install(t.Context(), cfg, "test-driver-1", "") require.NoError(t, err) manifestPath := filepath.Join(tmpDir, "test-driver-1.toml") diff --git a/cmd/dbc/install.go b/cmd/dbc/install.go index be06fd35..34e14342 100644 --- a/cmd/dbc/install.go +++ b/cmd/dbc/install.go @@ -66,6 +66,7 @@ type InstallCmd struct { // URI url.URL `arg:"-u" placeholder:"URL" help:"Base URL for fetching drivers"` Driver string `arg:"positional,required" help:"Driver to install, optionally with a version constraint (for example: mysql, mysql=0.1.0, mysql>=1,<2)"` Level config.ConfigLevel `arg:"-l" help:"Config level to install to (user, system)"` + Platform config.Platform `arg:"--platform" placeholder:"TUPLE" help:"Platform tuple to install for (e.g. linux_amd64). Defaults to the host platform."` Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` JsonStreamProgress bool `arg:"--json-stream-progress" help:"Stream progress events as JSON lines (implies --json)"` NoVerify bool `arg:"--no-verify" help:"Allow installation of drivers without a signature file"` @@ -89,6 +90,7 @@ func (c InstallCmd) GetModelCustom(baseModel baseModel) tea.Model { } return progressiveInstallModel{ Driver: c.Driver, + platform: c.Platform.Resolve(), NoVerify: c.NoVerify, jsonOutput: c.Json || c.JsonStreamProgress, jsonStreamProgress: c.JsonStreamProgress, @@ -111,12 +113,12 @@ func (c InstallCmd) GetModel() tea.Model { return c.GetModelCustom(defaultBaseModel()) } -func verifySignature(m config.Manifest, noVerify bool) error { +func verifySignature(m config.Manifest, platform string, noVerify bool) error { if m.Files.Driver == "" || noVerify { return nil } - path := filepath.Dir(m.Driver.Shared.Get(config.PlatformTuple())) + path := filepath.Dir(m.Driver.Shared.Get(platform)) lib, err := os.Open(filepath.Join(path, m.Files.Driver)) if err != nil { @@ -215,6 +217,7 @@ type progressiveInstallModel struct { baseModel Driver string + platform string VersionInput *semver.Version NoVerify bool jsonOutput bool @@ -353,8 +356,8 @@ func (m progressiveInstallModel) FinalOutput() string { installStatus.Message = m.postInstallMessage } - if !m.insecureNoChecksum && m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { - driverPath := m.installedDriverInfo.Driver.Shared.Get(config.PlatformTuple()) + if !m.insecureNoChecksum && m.installedDriverInfo.Driver.Shared.Get(m.platform) != "" { + driverPath := m.installedDriverInfo.Driver.Shared.Get(m.platform) chksum, err := checksum(driverPath) if err != nil && m.jsonOutput { return marshalEnvelope("error", jsonschema.ErrorResponse{ @@ -415,14 +418,14 @@ func (m progressiveInstallModel) searchForDriver(list []dbc.Driver) (tea.Model, return m, func() tea.Msg { if vers != nil { vers.IncludePrerelease = m.Pre - pkg, err := d.GetWithConstraint(vers, config.PlatformTuple()) + pkg, err := d.GetWithConstraint(vers, m.platform) if err != nil { return err } return pkg } - pkg, err := d.GetPackage(nil, config.PlatformTuple(), m.Pre) + pkg, err := d.GetPackage(nil, m.platform, m.Pre) if err != nil { if !m.Pre && !d.HasNonPrerelease() { for _, cfg := range config.Get() { @@ -442,8 +445,8 @@ func (m progressiveInstallModel) startDownloading() (tea.Model, tea.Cmd) { m.state = stDownloading if m.isAlreadyInstalled() { m.state = stDone - if m.jsonOutput && !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) != "" { - driverPath := m.conflictingInfo.Driver.Shared.Get(config.PlatformTuple()) + if m.jsonOutput && !m.insecureNoChecksum && m.conflictingInfo.Driver.Shared.Get(m.platform) != "" { + driverPath := m.conflictingInfo.Driver.Shared.Get(m.platform) return m, func() tea.Msg { chksum, err := checksum(driverPath) if err != nil { @@ -470,7 +473,7 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model if m.isLocal { driverName := strings.TrimSuffix( strings.TrimSuffix(filepath.Base(m.Driver), ".tar.gz"), ".tgz") - parts := strings.Split(driverName, "_"+config.PlatformTuple()+"_") + parts := strings.Split(driverName, "_"+m.platform+"_") if len(parts) < 2 { m.Driver = driverName } else { @@ -485,7 +488,7 @@ func (m progressiveInstallModel) startInstalling(downloaded *os.File) (tea.Model } } - manifest, err := config.InstallDriver(m.cfg, m.Driver, downloaded) + manifest, err := config.InstallDriver(m.cfg, m.Driver, downloaded, m.platform) if err != nil { return err } @@ -557,8 +560,8 @@ func (m progressiveInstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.addEvent("extract.complete") m = m.addEvent("verify.start") return m, func() tea.Msg { - if err := verifySignature(msg, m.NoVerify); err != nil { - path := filepath.Dir(msg.Driver.Shared.Get(config.PlatformTuple())) + if err := verifySignature(msg, m.platform, m.NoVerify); err != nil { + path := filepath.Dir(msg.Driver.Shared.Get(m.platform)) _ = os.RemoveAll(path) return err } diff --git a/cmd/dbc/install_test.go b/cmd/dbc/install_test.go index decd17c5..b9c7ab70 100644 --- a/cmd/dbc/install_test.go +++ b/cmd/dbc/install_test.go @@ -629,3 +629,26 @@ func (suite *SubcommandTestSuite) TestInstallJSON_AlreadyInstalledChecksumFailur suite.Require().NoError(json.Unmarshal(errEnv.Payload, &errPayload)) suite.Equal("install_failed", errPayload.Code, "expected install_failed error code") } + +func (suite *SubcommandTestSuite) TestInstallWithPlatform() { + const platform = "linux_amd64" + m := InstallCmd{ + Driver: "test-driver-1", + Level: suite.configLevel, + Platform: config.Platform(platform), + }.GetModelCustom(testBaseModel()) + out := suite.runCmd(m) + + suite.validateOutput( + "\r[✓] searching\r\n[✓] downloading\r\n[✓] installing\r\n[✓] verifying signature\r\n", + "\nInstalled test-driver-1 1.1.0 to "+suite.Dir(), out) + + driver := suite.getInstalledDriver("test-driver-1") + sharedPath := driver.Driver.Shared.Get(platform) + suite.NotEmpty(sharedPath, "manifest should record the requested platform tuple") + suite.FileExists(sharedPath) + if platform != config.PlatformTuple() { + suite.Empty(driver.Driver.Shared.Get(config.PlatformTuple()), + "manifest should not use the host platform tuple when --platform is set") + } +} diff --git a/cmd/dbc/main_test.go b/cmd/dbc/main_test.go index cc61c86a..584b003c 100644 --- a/cmd/dbc/main_test.go +++ b/cmd/dbc/main_test.go @@ -136,6 +136,19 @@ func TestInstallHelpMentionsVersionConstraints(t *testing.T) { require.Contains(t, out, `dbc install "mysql=0.1.0"`) require.Contains(t, out, `dbc install "mysql>=1,<2"`) require.Contains(t, out, "https://docs.columnar.tech/dbc/guides/installing/#version-constraints") + require.Contains(t, out, "--platform") +} + +func TestInstallInvalidPlatformRejectedAtParse(t *testing.T) { + args := &cmds{} + p, err := newParser(args) + require.NoError(t, err) + + err = p.Parse([]string{"install", "--platform", "noos_noarch", "mysql"}) + require.Error(t, err) + require.NotErrorIs(t, err, arg.ErrHelp) + require.Contains(t, err.Error(), "unknown platform") + require.Contains(t, err.Error(), "valid values are:") } func TestSubcommandSuggestions(t *testing.T) { diff --git a/cmd/dbc/sync.go b/cmd/dbc/sync.go index ab8337bc..5e95e29e 100644 --- a/cmd/dbc/sync.go +++ b/cmd/dbc/sync.go @@ -321,7 +321,7 @@ func (s syncModel) installDriver(cfg config.Config, item installItem) tea.Cmd { manifest.DriverInfo.Source = "dbc" manifest.DriverInfo.Driver.Shared.Set(config.PlatformTuple(), driverPath) - if err := verifySignature(manifest, s.NoVerify); err != nil { + if err := verifySignature(manifest, config.PlatformTuple(), s.NoVerify); err != nil { _ = os.RemoveAll(finalDir) prog.Send(fmt.Errorf("failed to verify signature: %w", err)) return diff --git a/config/config.go b/config/config.go index a7b725fb..f1575da1 100644 --- a/config/config.go +++ b/config/config.go @@ -223,7 +223,7 @@ func getEnvConfigDir() string { return strings.Join(envConfigLoc, string(filepath.ListSeparator)) } -func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, error) { +func InstallDriver(cfg Config, shortName string, downloaded *os.File, platform string) (Manifest, error) { var ( loc string err error @@ -247,7 +247,7 @@ func InstallDriver(cfg Config, shortName string, downloaded *os.File) (Manifest, manifest.DriverInfo.ID = shortName manifest.DriverInfo.Source = "dbc" - manifest.DriverInfo.Driver.Shared.Set(PlatformTuple(), driverPath) + manifest.DriverInfo.Driver.Shared.Set(platform, driverPath) return manifest, nil } diff --git a/config/config_api_test.go b/config/config_api_test.go index 8422caaf..56ca7aff 100644 --- a/config/config_api_test.go +++ b/config/config_api_test.go @@ -154,7 +154,7 @@ func TestInstallDriver(t *testing.T) { f, err := os.Open(filepath.Join("..", "cmd", "dbc", "testdata", "test-driver-1.tar.gz")) require.NoError(t, err) - manifest, err := config.InstallDriver(cfg, "test-driver-1", f) + manifest, err := config.InstallDriver(cfg, "test-driver-1", f, config.PlatformTuple()) require.NoError(t, err) assert.Equal(t, "test-driver-1", manifest.DriverInfo.ID) @@ -168,6 +168,30 @@ func TestInstallDriver(t *testing.T) { assert.FileExists(t, sharedPath) }) + t.Run("records explicit platform in manifest", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("ADBC_DRIVER_PATH", tmpDir) + + cfg := config.Config{ + Level: config.ConfigEnv, + Location: tmpDir, + } + + f, err := os.Open(filepath.Join("..", "cmd", "dbc", "testdata", "test-driver-1.tar.gz")) + require.NoError(t, err) + + const platform = "linux_amd64" + manifest, err := config.InstallDriver(cfg, "test-driver-1", f, platform) + require.NoError(t, err) + + sharedPath := manifest.DriverInfo.Driver.Shared.Get(platform) + assert.NotEmpty(t, sharedPath) + assert.FileExists(t, sharedPath) + if platform != config.PlatformTuple() { + assert.Empty(t, manifest.DriverInfo.Driver.Shared.Get(config.PlatformTuple())) + } + }) + t.Run("invalid_tarball", func(t *testing.T) { tmpDir := t.TempDir() cfg := config.Config{ @@ -180,7 +204,7 @@ func TestInstallDriver(t *testing.T) { _, _ = f.Write([]byte("not a tarball")) _, _ = f.Seek(0, io.SeekStart) - _, err = config.InstallDriver(cfg, "bad-driver", f) + _, err = config.InstallDriver(cfg, "bad-driver", f, config.PlatformTuple()) assert.Error(t, err) }) } diff --git a/config/platform.go b/config/platform.go new file mode 100644 index 00000000..f53f6648 --- /dev/null +++ b/config/platform.go @@ -0,0 +1,69 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "slices" + "strings" +) + +// Platform is an optional install target platform tuple (e.g. "linux_amd64"). +// When unset, callers should use PlatformTuple() for the host platform. +type Platform string + +// Valid platform tuples as in https://dbc-cdn.columnar.tech/index.yaml +// Please keep up-to-date with the registry index! +var validPlatformTuples = []string{ + "linux_amd64", + "linux_arm64", + "macos_amd64", + "macos_arm64", + "windows_amd64", + "windows_arm64", +} + +func ValidPlatformTuples() []string { + return slices.Clone(validPlatformTuples) +} + +func IsValidPlatformTuple(tuple string) bool { + return slices.Contains(validPlatformTuples, tuple) +} + +func (p Platform) String() string { + return string(p) +} + +// Resolve returns the explicit platform tuple, or the host platform when unset. +func (p Platform) Resolve() string { + if p == "" { + return PlatformTuple() + } + return string(p) +} + +func (p *Platform) UnmarshalText(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "" { + *p = "" + return nil + } + if !IsValidPlatformTuple(s) { + return fmt.Errorf("unknown platform %q, valid values are: %s", s, strings.Join(validPlatformTuples, ", ")) + } + *p = Platform(s) + return nil +} diff --git a/config/platform_test.go b/config/platform_test.go new file mode 100644 index 00000000..c3477d79 --- /dev/null +++ b/config/platform_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Columnar Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlatformUnmarshalText(t *testing.T) { + valid := []string{ + "linux_amd64", + "linux_arm64", + "macos_amd64", + "macos_arm64", + "windows_amd64", + "windows_arm64", + } + for _, tuple := range valid { + var p Platform + assert.NoError(t, p.UnmarshalText([]byte(tuple)), "tuple %q", tuple) + assert.Equal(t, Platform(tuple), p) + } + + invalid := []string{ + "darwin_arm64", // wrong OS name + "linux", // missing arch + "junk", // invalid name + "LINUX_AMD64", // wrong case + } + for _, tuple := range invalid { + var p Platform + err := p.UnmarshalText([]byte(tuple)) + assert.ErrorContains(t, err, "unknown platform") + assert.ErrorContains(t, err, "valid values are:") + } +} + +func TestPlatformResolve(t *testing.T) { + assert.Equal(t, PlatformTuple(), Platform("").Resolve()) + assert.Equal(t, "linux_amd64", Platform("linux_amd64").Resolve()) +} diff --git a/wasm/ops_node_js.go b/wasm/ops_node_js.go index 06ab2ecd..60ac2279 100644 --- a/wasm/ops_node_js.go +++ b/wasm/ops_node_js.go @@ -46,7 +46,7 @@ func jsInstall(args []js.Value) func() (any, error) { if err != nil { return nil, err } - m, err := c.Install(context.Background(), config.Config{Level: config.ConfigEnv, Location: location}, name) + m, err := c.Install(context.Background(), config.Config{Level: config.ConfigEnv, Location: location}, name, "") if err != nil { return nil, err }