Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions internal/plugin/local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package plugin

import (
"os"
"path/filepath"
"testing"

"go.jetify.com/devbox/nix/flake"
)

// newTestLocalPlugin writes a plugin.json (with the given create_files content
// file) into a temp dir and returns a *LocalPlugin pointing at it along with the
// path of the referenced content file.
func newTestLocalPlugin(t *testing.T, fileContents string) (*LocalPlugin, string) {
t.Helper()
pluginDir := t.TempDir()

pluginJSON := `{
"name": "testplugin",
"create_files": {
"{{ .Virtenv }}/test.txt": "test.txt"
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, pluginConfigName), []byte(pluginJSON), 0o644); err != nil {
t.Fatal(err)
}

contentPath := filepath.Join(pluginDir, "test.txt")
if err := os.WriteFile(contentPath, []byte(fileContents), 0o644); err != nil {
t.Fatal(err)
}

local, err := newLocalPlugin(flake.Ref{Type: flake.TypePath, Path: pluginDir}, pluginDir)
if err != nil {
t.Fatal(err)
}
return local, contentPath
}

// TestLocalPluginHashIncludesCreateFilesContent verifies that editing a file
// referenced by a local plugin's create_files changes the plugin config hash,
// even though devbox.json and the plugin.json are unchanged. This is what allows
// Devbox to detect the change and regenerate the file in the virtenv.
// See https://github.com/jetify-com/devbox/issues/2755.
func TestLocalPluginHashIncludesCreateFilesContent(t *testing.T) {
local, contentPath := newTestLocalPlugin(t, "123")

pluginJSON, err := local.Fetch()
if err != nil {
t.Fatal(err)
}
cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON))
if err != nil {
t.Fatal(err)
}

before, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

// Edit the referenced content file without touching plugin.json.
if err := os.WriteFile(contentPath, []byte("456"), 0o644); err != nil {
t.Fatal(err)
}

after, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

if before == after {
t.Errorf("hash did not change after editing create_files content: %q", before)
}
}

// TestLocalPluginHashIncludesCreateFilesDestinations verifies that changing a
// create_files destination (a plugin-only field not covered by
// configfile.ConfigFile.Hash()) changes the plugin config hash.
func TestLocalPluginHashIncludesCreateFilesDestinations(t *testing.T) {
local, contentPath := newTestLocalPlugin(t, "123")

pluginJSON, err := local.Fetch()
if err != nil {
t.Fatal(err)
}
cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON))
if err != nil {
t.Fatal(err)
}

before, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

// Point the same source file at a different destination in the virtenv.
cfg.CreateFiles = map[string]string{"renamed.txt": "test.txt"}

after, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}

if before == after {
t.Errorf("hash did not change after changing create_files destination: %q", before)
}
}

// TestLocalPluginHashStableWithoutContentChange verifies the hash is stable when
// the referenced files don't change.
func TestLocalPluginHashStableWithoutContentChange(t *testing.T) {
local, contentPath := newTestLocalPlugin(t, "123")

pluginJSON, err := local.Fetch()
if err != nil {
t.Fatal(err)
}
cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON))
if err != nil {
t.Fatal(err)
}

first, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}
second, err := cfg.Hash()
if err != nil {
t.Fatal(err)
}
if first != second {
t.Errorf("hash is not stable: %q != %q", first, second)
}
}
62 changes: 62 additions & 0 deletions internal/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/pkg/errors"
"github.com/tailscale/hujson"
"go.jetify.com/devbox/internal/cachehash"
"go.jetify.com/devbox/internal/devconfig/configfile"
"go.jetify.com/devbox/internal/devpkg"
"go.jetify.com/devbox/internal/lock"
Expand Down Expand Up @@ -70,6 +71,67 @@ func (c *Config) Services() (services.Services, error) {
return nil, nil
}

// Hash returns a hash of the plugin config. It shadows the embedded
// configfile.ConfigFile.Hash() so that, for local plugins, the plugin-only
// fields and the contents of the files referenced by create_files are folded
// into the hash.
//
// Devbox uses this hash (via Devbox.ConfigHash) to decide whether the cached
// environment is still up to date. configfile.ConfigFile.Hash() only hashes the
// ConfigFile fields, so on its own it omits plugin-only fields (create_files,
// version, ...) and the create_files content files. For a local plugin all of
// these live on disk and can be edited without any corresponding change to
// devbox.json or the lockfile, so without this a local plugin's create_files
// would never be regenerated in the virtenv after the source changes. See
// https://github.com/jetify-com/devbox/issues/2755.
//
// Built-in plugin files are embedded in the Devbox binary (covered by the
// Devbox version in the state hash) and remote plugins are addressed by an
// immutable ref, so for those the config hash alone is sufficient.
func (c *Config) Hash() (string, error) {
configHash, err := c.ConfigFile.Hash()
if err != nil {
return "", err
}

local, ok := c.Source.(*LocalPlugin)
if !ok {
return configHash, nil
}

// Hash each referenced file independently, keyed by its source path, rather
// than concatenating raw bytes. Concatenation is ambiguous (different
// file/content splits can produce identical byte streams) and would buffer
// every file at once.
fileHashes := make(map[string]string, len(c.CreateFiles))
for _, contentPath := range c.CreateFiles {
if contentPath == "" {
continue
}
content, err := local.FileContent(contentPath)
if err != nil {
return "", errors.WithStack(err)
}
fileHashes[contentPath] = cachehash.Bytes(content)
}

return cachehash.JSON(struct {
ConfigHash string `json:"config_hash"`
CreateFiles map[string]string `json:"create_files"`
Version string `json:"version"`
Readme string `json:"readme"`
RemoveTriggerPackage bool `json:"remove_trigger_package"`
FileHashes map[string]string `json:"file_hashes"`
}{
ConfigHash: configHash,
CreateFiles: c.CreateFiles,
Version: c.Version,
Readme: c.DeprecatedDescription,
RemoveTriggerPackage: c.RemoveTriggerPackage,
FileHashes: fileHashes,
})
}

func (m *Manager) CreateFilesForConfig(cfg *Config) error {
virtenvPath := filepath.Join(m.ProjectDir(), VirtenvPath)
pkg := cfg.Source
Expand Down
Loading