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/list.go b/cmd/dbc/list.go index 6f06db47..e43c5a40 100644 --- a/cmd/dbc/list.go +++ b/cmd/dbc/list.go @@ -29,8 +29,9 @@ import ( ) type ListCmd struct { - Level config.ConfigLevel `arg:"-l" help:"Only list drivers installed at this config level (user, system)"` - Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` + Level config.ConfigLevel `arg:"-l" help:"Only list drivers installed at this config level (user, system)"` + AllPlatforms bool `arg:"--all-platforms" help:"List drivers for all platforms, including those not loadable on this host"` + Json bool `arg:"--json" help:"Print output as JSON instead of plaintext"` } func (ListCmd) Description() string { @@ -39,17 +40,19 @@ func (ListCmd) Description() string { func (c ListCmd) GetModel() tea.Model { return listModel{ - level: c.Level, - jsonOutput: c.Json, + level: c.Level, + allPlatforms: c.AllPlatforms, + jsonOutput: c.Json, } } type installedDriver struct { - Level config.ConfigLevel - ID string - Name string - Version string - Path string + Level config.ConfigLevel + ID string + Name string + Version string + Path string + Platform string } type installedDriversMsg []installedDriver @@ -57,14 +60,44 @@ type installedDriversMsg []installedDriver type listModel struct { baseModel - level config.ConfigLevel - jsonOutput bool - drivers []installedDriver + level config.ConfigLevel + allPlatforms bool + jsonOutput bool + drivers []installedDriver +} + +func driverAvailableOnHost(d config.DriverInfo, hostPlatform string) bool { + return d.Driver.Shared.HasPlatform(hostPlatform) +} + +func formatPlatformsColumn(d config.DriverInfo, hostPlatform string) string { + // If the driver is configured with a default path (i.e. no explicit + // ` = '/path/to/driver.so'` in the manifest), + // then we assume that the driver is a match for the host platform. + if d.Driver.Shared.UsesDefaultPath() { + return hostPlatform + " (*)" + } + + platforms := d.Driver.Shared.PlatformTuples() + if len(platforms) == 0 { + return "" + } + + parts := make([]string, len(platforms)) + for i, platform := range platforms { + if platform == hostPlatform { + parts[i] = platform + " (*)" + } else { + parts[i] = platform + } + } + return strings.Join(parts, ", ") } func (m listModel) Init() tea.Cmd { return func() tea.Msg { cfgs := config.Get() + hostPlatform := config.PlatformTuple() var levels []config.ConfigLevel if m.level == config.ConfigUnknown { @@ -83,17 +116,26 @@ func (m listModel) Init() tea.Cmd { return fmt.Errorf("failed to list drivers at %s level: %w", lvl, cfg.Err) } for _, d := range cfg.Drivers { + if !m.allPlatforms && !driverAvailableOnHost(d, hostPlatform) { + continue + } + version := "" if d.Version != nil { version = d.Version.String() } - drivers = append(drivers, installedDriver{ + + entry := installedDriver{ Level: lvl, ID: d.ID, Name: d.Name, Version: version, Path: d.FilePath, - }) + } + if m.allPlatforms { + entry.Platform = formatPlatformsColumn(d, hostPlatform) + } + drivers = append(drivers, entry) } } @@ -136,20 +178,25 @@ func (m listModel) FinalOutput() string { } if m.jsonOutput { - return listDriversJSON(m.drivers) + return listDriversJSON(m.drivers, m.allPlatforms) } - return formatInstalledDrivers(m.drivers) + return formatInstalledDrivers(m.drivers, m.allPlatforms) } -func formatInstalledDrivers(drivers []installedDriver) string { +func formatInstalledDrivers(drivers []installedDriver, allPlatforms bool) string { if len(drivers) == 0 { lipgloss.Fprintln(os.Stderr, "No drivers installed.") return "" } + headers := []string{"DRIVER", "VERSION", "LEVEL", "LOCATION"} + if allPlatforms { + headers = []string{"DRIVER", "VERSION", "PLATFORM", "LEVEL", "LOCATION"} + } + t := table.New().Border(lipgloss.HiddenBorder()). BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false). - Headers("DRIVER", "VERSION", "LEVEL", "LOCATION") + Headers(headers...) headerStyle := lipgloss.NewStyle().Bold(true) levelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) versionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) @@ -163,27 +210,42 @@ func formatInstalledDrivers(drivers []installedDriver) string { case 1: return versionStyle case 2: + if allPlatforms { + return lipgloss.NewStyle() + } return levelStyle + case 3: + if allPlatforms { + return levelStyle + } } return lipgloss.NewStyle() }) for _, d := range drivers { - t.Row(d.ID, d.Version, d.Level.String(), d.Path) + if allPlatforms { + t.Row(d.ID, d.Version, d.Platform, d.Level.String(), d.Path) + } else { + t.Row(d.ID, d.Version, d.Level.String(), d.Path) + } } return strings.TrimRight(t.String(), "\n") } -func listDriversJSON(drivers []installedDriver) string { +func listDriversJSON(drivers []installedDriver, allPlatforms bool) string { entries := make([]jsonschema.ListDriverEntry, 0, len(drivers)) for _, d := range drivers { - entries = append(entries, jsonschema.ListDriverEntry{ + entry := jsonschema.ListDriverEntry{ Driver: d.ID, Name: d.Name, Version: d.Version, Level: d.Level.String(), Location: d.Path, - }) + } + if allPlatforms { + entry.Platform = d.Platform + } + entries = append(entries, entry) } payloadBytes, err := json.Marshal(jsonschema.ListResponse{Drivers: entries}) if err != nil { diff --git a/cmd/dbc/list_test.go b/cmd/dbc/list_test.go index 9dd02242..823c33c8 100644 --- a/cmd/dbc/list_test.go +++ b/cmd/dbc/list_test.go @@ -118,3 +118,97 @@ func (suite *SubcommandTestSuite) TestListUnreadableConfigJSON() { out := suite.runCmdErr(m) suite.assertJSONErrorEnvelope(out, "list_failed", "failed to list drivers") } + +func (suite *SubcommandTestSuite) TestListHidesNonHostPlatformDriver() { + if runtime.GOOS == "windows" { + suite.T().Skip() + } + + const platform = "linux_amd64" + install := InstallCmd{ + Driver: "test-driver-1", + Level: config.ConfigEnv, + Platform: config.Platform(platform), + }.GetModelCustom(testBaseModel()) + suite.runCmd(install) + + m := ListCmd{Level: config.ConfigEnv}.GetModel() + out := suite.runCmd(m) + + host := config.PlatformTuple() + if host == platform { + suite.Contains(out, "test-driver-1") + } else { + suite.NotContains(out, "test-driver-1") + } +} + +func (suite *SubcommandTestSuite) TestListAllPlatformsShowsCrossPlatformDriver() { + if runtime.GOOS == "windows" { + suite.T().Skip() + } + + const platform = "linux_amd64" + install := InstallCmd{ + Driver: "test-driver-1", + Level: config.ConfigEnv, + Platform: config.Platform(platform), + }.GetModelCustom(testBaseModel()) + suite.runCmd(install) + + m := ListCmd{Level: config.ConfigEnv, AllPlatforms: true}.GetModel() + out := suite.runCmd(m) + + suite.Contains(out, "test-driver-1") + suite.Contains(out, "PLATFORM") + host := config.PlatformTuple() + if host == platform { + suite.Contains(out, platform+" (*)") + } else { + suite.Contains(out, platform) + suite.NotContains(out, platform+" (*)") + } +} + +func (suite *SubcommandTestSuite) TestListAllPlatformsJSONIncludesPlatform() { + if runtime.GOOS == "windows" { + suite.T().Skip() + } + + const platform = "linux_amd64" + install := InstallCmd{ + Driver: "test-driver-1", + Level: config.ConfigEnv, + Platform: config.Platform(platform), + }.GetModelCustom(testBaseModel()) + suite.runCmd(install) + + m := ListCmd{Level: config.ConfigEnv, AllPlatforms: true, Json: true}.GetModel() + out := suite.runCmd(m) + + var env jsonschema.Envelope + suite.Require().NoError(json.Unmarshal([]byte(out), &env)) + suite.Equal("list.results", env.Kind) + + var resp jsonschema.ListResponse + suite.Require().NoError(json.Unmarshal(env.Payload, &resp)) + suite.Len(resp.Drivers, 1) + suite.NotEmpty(resp.Drivers[0].Platform) + suite.Contains(resp.Drivers[0].Platform, platform) +} + +func (suite *SubcommandTestSuite) TestListAllPlatformsMarksHostDriver() { + if runtime.GOOS == "windows" { + suite.T().Skip() + } + + install := InstallCmd{Driver: "test-driver-1", Level: config.ConfigEnv}. + GetModelCustom(testBaseModel()) + suite.runCmd(install) + + m := ListCmd{Level: config.ConfigEnv, AllPlatforms: true}.GetModel() + out := suite.runCmd(m) + + suite.Contains(out, "test-driver-1") + suite.Contains(out, config.PlatformTuple()+" (*)") +} 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/driver.go b/config/driver.go index c53b7767..685aaeab 100644 --- a/config/driver.go +++ b/config/driver.go @@ -21,6 +21,7 @@ import ( "iter" "os" "path/filepath" + "slices" "strings" "github.com/Masterminds/semver/v3" @@ -85,6 +86,29 @@ func (d driverMap) Get(platformTuple string) string { return d.platformMap[platformTuple] } +// HasPlatform reports whether a shared library path is recorded for platform. +func (d driverMap) HasPlatform(platformTuple string) bool { + return d.Get(platformTuple) != "" +} + +// PlatformTuples returns sorted platform keys from a multi-platform shared map. +// When shared is a single default path, the slice is empty. +func (d driverMap) PlatformTuples() []string { + if d.defaultPath != "" { + return nil + } + platforms := make([]string, 0, len(d.platformMap)) + for platform := range d.platformMap { + platforms = append(platforms, platform) + } + slices.Sort(platforms) + return platforms +} + +func (d driverMap) UsesDefaultPath() bool { + return d.defaultPath != "" +} + func (d driverMap) Paths() iter.Seq[string] { if d.defaultPath != "" { return func(yield func(string) bool) { diff --git a/config/driver_test.go b/config/driver_test.go index 6c94ee9a..5b384f1a 100644 --- a/config/driver_test.go +++ b/config/driver_test.go @@ -55,6 +55,19 @@ linux_amd64 = '/path/to/majestik/moose/file' assert.Equal(t, driverName, driverInfo.ID) assert.Equal(t, "/path/to/majestik/moose/file", driverInfo.Driver.Shared.Get("linux_amd64")) + assert.True(t, driverInfo.Driver.Shared.HasPlatform("linux_amd64")) + assert.False(t, driverInfo.Driver.Shared.HasPlatform("macos_arm64")) + assert.Equal(t, []string{"linux_amd64"}, driverInfo.Driver.Shared.PlatformTuples()) +} + +func TestDriverMapDefaultPath(t *testing.T) { + var shared driverMap + shared.defaultPath = "/path/to/driver.so" + + assert.True(t, shared.HasPlatform("linux_amd64")) + assert.True(t, shared.HasPlatform("any_platform")) + assert.True(t, shared.UsesDefaultPath()) + assert.Empty(t, shared.PlatformTuples()) } func TestCreateDriverManifest(t *testing.T) { 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/internal/jsonschema/schema.go b/internal/jsonschema/schema.go index 22e1737d..6d45b715 100644 --- a/internal/jsonschema/schema.go +++ b/internal/jsonschema/schema.go @@ -173,6 +173,8 @@ type ListDriverEntry struct { Level string `json:"level"` // Location is the filesystem path containing the driver's manifest. Location string `json:"location"` + // Platform lists installed platform tuples when dbc list --all-platforms is used. + Platform string `json:"platform,omitempty"` } // ListResponse is the top-level JSON payload for the list command. 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 }