Skip to content

Commit e976cdb

Browse files
[BE-1876] Integrate nixpkgs plugin (#1156)
* [BE-1876] Integrate nixpkgs plugin * Check for version in index, remove plugin version pinning, fixes * * * Fix test version * Rename tool * Remove index checking, use listing * * * Timeout on test * Simplify things - Do not clone + link the plugin, use the `mise plugin install ID URL` command instead - Move as much nix plugin specific logic to install.go as possible * Get rid of MiseExecutor, make ExecEnv mockable instead MiseExecutor as an interface was created for testing, but I relized this is redundant and ExecEnv should be mockable instead. * Change plugin ID to final value This is what we must use a prefix in tool install commands, so let's use the final value (which can be different than what is the git repo's name) * Unit tests * Add force flag for integration testing Also don't return unexpected errors from the function. It's not that the caller would do anything with unexpected errors, it's just going to fallback to the default behavior * Integration tests * Add check before executing nix commands * Make feature flag value true instead of 1 * AI reviewer fixes * chore: trigger CI checks * Raise timeout, install backend even with force install * Lint * Fix tests, update plugin * Decrease validate timeout * Fix tests * * * Tests working on stable stacks too, consistent commenting * Remove nix availability check * Fake nix checking in tests instead of env var override * Fixes * Comments * * --------- Co-authored-by: Olivér Falvai <[email protected]> Co-authored-by: Olivér Falvai <[email protected]>
1 parent 23434fe commit e976cdb

File tree

14 files changed

+596
-171
lines changed

14 files changed

+596
-171
lines changed

bitrise.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ workflows:
7575
- bundle::run_integration_tests: { }
7676
meta:
7777
bitrise.io:
78-
stack: osx-xcode-16.4.x
78+
# TODO: switch to stable stack when Nix is rolled out there
79+
stack: osx-xcode-edge
7980
machine_type_id: g2.mac.large
8081

8182
run_integration_tests_linux:
@@ -86,6 +87,11 @@ workflows:
8687
- workflow_id: unpin-go-version
8788
- bundle::setup_go_junit_report: { }
8889
- bundle::run_integration_tests: { }
90+
meta:
91+
bitrise.io:
92+
# TODO: switch to stable stack when Nix is rolled out there
93+
stack: ubuntu-noble-24.04-bitrise-2025-android
94+
machine_type_id: g2.linux.x-large
8995

9096
run_docker_integration_tests_linux:
9197
steps:
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//go:build linux_and_mac
2+
// +build linux_and_mac
3+
4+
package mise
5+
6+
import (
7+
"testing"
8+
9+
"github.com/bitrise-io/bitrise/v2/toolprovider/mise"
10+
"github.com/bitrise-io/bitrise/v2/toolprovider/provider"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestMiseInstallNixpkgsRuby(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
tool string
18+
version string
19+
resolutionStrategy provider.ResolutionStrategy
20+
want string
21+
wantErr bool
22+
}{
23+
{
24+
name: "Install specific version",
25+
tool: "ruby",
26+
version: "3.3.9",
27+
resolutionStrategy: provider.ResolutionStrategyStrict,
28+
want: "3.3.9",
29+
},
30+
{
31+
name: "Install fuzzy version and released strategy",
32+
tool: "ruby",
33+
version: "3.1", // EOL version, won't receive new patch versions suddenly
34+
resolutionStrategy: provider.ResolutionStrategyLatestReleased,
35+
want: "3.1.7",
36+
},
37+
{
38+
name: "Install fuzzy version and installed strategy",
39+
tool: "ruby",
40+
version: "3.1", // EOL version, won't receive new patch versions
41+
resolutionStrategy: provider.ResolutionStrategyLatestInstalled,
42+
want: "3.1.7",
43+
},
44+
{
45+
name: "Nonexistent version in nixpkgs index",
46+
tool: "ruby",
47+
version: "0.1.999",
48+
resolutionStrategy: provider.ResolutionStrategyStrict,
49+
wantErr: true,
50+
},
51+
{
52+
name: "Install some other tool with forced nixpkgs backend",
53+
tool: "node",
54+
version: "22.22.1",
55+
resolutionStrategy: provider.ResolutionStrategyStrict,
56+
wantErr: true,
57+
},
58+
}
59+
60+
t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL", "true")
61+
t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL_FORCE", "true")
62+
63+
for _, tt := range tests {
64+
miseInstallDir := t.TempDir()
65+
miseDataDir := t.TempDir()
66+
miseProvider, err := mise.NewToolProvider(miseInstallDir, miseDataDir)
67+
require.NoError(t, err)
68+
69+
err = miseProvider.Bootstrap()
70+
require.NoError(t, err)
71+
72+
t.Run(tt.name, func(t *testing.T) {
73+
request := provider.ToolRequest{
74+
ToolName: provider.ToolID(tt.tool),
75+
UnparsedVersion: tt.version,
76+
ResolutionStrategy: tt.resolutionStrategy,
77+
}
78+
result, installErr := miseProvider.InstallTool(request)
79+
80+
if tt.wantErr {
81+
require.Error(t, installErr)
82+
return
83+
}
84+
require.NoError(t, installErr)
85+
if tt.tool == "ruby" {
86+
// We purposely return ruby with the nixpkgs: prefix for environment activation later
87+
require.Equal(t, provider.ToolID("nixpkgs:ruby"), result.ToolName)
88+
} else {
89+
require.Equal(t, provider.ToolID(tt.tool), result.ToolName)
90+
}
91+
require.Equal(t, tt.want, result.ConcreteVersion)
92+
require.False(t, result.IsAlreadyInstalled)
93+
})
94+
}
95+
}

