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

[plugins] Implement plugins v2 #1850

Merged
merged 15 commits into from
Mar 5, 2024
Merged

[plugins] Implement plugins v2 #1850

merged 15 commits into from
Mar 5, 2024

Conversation

mikeland73
Copy link
Contributor

@mikeland73 mikeland73 commented Feb 27, 2024

Summary

Apologies in advance for the size of this PR. Was not really sure how to split it up, since it's a fairly large step change.

This PR makes 2 major changes:

  • Change how plugin fields are processed. Instead of being processed independently in several parts of the code (Env, Scripts, etc) they are now combined inside of devconfig in a simple, predictable way.
  • Plugins are now allowed to have all fields that devbox.json have. In fact the plugin struct now embeds a ConfigFile.

All fields from included plugins are added to the environment. The merge algorithm is as follows:

  • includes are evaluated in order first. Base config is evaluated last.
  • Slices are concatenated (init hooks, etc)
  • maps are merged with later evaluation replacing previous one on conflict.
  • Packages are deduped by name and concatenated. (i.e. multiple versions not allowed)

Known issues/limitations:

  • Github plugins will be slow. When using remote imports, every devbox command does 1 or more HTTP requests to load the imports.
  • central lock file. All dependencies are stored there.
  • While recursive includes are allowed, relative paths are not handled correctly. That means if project A includes plugin 1 and plugin 1 includes a relative local plugin, it will not work. Including github plugins recursively works as expected (but will be slow).
  • Cycle detection is not implemented. A user can cause infinite include.
  • Plugins are json only. They don't support hujson. (This is inline with current plugins, but we should build support to match devbox.json)

Additional requires testing:

  • Test create_files works as expected (for both local and github)
  • Test process compose works as expected
  • Test global push pull (config changes may have affected that)
  • Ensure that we are able to add a package that is already added by plugin.

Follow up cleanup:

  • Remove tyson support (this will simplify config code)
  • Plugin manager can be removed or greatly reduced.
  • Fix some of the limitations (relative paths, cycle detection)
  • Cache included remote plugins for performance (and to avoid github limits).

How was it tested?

  • New example tests
  • Manual testing
  • Existing tests

@mikeland73 mikeland73 changed the title Landau/implement plugins v2 [plugins] Implement plugins v2 Feb 27, 2024
@mikeland73 mikeland73 changed the base branch from main to landau/shift-plugins-to-config February 27, 2024 05:38
@mikeland73 mikeland73 marked this pull request as ready for review February 28, 2024 06:18
@mikeland73 mikeland73 force-pushed the landau/implement-plugins-v2 branch 3 times, most recently from f07d2a1 to 2eba821 Compare February 29, 2024 00:50
@savil
Copy link
Collaborator

savil commented Feb 29, 2024

From reading the summary, a couple of callouts:

Design of github plugins

"Github plugins will be slow. When using remote imports, every devbox command does 1 or more HTTP requests to load the imports."
"Cache included remote plugins for performance (and to avoid github limits).".

Two concerns:

  1. Reproducibility: if we are pulling from Github every time, then every change to that Github repo will be instantly reflected. Instead, could we lock the current version in time in the lockfile (perhaps resolve to a git commit)? Then devbox update can fetch the very latest version.
  2. Denial of service: due to the github limits being hit, especially for folks using Devbox in CICD. As a mitigation, we can add support for GITHUB_TOKEN that has higher limits. In CICD, users should be recommended to set that as well.

Plugins evaluation order

"includes are evaluated in order first. Base config is evaluated last."

This makes sense. How are we handling plugins that are applied on packages added in the base-config? For example, a Rust plugin that must be applied to the version of Rust included in the base config.

Copy link
Collaborator

@savil savil left a comment

Choose a reason for hiding this comment

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

Reviewed some. Pausing to do other work. Will resume.

Good:

  1. Love that we are moving plugin code out of functions like computeEnv

Scenarios to consider:

  1. if a plugin changes, then devbox shell will get the latest content. How will direnv know to refresh its environment?

return false
}
return !cfg.Root.Equals(&DefaultConfig().Root)
}
Copy link
Collaborator

@savil savil Feb 29, 2024

Choose a reason for hiding this comment

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

nit: Can we have this be IsDefault and caller can use the not operator?

Otherwise, in the future, if we'll want to write a function that needs IsDefault and will have to call it as !devconfig.IsNotDefault and we get a double negative

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will do in cleanup.

b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
baseConfig, err := loadBytes(b)
return loadBytes(b)
Copy link
Collaborator

Choose a reason for hiding this comment

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

can replace entire function's implemenation with return readFromFile(path)

}, nil
}

