Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[bug] plugin env PATH fix #2418

Merged
merged 7 commits into from
Dec 2, 2024
Merged

[bug] plugin env PATH fix #2418

merged 7 commits into from
Dec 2, 2024

Conversation

mohsenari
Copy link
Collaborator

@mohsenari mohsenari commented Nov 21, 2024

Summary

When a plugin modifies PATH and also config in devbox.json modifies PATH via env:{}, the env overwrites the plugin and so PATH modification from the plugin is lost. This is because we do maps.Copy() to merge the env from plugin to the one from config.
The copy is fine for all other env variables but for PATH we need to handle merging the two rather than overwriting.
Fixes #2138

How was it tested?

bug recreate:

  • devbox init
  • devbox add ruby (ruby plugin modifies PATH)
  • devbox shell (see PATH is prepended with ruby plugin)
  • modify devbox.json and add "env": {"PATH": "/my/config/bin:$PATH"}
  • re-enter devbox shell and see PATH prepend from ruby plugin is lost, only "/my/config/bin" is prepended.
    fix recreate:
  • follow same steps, but in last step both PATH prepends from devbox.json and from plugin are present.

@mohsenari mohsenari requested a review from mikeland73 November 21, 2024 16:31
Comment on lines 360 to 361
rootEnv := mergePATHsFromTwoEnvs(env, c.Root.Env)
maps.Copy(env, rootEnv)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use OSExpandEnvMap instead?

Basically take the environment provided by the plugins and use it to expand the environment of the config.

Copy link
Collaborator Author

@mohsenari mohsenari Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikeland73 after your suggestion, I tried OSExpandEnvMap and it it doesn't work.

The issue with OSExpandEnvMap is it merges the two PATHs correctly but it applies the (non-nix) system PATH to the :$PATH at the end. So with OSExpandEnvMap PATH becomes: /my/config:/my/plugin:/opt/homebrew/sbin:/usr/local/bin:...

With mergePATHsFromTwoEnvs PATH becomes: /my/config:/my/plugin:/Users/mohsenansari/code/go.jetpack.io/devbox/examples/stacks/rails/.devbox/nix/profile/default/bin:...:/opt/homebrew/sbin:/usr/local/bin:...

So OSExpandEnvMap can't work here because I don't want to expand $PATH env yet, I just want to merge them correctly without losing :$PATH suffix or $PATH: prefix to it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essentially, OSExpandEnvMap expands PATH with non-devboxified PATH value, but I don't want that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would something like

func OSExpandIfPossible(env, existingEnv map[string]string) map[string]string {
	mapping := func(value string) string {
		// If the value is not set in existingEnv, return the value wrapped in ${...}
		if existingEnv == nil || existingEnv[value] == "" {
			return fmt.Sprintf("${%s}", value)
		}
		return existingEnv[value]
	}

	res := map[string]string{}
	for k, v := range env {
		res[k] = os.Expand(v, mapping)
	}
	return res
}

work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And some unit tests if you decide to go this route:

package conf

import "testing"

func TestOSExpandIfPossible(t *testing.T) {
	tests := []struct {
		name        string
		env         map[string]string
		existingEnv map[string]string
		want        map[string]string
	}{
		{
			name: "basic expansion",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
				"QUX": "qux_value",
			},
			want: map[string]string{
				"FOO": "bar_value",
				"BAZ": "qux_value",
			},
		},
		{
			name: "missing values remain as template",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
				// QUX is missing
			},
			want: map[string]string{
				"FOO": "bar_value",
				"BAZ": "${QUX}",
			},
		},
		{
			name: "nil existing env",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: nil,
			want: map[string]string{
				"FOO": "${BAR}",
				"BAZ": "${QUX}",
			},
		},
		{
			name: "empty existing env",
			env: map[string]string{
				"FOO": "$BAR",
			},
			existingEnv: map[string]string{},
			want: map[string]string{
				"FOO": "${BAR}",
			},
		},
		{
			name: "mixed literal and variable",
			env: map[string]string{
				"FOO": "prefix_${BAR}_suffix",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
			},
			want: map[string]string{
				"FOO": "prefix_bar_value_suffix",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := OSExpandIfPossible(tt.env, tt.existingEnv)
			if len(got) != len(tt.want) {
				t.Errorf("OSExpandIfPossible() got %v entries, want %v entries", len(got), len(tt.want))
			}
			for k, v := range tt.want {
				if got[k] != v {
					t.Errorf("OSExpandIfPossible() for key %q = %q, want %q", k, got[k], v)
				}
			}
		})
	}
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OSExpandIfPossible works. I included this suggestion along with the unit test and added a case for path to the unit test as well.