toolprovider/asdf/install_plugin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (a *AsdfToolProvider) InstallPlugin(tool provider.ToolRequest) error {
4646
log.Warnf("Failed to check if plugin is already installed: %v", err)
4747
}
4848
if installed {
49-
log.Debugf("Tool plugin %s is already installed, skipping installation.", tool.ToolName)
49+
log.Debugf("[TOOLPROVIDER] Tool plugin %s is already installed, skipping installation.", tool.ToolName)
5050
return nil
5151
}
5252

toolprovider/mise/bootstrap.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,18 @@ const fallbackDownloadURLBase = "https://storage.googleapis.com/mise-release-mir
2323
func isMiseInstalled(targetDir string) bool {
2424
misePath := filepath.Join(targetDir, "bin", "mise")
2525

26-
// Check if the file exists and is executable
26+
// Check if the file exists and is executable.
2727
info, err := os.Stat(misePath)
2828
if err != nil {
2929
return false
3030
}
3131

32-
// Check if it's a regular file and has execute permissions
32+
// Check if it's a regular file and has execute permissions.
3333
if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
3434
return false
3535
}
3636

37-
// Try to run mise --version to verify it's actually runnable
37+
// Try to run mise --version to verify it's actually runnable.
3838
cmd := exec.Command(misePath, "--version")
3939
err = cmd.Run()
4040
return err == nil
@@ -106,7 +106,7 @@ func downloadAndVerify(url, expectedChecksum string) (string, error) {
106106
_ = tempFile.Close()
107107
}()
108108