func (c *Config) LoadRecursive(lockfile *lock.File) error {
Copy link
Collaborator

Choose a reason for hiding this comment

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

reviewed till here.

Base automatically changed from landau/shift-plugins-to-config to main February 29, 2024 21:16
Copy link
Collaborator

@gcurtis gcurtis left a comment

Choose a reason for hiding this comment

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

Also still reviewing. I'll finish it up this afternoon.

}

func (c *Config) PackageMutator() *packagesMutator {
func LoadConfigFromURL(url string) (*Config, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Pass in context so HTTP request can be interrupted.

Copy link
Collaborator

@savil savil left a comment

Choose a reason for hiding this comment

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

Pausing for a bit. Left some more comments but not any fundamental design problems so far!

Question:

  • is there a way to disable a builtin plugin by having an includable plugin supersede it? For example, the builtin python plugin may be doing something a user feels is undesirable and would like to either disable it completely, or override it with their own python plugin

func (c *Config) LoadRecursive(lockfile *lock.File) error {
included := make([]*Config, 0, len(c.Root.Include))

for _, importRef := range c.Root.Include {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: will be nice to stick to consistent terminology by calling it includeRef instead of importRef

}

builtIns, err := plugin.GetBuiltinsForPackages(
c.Root.PackagesMutator.Collection,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's a bit odd to have PackagesMutator be the source of reading which packages are in the devbox.json file. Is there a nicer API we can use?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Something like configFileStruct.Packages() so the code here would be c.Root.Packages()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think implementing Packages() in both the config and the configFile would be a bit confusing.

I actually think Collection can be private. This would make it an implementation detail. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Never mind, this is used outside of the configfile package.

I'm adding SingleFilePackages and will make Collection unexported.

Comment on lines 137 to 138
pluginData := builtIn.PluginOnlyData
includable.pluginData = &pluginData
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't follow: why are we re-setting the includable.pluginData here after the LoadRecursive call?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This looks weird. This may be a mistake.

return &c.Root.PackagesMutator
}

func (c *Config) Packages() []Package {
return c.Root.PackagesMutator.collection
func (c *Config) PluginConfigs() []*plugin.Config {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we call this IncludedPluginConfigs()?

Because: I'd have expected PluginConfigs() to be IncludedPluginConfigs() + BuiltInPluginConfigs()

Comment on lines +155 to +160
if c.pluginData != nil {
configs = append(configs, &plugin.Config{
ConfigFile: c.Root,
PluginOnlyData: *c.pluginData,
})
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

🤔 I don't follow. Why do we add this additional plugin.Config?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the step that actually adds the current plugin in the recursive stack. In pseudocode:

configs = get-all-child-configs;
if cur-is-plugin:
  configs = append(configs, config-for-cur)

It uses c.pluginData != nil to ensure it doesn't add the top level devbox.json (which is not a plugin).

It runs after the for loop in order to respect the order configs are evaluated in.

func (c *ConfigFile) Bytes() []byte {
b := c.ast.root.Pack()
return bytes.ReplaceAll(b, []byte("\t"), []byte(" "))
}

func (c *ConfigFile) Hash() (string, error) {
if c.ast == nil {
return cachehash.JSON(c)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

in what scenario would c.ast be nil?

Instead, should we be building the ast in this if-block?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

plugin configs don't use hujson (but will in the future). Scope of this change is already too big to include that moving part.

Comment on lines +110 to +111
pkg.patchGlibc = sync.OnceValue(func() bool {
return cfgPkg.PatchGlibc && nix.SystemIsLinux()
Copy link
Collaborator

Choose a reason for hiding this comment

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

is changing to a function really needed?

I'm not convinced because:nix.SystemIsLinux relies on nix.System which is pre-computed once when running the cobra command, so likely won't be an actual system call here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is needed because unfortunately devbox CLI calls devbox.Open when populating autocomplete for scripts. (https://github.com/jetpack-io/devbox/blob/main/internal/boxcli/run.go#L54-L54). Note that this code happens before ensureNixInstalled is called. Arguably, we don't want to require nix for stuff like listing scripts which doesn't need nix.

So devbox.Open cannot call anything requiring nix, otherwise all commends will fail. Without this change, just parsing a devbox.json requires nix.

We should probably replace command.ValidArgs with command.ValidArgsFunction but even then, I think it's useful to keep devbox.Open working without nix.

@@ -4,11 +4,22 @@ import (
"strings"

"go.jetpack.io/devbox/internal/devpkg"
"go.jetpack.io/devbox/internal/lock"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Reviewed till here.

Copy link
Collaborator

@savil savil left a comment

Choose a reason for hiding this comment

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

@mikeland73 nice, overall lgtm. Please re-request review for when I should look again.

Please see above comments in the comment-box and in code for:

  1. questions about areas i couldn't follow why changes were made
  2. suggestions for minor improvements

A concern with this design is that scripts must be fully embedded in the plugin's devbox.json file, rather than moved into their own my_script.sh file where they can be properly handled by tooling (e.g. syntax-hightlighting, linting, etc.). Enabling that scenario would be a big win IMO.

One idea for a follow up PR is to add special-case support for detecting script files in plugin-configs, and downloading them as well.

@@ -49,37 +43,3 @@ func (m *Manager) ApplyOptions(opts ...managerOption) {
opt(m)
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the role of the Plugin Manager, after this PR's changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After this PR it does:

  • Builds includables and creates files
  • Figures out what services a plugin has.

We may want to split those out. Maybe the services stuff goes into the services package and the includable stuff moves into devconf

// If true, we remove the package that triggered this plugin from the environment
// Useful when we want to replace with flake
RemoveTriggerPackage bool `json:"__remove_trigger_package,omitempty"`
Version string `json:"version"`
Source Includable
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we add a comment explaining what Source is?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will do

}

func parseReflike(s string) (Includable, error) {
func parseReflike(s, projectDir string) (Includable, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

what is s? Lets rename it to a bigger, meaningful term

Copy link
Collaborator

Choose a reason for hiding this comment

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

same for withFilename below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do as part of cleanup

pkgs []*devpkg.Package,
includes []string,
) (services.Services, error) {
func (m *Manager) GetServices(configs []*Config) (services.Services, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we make this a standalone package function? Seems like it no longer needs to be a struct method

@mikeland73
Copy link
Contributor Author

is there a way to disable a builtin plugin by having an includable plugin supersede it? For example, the builtin python plugin may be doing something a user feels is undesirable and would like to either disable it completely, or override it with their own python plugin

Currently you have to use --disable-plugin when adding a package and then add your own plugin. It's not perfect, but seems OK since this is a bit of an edge case.

A concern with this design is that scripts must be fully embedded in the plugin's devbox.json file, rather than moved into their own my_script.sh file where they can be properly handled by tooling (e.g. syntax-hightlighting, linting, etc.). Enabling that scenario would be a big win IMO.

One idea for a follow up PR is to add special-case support for detecting script files in plugin-configs, and downloading them as well.

This use case is doable, but requires a bit more work from the user.

If you want script files they must be copied over using create_files field. Copying over all files is a bit tricky because we need to figure out the file structure of the repo (we can do this with git or maybe github has an API, not sure. Either way, a decent amount of more work).

@mikeland73 mikeland73 requested review from gcurtis and savil March 5, 2024 00:17
@mikeland73
Copy link
Contributor Author

@savil made requested changes (here and in #1867). PTAL

Copy link
Collaborator

@savil savil left a comment

Choose a reason for hiding this comment

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

nice!

func LoadConfigFromURL(url string) (*Config, error) {
res, err := http.Get(url)
func LoadConfigFromURL(ctx context.Context, url string) (*Config, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
Copy link
Collaborator

Choose a reason for hiding this comment

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

this should be http.MethodGet, right?

On line 84, we are trying to read res.Body which HEAD requests don't return

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oof, good catch. Terrible copy pasta on my part


// SingleFilePackages returns the packages in the config file, but not the included ones.
// Semi-awkwardly named to avoid confusion with the Packages method on Config.
func (c *ConfigFile) SingleFilePackages() []Package {
Copy link
Collaborator

Choose a reason for hiding this comment

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

TopLevelPackages ?

// Collection contains the set of package definitions
Collection []Package
// collection contains the set of package definitions
collection []Package
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍🏾

@mikeland73 mikeland73 merged commit 3e8115a into main Mar 5, 2024
24 checks passed
@mikeland73 mikeland73 deleted the landau/implement-plugins-v2 branch March 5, 2024 20:07
mikeland73 added a commit that referenced this pull request Mar 5, 2024
## Summary

Stacked on #1850

* Remove RefLike
* Remove experimental tyson support (v2 plugins are a reasonable
substitute)

## How was it tested?

CICD
Copy link

sentry-io bot commented Mar 7, 2024

Suspect Issues

This pull request was deployed and Sentry observed the following issues:

  • ‼️ **syscall.Errno: <redacted errors.withStack>: <redacted os.LinkError>: go.jetpack.io/devbox/internal/plugin in createS... View Issue

Did you find this useful? React with a 👍 or 👎

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.

3 participants