Comment on lines 354 to 355
pluginEnv := mergePATHsFromTwoEnvs(env, i.Env())
maps.Copy(env, pluginEnv)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you use OSExpandEnvMap instead of mergePATHsFromTwoEnvs?

@mohsenari mohsenari requested a review from mikeland73 November 26, 2024 21:31
Copy link
Contributor

@mikeland73 mikeland73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should treat $PATH different from other env vars. Also, a minor nit is that you are not really "merging" paths, you are expanding, but only if $PATH already exists in the new environment you are creating.

I think what you want is something like OSExpandIfPossible (I added a possible implementation).

Also, for stuff like this, adding some unit tests and/or some integration tests in example dir is really useful, since reasoning about what the final environment will look like is hard. See https://github.com/jetify-com/devbox/blob/163ae999a46e6ff4b946437b22a782557e708eeb/examples/plugins/v2-local/devbox.json#L1C1-L36C1 for an example of an integration test for plugins.

Comment on lines 360 to 361
rootEnv := mergePATHsFromTwoEnvs(env, c.Root.Env)
maps.Copy(env, rootEnv)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would something like

func OSExpandIfPossible(env, existingEnv map[string]string) map[string]string {
	mapping := func(value string) string {
		// If the value is not set in existingEnv, return the value wrapped in ${...}
		if existingEnv == nil || existingEnv[value] == "" {
			return fmt.Sprintf("${%s}", value)
		}
		return existingEnv[value]
	}

	res := map[string]string{}
	for k, v := range env {
		res[k] = os.Expand(v, mapping)
	}
	return res
}

work?

Comment on lines 360 to 361
rootEnv := mergePATHsFromTwoEnvs(env, c.Root.Env)
maps.Copy(env, rootEnv)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And some unit tests if you decide to go this route:

package conf

import "testing"

func TestOSExpandIfPossible(t *testing.T) {
	tests := []struct {
		name        string
		env         map[string]string
		existingEnv map[string]string
		want        map[string]string
	}{
		{
			name: "basic expansion",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
				"QUX": "qux_value",
			},
			want: map[string]string{
				"FOO": "bar_value",
				"BAZ": "qux_value",
			},
		},
		{
			name: "missing values remain as template",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
				// QUX is missing
			},
			want: map[string]string{
				"FOO": "bar_value",
				"BAZ": "${QUX}",
			},
		},
		{
			name: "nil existing env",
			env: map[string]string{
				"FOO": "$BAR",
				"BAZ": "${QUX}",
			},
			existingEnv: nil,
			want: map[string]string{
				"FOO": "${BAR}",
				"BAZ": "${QUX}",
			},
		},
		{
			name: "empty existing env",
			env: map[string]string{
				"FOO": "$BAR",
			},
			existingEnv: map[string]string{},
			want: map[string]string{
				"FOO": "${BAR}",
			},
		},
		{
			name: "mixed literal and variable",
			env: map[string]string{
				"FOO": "prefix_${BAR}_suffix",
			},
			existingEnv: map[string]string{
				"BAR": "bar_value",
			},
			want: map[string]string{
				"FOO": "prefix_bar_value_suffix",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := OSExpandIfPossible(tt.env, tt.existingEnv)
			if len(got) != len(tt.want) {
				t.Errorf("OSExpandIfPossible() got %v entries, want %v entries", len(got), len(tt.want))
			}
			for k, v := range tt.want {
				if got[k] != v {
					t.Errorf("OSExpandIfPossible() for key %q = %q, want %q", k, got[k], v)
				}
			}
		})
	}
}

Copy link
Contributor

@mikeland73 mikeland73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RC to send back to your queue

@mohsenari mohsenari requested a review from mikeland73 November 27, 2024 22:23
@mohsenari mohsenari merged commit 28ba55e into main Dec 2, 2024
29 checks passed
@mohsenari mohsenari deleted the mohsen--plugin-env-path-fix branch December 2, 2024 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Cannot set PATH env var in both a plugin and devbox.json
2 participants