109-
// Compute SHA256 hash of the downloaded file and store contents in the temp file
109+
// Compute SHA256 hash of the downloaded file and store contents in the temp file.
110110
hash := sha256.New()
111111
multiWriter := io.MultiWriter(tempFile, hash)
112112
if _, err := io.Copy(multiWriter, resp.Body); err != nil {
@@ -211,18 +211,18 @@ func FallbackDownloadURL(version, platformName string) string {
211211

212212
// processHeader processes a tar header and determines the target extraction path.
213213
func processHeader(header *tar.Header, targetDir string) (string, bool) {
214-
// Skip the top-level "mise" directory and extract its contents directly
214+
// Skip the top-level "mise" directory and extract its contents directly.
215215
pathParts := strings.Split(header.Name, "/")
216216
if len(pathParts) > 0 && pathParts[0] == "mise" {
217217
if len(pathParts) == 1 {
218-
// This is the top-level "mise" directory itself, skip it
218+
// This is the top-level "mise" directory itself, skip it.
219219
return "", false
220220
}
221-
// Remove the top-level "mise" directory from the path
221+
// Remove the top-level "mise" directory from the path.
222222
header.Name = strings.Join(pathParts[1:], "/")
223223
}
224224

225-
// Clean the path to prevent directory traversal attacks
225+
// Clean the path to prevent directory traversal attacks.
226226
targetPath := filepath.Join(targetDir, header.Name)
227227
if !strings.HasPrefix(targetPath, filepath.Clean(targetDir)) {
228228
return "", false
@@ -260,14 +260,14 @@ func extractFile(tarReader *tar.Reader, header *tar.Header, targetPath string) e
260260
}
261261

262262
if filepath.Base(targetPath) == "mise" {
263-
// Make mise binary executable
263+
// Make mise binary executable.
264264
err = os.Chmod(targetPath, 0755)
265265
if err != nil {
266266
return fmt.Errorf("make mise binary executable %s: %w", targetPath, err)
267267
}
268268
}
269269
default:
270-
// Skip other file types (symlinks, etc.)
270+
// Skip other file types (symlinks, etc.).
271271
}
272272
return nil
273273
}

toolprovider/mise/execenv/execenv.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,42 @@ const (
1616

1717
// ExecEnv contains everything needed to run mise commands in a specific environment
1818
// that is installed and pre-configured.
19-
type ExecEnv struct {
19+
type ExecEnv interface {
2020
// InstallDir is the directory where mise is installed. This is not necessarily the same as the data directory.
21-
InstallDir string
21+
InstallDir() string
22+
RunMise(args ...string) (string, error)
23+
RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error)
24+
RunMisePlugin(args ...string) (string, error)
25+
}
26+
27+
type MiseExecEnv struct {
28+
installDir string
2229

23-
// Additional env vars that configure mise and are required for its operation.
24-
ExtraEnvs map[string]string
30+
extraEnvs map[string]string
2531
}
2632

27-
func (e *ExecEnv) RunMise(args ...string) (string, error) {
33+
// extraEnvs: additional env vars that configure mise and are required for its operation.
34+
func NewMiseExecEnv(installDir string, extraEnvs map[string]string) MiseExecEnv {
35+
return MiseExecEnv{
36+
installDir: installDir,
37+
extraEnvs: extraEnvs,
38+
}
39+
}
40+
41+
func (e MiseExecEnv) RunMise(args ...string) (string, error) {
2842
return e.RunMiseWithTimeout(0, args...)
2943
}
3044

31-
func (e *ExecEnv) RunMisePlugin(args ...string) (string, error) {
45+
func (e MiseExecEnv) RunMisePlugin(args ...string) (string, error) {
3246
cmdWithArgs := append([]string{"plugin"}, args...)
3347

34-
// Use timeout for all plugin operations as they involve unknown code execution
48+
// Use timeout for all plugin operations as they involve unknown code execution.
3549
return e.RunMiseWithTimeout(DefaultTimeout, cmdWithArgs...)
3650
}
3751

3852
// RunMiseWithTimeout runs mise commands that involve untrusted operations (plugin execution, remote network calls)
39-
// with a timeout to prevent hanging
40-
func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
53+
// with a timeout to prevent hanging.
54+
func (e MiseExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
4155
var ctx context.Context
4256
if timeout == 0 {
4357
ctx = context.Background()
@@ -47,10 +61,10 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str
4761
defer cancel()
4862
}
4963

50-
executable := filepath.Join(e.InstallDir, "bin", "mise")
64+
executable := filepath.Join(e.installDir, "bin", "mise")
5165
cmd := exec.CommandContext(ctx, executable, args...)
5266
cmd.Env = os.Environ()
53-
for k, v := range e.ExtraEnvs {
67+
for k, v := range e.extraEnvs {
5468
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
5569
}
5670
output, err := cmd.CombinedOutput()
@@ -63,3 +77,7 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str
6377

6478
return string(output), nil
6579
}
80+
81+
func (e MiseExecEnv) InstallDir() string {
82+
return e.installDir
83+
}

toolprovider/mise/helpers_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package mise
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
)
8+
9+
type fakeExecEnv struct {
10+
// responses maps command strings to their outputs.
11+
responses map[string]string
12+
// errors maps command strings to errors.
13+
errors map[string]error
14+
}
15+
16+
func newFakeExecEnv() *fakeExecEnv {
17+
return &fakeExecEnv{
18+
responses: make(map[string]string),
19+
errors: make(map[string]error),
20+
}
21+
}
22+
23+
func (m *fakeExecEnv) setResponse(cmdKey string, output string) {
24+
m.responses[cmdKey] = output
25+
}
26+
27+
func (m *fakeExecEnv) setError(cmdKey string, err error) {
28+
m.errors[cmdKey] = err
29+
}
30+
31+
func (m *fakeExecEnv) InstallDir() string {
32+
return "/fake/mise/install/dir"
33+
}
34+
35+
func (m *fakeExecEnv) RunMise(args ...string) (string, error) {
36+
return m.runCommand(args...)
37+
}
38+
39+
func (m *fakeExecEnv) RunMisePlugin(args ...string) (string, error) {
40+
return m.runCommand(append([]string{"plugin"}, args...)...)
41+
}
42+
43+
func (m *fakeExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
44+
return m.runCommand(args...)
45+
}
46+
47+
func (m *fakeExecEnv) runCommand(args ...string) (string, error) {
48+
cmdKey := strings.Join(args, " ")
49+
50+
if err, ok := m.errors[cmdKey]; ok {
51+
return "", err
52+
}
53+
54+
if output, ok := m.responses[cmdKey]; ok {
55+
return output, nil
56+
}
57+
58+
return "", fmt.Errorf("no mock response configured for command: %s", cmdKey)
59+
}

0 commit comments

Comments
 (0)