feat: absolute imports (#63)

* feat: go-like import style

jb now creates a directory structure inside of vendor/ that is similar to how go
does (github.com/grafana/jsonnet-libs). This is reflected in the final import
paths, which means they will be go-like

* refactor(spec/deps): named regexs

* feat: make goImportStyle configurable

Defaults to off, can be enabled in `jsonnetfile.json`

* fix: integration test

* doc: license headers

* fix(deps): remove GO_IMPORT_STYLE

not an option anymore, will always do so and symlink

* feat: symlink to legacy location

* feat: allow to disable legacy links

* fix(test): legacyImports in integration tests

* fix(spec): test

* fix: respect legacyName aliases

It was possible to alias packages by changing `name` previously.

While names are now absolute (and computed), legacy links should still respect
old aliases to avoid breaking code.

* fix(test): integration

* fix(init): keep legacyImports enabled for now

* feat: rewrite imports

adds a command to automatically rewrite imports from legacy to absolute style

* fix(tool): rewrite confused by prefixing packages

When a package was a prefix of another one, it broke.
Fixed that by using a proper regular expression. Added a test to make sure it
works as expected

* Update cmd/jb/init.go

* fix: exclude local packages from legacy linking

They actually still use the old style, which is fine. LegacyLinking
messed them up, but from now on it just ignores symlinks that match a localPackage.
This commit is contained in:
Tom 2020-01-24 08:02:34 +01:00 committed by GitHub
parent 184841238b
commit 7b8a7836a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1129 additions and 361 deletions

View file

@ -106,6 +106,9 @@ Commands:
update update
Update all dependencies. Update all dependencies.
rewrite
Automatically rewrite legacy imports to absolute ones
``` ```

View file

@ -15,32 +15,37 @@
package main package main
import ( import (
"encoding/json"
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
kingpin "gopkg.in/alecthomas/kingpin.v2" kingpin "gopkg.in/alecthomas/kingpin.v2"
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec"
) )
func initCommand(dir string) int { func initCommand(dir string) int {
exists, err := jsonnetfile.Exists(jsonnetfile.File) exists, err := jsonnetfile.Exists(jsonnetfile.File)
if err != nil { kingpin.FatalIfError(err, "Failed to check for jsonnetfile.json")
kingpin.Errorf("Failed to check for jsonnetfile.json: %v", err)
return 1
}
if exists { if exists {
kingpin.Errorf("jsonnetfile.json already exists") kingpin.Errorf("jsonnetfile.json already exists")
return 1 return 1
} }
s := spec.New()
// TODO: disable them by default eventually
// s.LegacyImports = false
contents, err := json.MarshalIndent(s, "", " ")
kingpin.FatalIfError(err, "formatting jsonnetfile contents as json")
contents = append(contents, []byte("\n")...)
filename := filepath.Join(dir, jsonnetfile.File) filename := filepath.Join(dir, jsonnetfile.File)
if err := ioutil.WriteFile(filename, []byte("{}\n"), 0644); err != nil { ioutil.WriteFile(filename, contents, 0644)
kingpin.Errorf("Failed to write new jsonnetfile.json: %v", err) kingpin.FatalIfError(err, "Failed to write new jsonnetfile.json")
return 1
}
return 0 return 0
} }

View file

@ -27,6 +27,7 @@ import (
"github.com/jsonnet-bundler/jsonnet-bundler/pkg" "github.com/jsonnet-bundler/jsonnet-bundler/pkg"
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
func installCommand(dir, jsonnetHome string, uris []string) int { func installCommand(dir, jsonnetHome string, uris []string) int {
@ -53,23 +54,25 @@ func installCommand(dir, jsonnetHome string, uris []string) int {
"creating vendor folder") "creating vendor folder")
for _, u := range uris { for _, u := range uris {
d := parseDependency(dir, u) d := deps.Parse(dir, u)
if d == nil { if d == nil {
kingpin.Fatalf("Unable to parse package URI `%s`", u) kingpin.Fatalf("Unable to parse package URI `%s`", u)
} }
if !depEqual(jsonnetFile.Dependencies[d.Name], *d) { if !depEqual(jsonnetFile.Dependencies[d.Name()], *d) {
// the dep passed on the cli is different from the jsonnetFile // the dep passed on the cli is different from the jsonnetFile
jsonnetFile.Dependencies[d.Name] = *d jsonnetFile.Dependencies[d.Name()] = *d
// we want to install the passed version (ignore the lock) // we want to install the passed version (ignore the lock)
delete(lockFile.Dependencies, d.Name) delete(lockFile.Dependencies, d.Name())
} }
} }
locked, err := pkg.Ensure(jsonnetFile, jsonnetHome, lockFile.Dependencies) locked, err := pkg.Ensure(jsonnetFile, jsonnetHome, lockFile.Dependencies)
kingpin.FatalIfError(err, "failed to install packages") kingpin.FatalIfError(err, "failed to install packages")
pkg.CleanLegacyName(jsonnetFile.Dependencies)
kingpin.FatalIfError( kingpin.FatalIfError(
writeChangedJsonnetFile(jbfilebytes, &jsonnetFile, filepath.Join(dir, jsonnetfile.File)), writeChangedJsonnetFile(jbfilebytes, &jsonnetFile, filepath.Join(dir, jsonnetfile.File)),
"updating jsonnetfile.json") "updating jsonnetfile.json")
@ -81,8 +84,8 @@ func installCommand(dir, jsonnetHome string, uris []string) int {
return 0 return 0
} }
func depEqual(d1, d2 spec.Dependency) bool { func depEqual(d1, d2 deps.Dependency) bool {
name := d1.Name == d2.Name name := d1.Name() == d2.Name()
version := d1.Version == d2.Version version := d1.Version == d2.Version
source := reflect.DeepEqual(d1.Source, d2.Source) source := reflect.DeepEqual(d1.Source, d2.Source)

View file

@ -25,8 +25,12 @@ import (
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
// TODO: Change legacyImports to false eventually
const initContents = `{"dependencies": [], "legacyImports": true}`
func TestInstallCommand(t *testing.T) { func TestInstallCommand(t *testing.T) {
testcases := []struct { testcases := []struct {
Name string Name string
@ -38,19 +42,21 @@ func TestInstallCommand(t *testing.T) {
{ {
Name: "NoURLs", Name: "NoURLs",
ExpectedCode: 0, ExpectedCode: 0,
ExpectedJsonnetFile: []byte(`{}`), ExpectedJsonnetFile: []byte(initContents),
}, { },
{
Name: "OneURL", Name: "OneURL",
URIs: []string{"github.com/jsonnet-bundler/jsonnet-bundler@v0.1.0"}, URIs: []string{"github.com/jsonnet-bundler/jsonnet-bundler@v0.1.0"},
ExpectedCode: 0, ExpectedCode: 0,
ExpectedJsonnetFile: []byte(`{"dependencies": [{"name": "jsonnet-bundler", "source": {"git": {"remote": "https://github.com/jsonnet-bundler/jsonnet-bundler", "subdir": ""}}, "version": "v0.1.0"}]}`), ExpectedJsonnetFile: []byte(`{"dependencies": [{"source": {"git": {"remote": "https://github.com/jsonnet-bundler/jsonnet-bundler", "subdir": ""}}, "version": "v0.1.0"}], "legacyImports": true}`),
ExpectedJsonnetLockFile: []byte(`{"dependencies": [{"name": "jsonnet-bundler", "source": {"git": {"remote": "https://github.com/jsonnet-bundler/jsonnet-bundler", "subdir": ""}}, "version": "080f157c7fb85ad0281ea78f6c641eaa570a582f", "sum": "W1uI550rQ66axRpPXA2EZDquyPg/5PHZlvUz1NEzefg="}]}`), ExpectedJsonnetLockFile: []byte(`{"dependencies": [{"source": {"git": {"remote": "https://github.com/jsonnet-bundler/jsonnet-bundler", "subdir": ""}}, "version": "080f157c7fb85ad0281ea78f6c641eaa570a582f", "sum": "W1uI550rQ66axRpPXA2EZDquyPg/5PHZlvUz1NEzefg="}], "legacyImports": false}`),
}, { },
Name: "Relative", {
Name: "Local",
URIs: []string{"jsonnet/foobar"}, URIs: []string{"jsonnet/foobar"},
ExpectedCode: 0, ExpectedCode: 0,
ExpectedJsonnetFile: []byte(`{"dependencies": [{"name": "foobar", "source": {"local": {"directory": "jsonnet/foobar"}}, "version": ""}]}`), ExpectedJsonnetFile: []byte(`{"dependencies": [{"source": {"local": {"directory": "jsonnet/foobar"}}, "version": ""}], "legacyImports": true}`),
ExpectedJsonnetLockFile: []byte(`{"dependencies": [{"name": "foobar", "source": {"local": {"directory": "jsonnet/foobar"}}, "version": ""}]}`), ExpectedJsonnetLockFile: []byte(`{"dependencies": [{"source": {"local": {"directory": "jsonnet/foobar"}}, "version": ""}], "legacyImports": false}`),
}, },
} }
@ -70,12 +76,12 @@ func TestInstallCommand(t *testing.T) {
err := os.MkdirAll(localDependency, os.ModePerm) err := os.MkdirAll(localDependency, os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
// init + check it works correctly (legacyImports true, empty dependencies)
initCommand("") initCommand("")
jsonnetFileContent(t, jsonnetfile.File, []byte(initContents))
jsonnetFileContent(t, jsonnetfile.File, []byte(`{}`)) // install something, check it writes only if required, etc.
installCommand("", "vendor", tc.URIs) installCommand("", "vendor", tc.URIs)
jsonnetFileContent(t, jsonnetfile.File, tc.ExpectedJsonnetFile) jsonnetFileContent(t, jsonnetfile.File, tc.ExpectedJsonnetFile)
if tc.ExpectedJsonnetLockFile != nil { if tc.ExpectedJsonnetLockFile != nil {
jsonnetFileContent(t, jsonnetfile.LockFile, tc.ExpectedJsonnetLockFile) jsonnetFileContent(t, jsonnetfile.LockFile, tc.ExpectedJsonnetLockFile)
@ -111,11 +117,11 @@ func TestWriteChangedJsonnetFile(t *testing.T) {
}, },
{ {
Name: "NoDiffNotEmpty", Name: "NoDiffNotEmpty",
JsonnetFileBytes: []byte(`{"dependencies": [{"name": "foobar"}]}`), JsonnetFileBytes: []byte(`{"dependencies": [{"version": "master"}]}`),
NewJsonnetFile: spec.JsonnetFile{ NewJsonnetFile: spec.JsonnetFile{
Dependencies: map[string]spec.Dependency{ Dependencies: map[string]deps.Dependency{
"foobar": { "": {
Name: "foobar", Version: "master",
}, },
}, },
}, },
@ -123,11 +129,10 @@ func TestWriteChangedJsonnetFile(t *testing.T) {
}, },
{ {
Name: "DiffVersion", Name: "DiffVersion",
JsonnetFileBytes: []byte(`{"dependencies": [{"name": "foobar", "version": "1.0"}]}`), JsonnetFileBytes: []byte(`{"dependencies": [{"version": "1.0"}]}`),
NewJsonnetFile: spec.JsonnetFile{ NewJsonnetFile: spec.JsonnetFile{
Dependencies: map[string]spec.Dependency{ Dependencies: map[string]deps.Dependency{
"foobar": { "": {
Name: "foobar",
Version: "2.0", Version: "2.0",
}, },
}, },
@ -138,17 +143,18 @@ func TestWriteChangedJsonnetFile(t *testing.T) {
Name: "Diff", Name: "Diff",
JsonnetFileBytes: []byte(`{}`), JsonnetFileBytes: []byte(`{}`),
NewJsonnetFile: spec.JsonnetFile{ NewJsonnetFile: spec.JsonnetFile{
Dependencies: map[string]spec.Dependency{ Dependencies: map[string]deps.Dependency{
"foobar": { "github.com/foobar/foobar": {
Name: "foobar", Source: deps.Source{
Source: spec.Source{ GitSource: &deps.Git{
GitSource: &spec.GitSource{ Scheme: deps.GitSchemeHTTPS,
Remote: "https://github.com/foobar/foobar", Host: "github.com",
User: "foobar",
Repo: "foobar",
Subdir: "", Subdir: "",
}, },
}, },
Version: "master", Version: "master",
DepSource: "",
}}, }},
}, },
ExpectWrite: true, ExpectWrite: true,

View file

@ -17,32 +17,16 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"path/filepath"
"regexp"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/jsonnet-bundler/jsonnet-bundler/spec"
) )
const ( const (
installActionName = "install" installActionName = "install"
updateActionName = "update" updateActionName = "update"
initActionName = "init" initActionName = "init"
) rewriteActionName = "rewrite"
var (
gitSSHRegex = regexp.MustCompile(`git\+ssh://git@([^:]+):([^/]+)/([^/]+).git`)
gitSSHWithVersionRegex = regexp.MustCompile(`git\+ssh://git@([^:]+):([^/]+)/([^/]+).git@(.*)`)
gitSSHWithPathRegex = regexp.MustCompile(`git\+ssh://git@([^:]+):([^/]+)/([^/]+).git/(.*)`)
gitSSHWithPathAndVersionRegex = regexp.MustCompile(`git\+ssh://git@([^:]+):([^/]+)/([^/]+).git/(.*)@(.*)`)
githubSlugRegex = regexp.MustCompile("github.com/([-_a-zA-Z0-9]+)/([-_a-zA-Z0-9]+)")
githubSlugWithVersionRegex = regexp.MustCompile("github.com/([-_a-zA-Z0-9]+)/([-_a-zA-Z0-9]+)@(.*)")
githubSlugWithPathRegex = regexp.MustCompile("github.com/([-_a-zA-Z0-9]+)/([-_a-zA-Z0-9]+)/(.*)")
githubSlugWithPathAndVersionRegex = regexp.MustCompile("github.com/([-_a-zA-Z0-9]+)/([-_a-zA-Z0-9]+)/(.*)@(.*)")
) )
func main() { func main() {
@ -69,6 +53,8 @@ func Main() int {
updateCmd := a.Command(updateActionName, "Update all dependencies.") updateCmd := a.Command(updateActionName, "Update all dependencies.")
rewriteCmd := a.Command(rewriteActionName, "Automatically rewrite legacy imports to absolute ones")
command, err := a.Parse(os.Args[1:]) command, err := a.Parse(os.Args[1:])
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, errors.Wrapf(err, "Error parsing commandline arguments")) fmt.Fprintln(os.Stderr, errors.Wrapf(err, "Error parsing commandline arguments"))
@ -88,148 +74,11 @@ func Main() int {
return installCommand(workdir, cfg.JsonnetHome, *installCmdURIs) return installCommand(workdir, cfg.JsonnetHome, *installCmdURIs)
case updateCmd.FullCommand(): case updateCmd.FullCommand():
return updateCommand(workdir, cfg.JsonnetHome) return updateCommand(workdir, cfg.JsonnetHome)
case rewriteCmd.FullCommand():
return rewriteCommand(workdir, cfg.JsonnetHome)
default: default:
installCommand(workdir, cfg.JsonnetHome, []string{}) installCommand(workdir, cfg.JsonnetHome, []string{})
} }
return 0 return 0
} }
func parseDependency(dir, uri string) *spec.Dependency {
if uri == "" {
return nil
}
if githubSlugRegex.MatchString(uri) {
return parseGithubDependency(uri)
}
if gitSSHRegex.MatchString(uri) {
return parseGitSSHDependency(uri)
}
return parseLocalDependency(dir, uri)
}
func parseGitSSHDependency(p string) *spec.Dependency {
subdir := ""
host := ""
org := ""
repo := ""
version := "master"
switch {
case gitSSHWithPathAndVersionRegex.MatchString(p):
matches := gitSSHWithPathAndVersionRegex.FindStringSubmatch(p)
host = matches[1]
org = matches[2]
repo = matches[3]
subdir = matches[4]
version = matches[5]
case gitSSHWithPathRegex.MatchString(p):
matches := gitSSHWithPathRegex.FindStringSubmatch(p)
host = matches[1]
org = matches[2]
repo = matches[3]
subdir = matches[4]
case gitSSHWithVersionRegex.MatchString(p):
matches := gitSSHWithVersionRegex.FindStringSubmatch(p)
host = matches[1]
org = matches[2]
repo = matches[3]
version = matches[4]
default:
matches := gitSSHRegex.FindStringSubmatch(p)
host = matches[1]
org = matches[2]
repo = matches[3]
}
return &spec.Dependency{
Name: repo,
Source: spec.Source{
GitSource: &spec.GitSource{
Remote: fmt.Sprintf("git@%s:%s/%s", host, org, repo),
Subdir: subdir,
},
},
Version: version,
}
}
func parseGithubDependency(p string) *spec.Dependency {
if !githubSlugRegex.MatchString(p) {
return nil
}
name := ""
user := ""
repo := ""
subdir := ""
version := "master"
if githubSlugWithPathRegex.MatchString(p) {
if githubSlugWithPathAndVersionRegex.MatchString(p) {
matches := githubSlugWithPathAndVersionRegex.FindStringSubmatch(p)
user = matches[1]
repo = matches[2]
subdir = matches[3]
version = matches[4]
name = path.Base(subdir)
} else {
matches := githubSlugWithPathRegex.FindStringSubmatch(p)
user = matches[1]
repo = matches[2]
subdir = matches[3]
name = path.Base(subdir)
}
} else {
if githubSlugWithVersionRegex.MatchString(p) {
matches := githubSlugWithVersionRegex.FindStringSubmatch(p)
user = matches[1]
repo = matches[2]
name = repo
version = matches[3]
} else {
matches := githubSlugRegex.FindStringSubmatch(p)
user = matches[1]
repo = matches[2]
name = repo
}
}
return &spec.Dependency{
Name: name,
Source: spec.Source{
GitSource: &spec.GitSource{
Remote: fmt.Sprintf("https://github.com/%s/%s", user, repo),
Subdir: subdir,
},
},
Version: version,
}
}
func parseLocalDependency(dir, p string) *spec.Dependency {
clean := filepath.Clean(p)
abs := filepath.Join(dir, clean)
info, err := os.Stat(abs)
if err != nil {
return nil
}
if !info.IsDir() {
return nil
}
return &spec.Dependency{
Name: info.Name(),
Source: spec.Source{
LocalSource: &spec.LocalSource{
Directory: clean,
},
},
Version: "",
}
}

View file

@ -20,7 +20,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
func TestParseDependency(t *testing.T) { func TestParseDependency(t *testing.T) {
@ -34,7 +34,7 @@ func TestParseDependency(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
path string path string
want *spec.Dependency want *deps.Dependency
}{ }{
{ {
name: "Empty", name: "Empty",
@ -49,11 +49,13 @@ func TestParseDependency(t *testing.T) {
{ {
name: "GitHub", name: "GitHub",
path: "github.com/jsonnet-bundler/jsonnet-bundler", path: "github.com/jsonnet-bundler/jsonnet-bundler",
want: &spec.Dependency{ want: &deps.Dependency{
Name: "jsonnet-bundler", Source: deps.Source{
Source: spec.Source{ GitSource: &deps.Git{
GitSource: &spec.GitSource{ Scheme: deps.GitSchemeHTTPS,
Remote: "https://github.com/jsonnet-bundler/jsonnet-bundler", Host: "github.com",
User: "jsonnet-bundler",
Repo: "jsonnet-bundler",
Subdir: "", Subdir: "",
}, },
}, },
@ -63,11 +65,13 @@ func TestParseDependency(t *testing.T) {
{ {
name: "SSH", name: "SSH",
path: "git+ssh://git@github.com:jsonnet-bundler/jsonnet-bundler.git", path: "git+ssh://git@github.com:jsonnet-bundler/jsonnet-bundler.git",
want: &spec.Dependency{ want: &deps.Dependency{
Name: "jsonnet-bundler", Source: deps.Source{
Source: spec.Source{ GitSource: &deps.Git{
GitSource: &spec.GitSource{ Scheme: deps.GitSchemeSSH,
Remote: "git@github.com:jsonnet-bundler/jsonnet-bundler", Host: "github.com",
User: "jsonnet-bundler",
Repo: "jsonnet-bundler",
Subdir: "", Subdir: "",
}, },
}, },
@ -77,10 +81,9 @@ func TestParseDependency(t *testing.T) {
{ {
name: "local", name: "local",
path: testFolder, path: testFolder,
want: &spec.Dependency{ want: &deps.Dependency{
Name: "foobar", Source: deps.Source{
Source: spec.Source{ LocalSource: &deps.Local{
LocalSource: &spec.LocalSource{
Directory: "test/jsonnet/foobar", Directory: "test/jsonnet/foobar",
}, },
}, },
@ -90,7 +93,7 @@ func TestParseDependency(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
_ = t.Run(tt.name, func(t *testing.T) { _ = t.Run(tt.name, func(t *testing.T) {
dependency := parseDependency("", tt.path) dependency := deps.Parse("", tt.path)
if tt.path == "" { if tt.path == "" {
assert.Nil(t, dependency) assert.Nil(t, dependency)

36
cmd/jb/rewrite.go Normal file
View file

@ -0,0 +1,36 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 main
import (
"path/filepath"
kingpin "gopkg.in/alecthomas/kingpin.v2"
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/tool/rewrite"
)
func rewriteCommand(dir, vendorDir string) int {
locks, err := jsonnetfile.Load(filepath.Join(dir, jsonnetfile.LockFile))
if err != nil {
kingpin.Fatalf("Failed to load lockFile: %s.\nThe locks are required to compute the new import names. Make sure to run `jb install` first.", err)
}
if err := rewrite.Rewrite(dir, vendorDir, locks.Dependencies); err != nil {
kingpin.FatalIfError(err, "")
}
return 0
}

View file

@ -24,6 +24,7 @@ import (
"github.com/jsonnet-bundler/jsonnet-bundler/pkg" "github.com/jsonnet-bundler/jsonnet-bundler/pkg"
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
func updateCommand(dir, jsonnetHome string, urls ...*url.URL) int { func updateCommand(dir, jsonnetHome string, urls ...*url.URL) int {
@ -39,7 +40,7 @@ func updateCommand(dir, jsonnetHome string, urls ...*url.URL) int {
"creating vendor folder") "creating vendor folder")
// When updating, locks are ignored. // When updating, locks are ignored.
locks := map[string]spec.Dependency{} locks := map[string]deps.Dependency{}
locked, err := pkg.Ensure(jsonnetFile, jsonnetHome, locks) locked, err := pkg.Ensure(jsonnetFile, jsonnetHome, locks)
kingpin.FatalIfError(err, "failed to install packages") kingpin.FatalIfError(err, "failed to install packages")

View file

@ -33,14 +33,14 @@ import (
"github.com/fatih/color" "github.com/fatih/color"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
type GitPackage struct { type GitPackage struct {
Source *spec.GitSource Source *deps.Git
} }
func NewGitPackage(source *spec.GitSource) Interface { func NewGitPackage(source *deps.Git) Interface {
return &GitPackage{ return &GitPackage{
Source: source, Source: source,
} }
@ -169,7 +169,7 @@ func remoteResolveRef(ctx context.Context, remote string, ref string) (string, e
func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (string, error) { func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (string, error) {
destPath := path.Join(dir, name) destPath := path.Join(dir, name)
tmpDir, err := ioutil.TempDir(filepath.Join(dir, ".tmp"), fmt.Sprintf("jsonnetpkg-%s-%s", name, version)) tmpDir, err := ioutil.TempDir(filepath.Join(dir, ".tmp"), fmt.Sprintf("jsonnetpkg-%s-%s", strings.Replace(name, "/", "-", -1), version))
if err != nil { if err != nil {
return "", errors.Wrap(err, "failed to create tmp dir") return "", errors.Wrap(err, "failed to create tmp dir")
} }
@ -177,11 +177,11 @@ func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (st
// Optimization for GitHub sources: download a tarball archive of the requested // Optimization for GitHub sources: download a tarball archive of the requested
// version instead of cloning the entire repository. // version instead of cloning the entire repository.
isGitHubRemote, err := regexp.MatchString(`^(https|ssh)://github\.com/.+$`, p.Source.Remote) isGitHubRemote, err := regexp.MatchString(`^(https|ssh)://github\.com/.+$`, p.Source.Remote())
if isGitHubRemote { if isGitHubRemote {
// Let git ls-remote decide if "version" is a ref or a commit SHA in the unlikely // Let git ls-remote decide if "version" is a ref or a commit SHA in the unlikely
// but possible event that a ref is comprised of 40 or more hex characters // but possible event that a ref is comprised of 40 or more hex characters
commitSha, err := remoteResolveRef(ctx, p.Source.Remote, version) commitSha, err := remoteResolveRef(ctx, p.Source.Remote(), version)
// If the ref resolution failed and "version" looks like a SHA, // If the ref resolution failed and "version" looks like a SHA,
// assume it is one and proceed. // assume it is one and proceed.
@ -190,7 +190,7 @@ func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (st
commitSha = version commitSha = version
} }
archiveUrl := fmt.Sprintf("%s/archive/%s.tar.gz", p.Source.Remote, commitSha) archiveUrl := fmt.Sprintf("%s/archive/%s.tar.gz", p.Source.Remote(), commitSha)
archiveFilepath := fmt.Sprintf("%s.tar.gz", tmpDir) archiveFilepath := fmt.Sprintf("%s.tar.gz", tmpDir)
defer os.Remove(archiveFilepath) defer os.Remove(archiveFilepath)
@ -205,7 +205,12 @@ func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (st
// Move the extracted directory to its final destination // Move the extracted directory to its final destination
if err == nil { if err == nil {
err = os.Rename(path.Join(tmpDir, p.Source.Subdir), destPath) if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
panic(err)
}
if err := os.Rename(path.Join(tmpDir, p.Source.Subdir), destPath); err != nil {
panic(err)
}
} }
} }
} }
@ -230,7 +235,7 @@ func (p *GitPackage) Install(ctx context.Context, name, dir, version string) (st
return "", err return "", err
} }
cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", p.Source.Remote) cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", p.Source.Remote())
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr

View file

@ -22,6 +22,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
const ( const (
@ -52,7 +53,7 @@ func Unmarshal(bytes []byte) (spec.JsonnetFile, error) {
return m, errors.Wrap(err, "failed to unmarshal file") return m, errors.Wrap(err, "failed to unmarshal file")
} }
if m.Dependencies == nil { if m.Dependencies == nil {
m.Dependencies = make(map[string]spec.Dependency) m.Dependencies = make(map[string]deps.Dependency)
} }
return m, nil return m, nil

View file

@ -24,17 +24,17 @@ import (
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
const notExist = "/this/does/not/exist" const notExist = "/this/does/not/exist"
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
empty := spec.New() jsonnetfileContent := `
{
jsonnetfileContent := `{ "legacyImports": false,
"dependencies": [ "dependencies": [
{ {
"name": "foobar",
"source": { "source": {
"git": { "git": {
"remote": "https://github.com/foobar/foobar", "remote": "https://github.com/foobar/foobar",
@ -46,53 +46,29 @@ func TestLoad(t *testing.T) {
] ]
} }
` `
jsonnetFileExpected := spec.JsonnetFile{ jsonnetFileExpected := spec.JsonnetFile{
Dependencies: map[string]spec.Dependency{ LegacyImports: false,
"foobar": { Dependencies: map[string]deps.Dependency{
Name: "foobar", "github.com/foobar/foobar": {
Source: spec.Source{ Source: deps.Source{
GitSource: &spec.GitSource{ GitSource: &deps.Git{
Remote: "https://github.com/foobar/foobar", Scheme: deps.GitSchemeHTTPS,
Host: "github.com",
User: "foobar",
Repo: "foobar",
Subdir: "", Subdir: "",
}, },
}, },
Version: "master", Version: "master",
DepSource: "",
}}, }},
} }
{
jf, err := jsonnetfile.Load(notExist)
assert.Equal(t, empty, jf)
assert.Error(t, err)
}
{
tempDir, err := ioutil.TempDir("", "jb-load-jsonnetfile") tempDir, err := ioutil.TempDir("", "jb-load-jsonnetfile")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer func() { defer os.RemoveAll(tempDir)
err := os.RemoveAll(tempDir)
assert.Nil(t, err)
}()
tempFile := filepath.Join(tempDir, jsonnetfile.File)
err = ioutil.WriteFile(tempFile, []byte(`{}`), os.ModePerm)
assert.Nil(t, err)
jf, err := jsonnetfile.Load(tempFile)
assert.Nil(t, err)
assert.Equal(t, empty, jf)
}
{
tempDir, err := ioutil.TempDir("", "jb-load-jsonnetfile")
if err != nil {
t.Fatal(err)
}
defer func() {
err := os.RemoveAll(tempDir)
assert.Nil(t, err)
}()
tempFile := filepath.Join(tempDir, jsonnetfile.File) tempFile := filepath.Join(tempDir, jsonnetfile.File)
err = ioutil.WriteFile(tempFile, []byte(jsonnetfileContent), os.ModePerm) err = ioutil.WriteFile(tempFile, []byte(jsonnetfileContent), os.ModePerm)
@ -101,7 +77,30 @@ func TestLoad(t *testing.T) {
jf, err := jsonnetfile.Load(tempFile) jf, err := jsonnetfile.Load(tempFile)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, jsonnetFileExpected, jf) assert.Equal(t, jsonnetFileExpected, jf)
}
func TestLoadEmpty(t *testing.T) {
tempDir, err := ioutil.TempDir("", "jb-load-empty")
if err != nil {
t.Fatal(err)
} }
defer os.RemoveAll(tempDir)
// write empty json file
tempFile := filepath.Join(tempDir, jsonnetfile.File)
err = ioutil.WriteFile(tempFile, []byte(`{}`), os.ModePerm)
assert.Nil(t, err)
// expect it to be loaded properly
got, err := jsonnetfile.Load(tempFile)
assert.Nil(t, err)
assert.Equal(t, spec.New(), got)
}
func TestLoadNotExist(t *testing.T) {
jf, err := jsonnetfile.Load(notExist)
assert.Equal(t, spec.New(), jf)
assert.Error(t, err)
} }
func TestFileExists(t *testing.T) { func TestFileExists(t *testing.T) {

View file

@ -19,15 +19,16 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
type LocalPackage struct { type LocalPackage struct {
Source *spec.LocalSource Source *deps.Local
} }
func NewLocalPackage(source *spec.LocalSource) Interface { func NewLocalPackage(source *deps.Local) Interface {
return &LocalPackage{ return &LocalPackage{
Source: source, Source: source,
} }

View file

@ -22,19 +22,21 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile" "github.com/jsonnet-bundler/jsonnet-bundler/pkg/jsonnetfile"
"github.com/jsonnet-bundler/jsonnet-bundler/spec" "github.com/jsonnet-bundler/jsonnet-bundler/spec"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
var ( var (
VersionMismatch = errors.New("multiple colliding versions specified") VersionMismatch = errors.New("multiple colliding versions specified")
) )
// Ensure receives all direct packages as, the directory to vendor in and all known locks. // Ensure receives all direct packages, the directory to vendor into and all known locks.
// It then makes sure all direct and nested dependencies are present in vendor at the correct version: // It then makes sure all direct and nested dependencies are present in vendor at the correct version:
// //
// If the package is locked and the files in vendor match the sha256 checksum, // If the package is locked and the files in vendor match the sha256 checksum,
@ -46,57 +48,183 @@ var (
// desired version in case by `jb install`ing it. // desired version in case by `jb install`ing it.
// //
// Finally, all unknown files and directories are removed from vendor/ // Finally, all unknown files and directories are removed from vendor/
func Ensure(direct spec.JsonnetFile, vendorDir string, locks map[string]spec.Dependency) (map[string]spec.Dependency, error) { // The full list of locked depedencies is returned
func Ensure(direct spec.JsonnetFile, vendorDir string, oldLocks map[string]deps.Dependency) (map[string]deps.Dependency, error) {
// ensure all required files are in vendor // ensure all required files are in vendor
deps, err := ensure(direct.Dependencies, vendorDir, locks) // This is the actual installation
locks, err := ensure(direct.Dependencies, vendorDir, oldLocks)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// cleanup unknown dirs from vendor/ // remove unchanged legacyNames
f, err := os.Open(vendorDir) CleanLegacyName(locks)
if err != nil {
return nil, err // find unknown dirs in vendor/
names := []string{}
err = filepath.Walk(vendorDir, func(path string, i os.FileInfo, err error) error {
if path == vendorDir {
return nil
} }
names, err := f.Readdirnames(0) if !i.IsDir() {
if err != nil { return nil
return nil, err
} }
for _, name := range names {
if _, ok := deps[name]; !ok { names = append(names, path)
dir := filepath.Join(vendorDir, name) return nil
})
// remove them
for _, dir := range names {
name := strings.TrimPrefix(dir, "vendor/")
if !known(locks, name) {
if err := os.RemoveAll(dir); err != nil { if err := os.RemoveAll(dir); err != nil {
return nil, err return nil, err
} }
if name != ".tmp" { if !strings.HasPrefix(name, ".tmp") {
color.Magenta("CLEAN %s", dir) color.Magenta("CLEAN %s", dir)
} }
} }
} }
// remove all symlinks, optionally adding known ones back later if wished
if err := cleanLegacySymlinks(vendorDir, locks); err != nil {
return nil, err
}
if !direct.LegacyImports {
return locks, nil
}
if err := linkLegacy(vendorDir, locks); err != nil {
return nil, err
}
// return the final lockfile contents // return the final lockfile contents
return deps, nil return locks, nil
} }
func ensure(direct map[string]spec.Dependency, vendorDir string, locks map[string]spec.Dependency) (map[string]spec.Dependency, error) { func CleanLegacyName(list map[string]deps.Dependency) {
deps := make(map[string]spec.Dependency) for k, d := range list {
// unset if not changed by user
if d.LegacyNameCompat == d.Source.LegacyName() {
dep := list[k]
dep.LegacyNameCompat = ""
list[k] = dep
}
}
}
func cleanLegacySymlinks(vendorDir string, locks map[string]deps.Dependency) error {
// local packages need to be ignored
locals := map[string]bool{}
for _, d := range locks {
if d.Source.LocalSource == nil {
continue
}
locals[filepath.Join(vendorDir, d.Name())] = true
}
// remove all symlinks first
return filepath.Walk(vendorDir, func(path string, i os.FileInfo, err error) error {
if locals[path] {
return nil
}
if i.Mode()&os.ModeSymlink != 0 {
if err := os.Remove(path); err != nil {
return err
}
}
return nil
})
}
func linkLegacy(vendorDir string, locks map[string]deps.Dependency) error {
// create only the ones we want
for _, d := range locks {
// localSource still uses the relative style
if d.Source.LocalSource != nil {
continue
}
legacyName := filepath.Join("vendor", d.LegacyName())
pkgName := d.Name()
taken, err := checkLegacyNameTaken(legacyName, pkgName)
if err != nil {
fmt.Println(err)
continue
}
if taken {
continue
}
// create the symlink
if err := os.Symlink(
filepath.Join(pkgName),
filepath.Join(legacyName),
); err != nil {
return err
}
}
return nil
}
func checkLegacyNameTaken(legacyName string, pkgName string) (bool, error) {
fi, err := os.Lstat(legacyName)
if err != nil {
// does not exist: not taken
if os.IsNotExist(err) {
return false, nil
}
// a real error
return false, err
}
// is it a symlink?
if fi.Mode()&os.ModeSymlink != 0 {
s, err := os.Readlink(legacyName)
if err != nil {
return false, err
}
color.Yellow("WARN: cannot link '%s' to '%s', because package '%s' already uses that name. The absolute import still works\n", pkgName, legacyName, s)
return true, nil
}
// sth else
color.Yellow("WARN: cannot link '%s' to '%s', because the file/directory already exists. The absolute import still works.\n", pkgName, legacyName)
return true, nil
}
func known(deps map[string]deps.Dependency, p string) bool {
for _, d := range deps {
k := d.Name()
if strings.HasPrefix(p, k) || strings.HasPrefix(k, p) {
return true
}
}
return false
}
func ensure(direct map[string]deps.Dependency, vendorDir string, locks map[string]deps.Dependency) (map[string]deps.Dependency, error) {
deps := make(map[string]deps.Dependency)
for _, d := range direct { for _, d := range direct {
l, present := locks[d.Name] l, present := locks[d.Name()]
// already locked and the integrity is intact // already locked and the integrity is intact
if present { if present {
d.Version = locks[d.Name].Version d.Version = locks[d.Name()].Version
if check(l, vendorDir) { if check(l, vendorDir) {
deps[d.Name] = l deps[d.Name()] = l
continue continue
} }
} }
expectedSum := locks[d.Name].Sum expectedSum := locks[d.Name()].Sum
// either not present or not intact: download again // either not present or not intact: download again
dir := filepath.Join(vendorDir, d.Name) dir := filepath.Join(vendorDir, d.Name())
os.RemoveAll(dir) os.RemoveAll(dir)
locked, err := download(d, vendorDir) locked, err := download(d, vendorDir)
@ -104,15 +232,15 @@ func ensure(direct map[string]spec.Dependency, vendorDir string, locks map[strin
return nil, errors.Wrap(err, "downloading") return nil, errors.Wrap(err, "downloading")
} }
if expectedSum != "" && locked.Sum != expectedSum { if expectedSum != "" && locked.Sum != expectedSum {
return nil, fmt.Errorf("checksum mismatch for %s. Expected %s but got %s", d.Name, expectedSum, locked.Sum) return nil, fmt.Errorf("checksum mismatch for %s. Expected %s but got %s", d.Name(), expectedSum, locked.Sum)
} }
deps[d.Name] = *locked deps[d.Name()] = *locked
// we settled on a new version, add it to the locks for recursion // we settled on a new version, add it to the locks for recursion
locks[d.Name] = *locked locks[d.Name()] = *locked
} }
for _, d := range deps { for _, d := range deps {
f, err := jsonnetfile.Load(filepath.Join(vendorDir, d.Name, jsonnetfile.File)) f, err := jsonnetfile.Load(filepath.Join(vendorDir, d.Name(), jsonnetfile.File))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
continue continue
@ -126,8 +254,8 @@ func ensure(direct map[string]spec.Dependency, vendorDir string, locks map[strin
} }
for _, d := range nested { for _, d := range nested {
if _, ok := deps[d.Name]; !ok { if _, ok := deps[d.Name()]; !ok {
deps[d.Name] = d deps[d.Name()] = d
} }
} }
} }
@ -137,7 +265,7 @@ func ensure(direct map[string]spec.Dependency, vendorDir string, locks map[strin
// download retrieves a package from a remote upstream. The checksum of the // download retrieves a package from a remote upstream. The checksum of the
// files is generated afterwards. // files is generated afterwards.
func download(d spec.Dependency, vendorDir string) (*spec.Dependency, error) { func download(d deps.Dependency, vendorDir string) (*deps.Dependency, error) {
var p Interface var p Interface
switch { switch {
case d.Source.GitSource != nil: case d.Source.GitSource != nil:
@ -150,32 +278,29 @@ func download(d spec.Dependency, vendorDir string) (*spec.Dependency, error) {
return nil, errors.New("either git or local source is required") return nil, errors.New("either git or local source is required")
} }
version, err := p.Install(context.TODO(), d.Name, vendorDir, d.Version) version, err := p.Install(context.TODO(), d.Name(), vendorDir, d.Version)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var sum string var sum string
if d.Source.LocalSource == nil { if d.Source.LocalSource == nil {
sum = hashDir(filepath.Join(vendorDir, d.Name)) sum = hashDir(filepath.Join(vendorDir, d.Name()))
} }
return &spec.Dependency{ d.Version = version
Name: d.Name, d.Sum = sum
Source: d.Source, return &d, nil
Version: version,
Sum: sum,
}, nil
} }
// check returns whether the files present at the vendor/ folder match the // check returns whether the files present at the vendor/ folder match the
// sha256 sum of the package. local-directory dependencies are not checked as // sha256 sum of the package. local-directory dependencies are not checked as
// their purpose is to change during development where integrity checking would // their purpose is to change during development where integrity checking would
// be a hindrance. // be a hindrance.
func check(d spec.Dependency, vendorDir string) bool { func check(d deps.Dependency, vendorDir string) bool {
// assume a local dependency is intact as long as it exists // assume a local dependency is intact as long as it exists
if d.Source.LocalSource != nil { if d.Source.LocalSource != nil {
x, err := jsonnetfile.Exists(filepath.Join(vendorDir, d.Name)) x, err := jsonnetfile.Exists(filepath.Join(vendorDir, d.Name()))
if err != nil { if err != nil {
return false return false
} }
@ -187,7 +312,7 @@ func check(d spec.Dependency, vendorDir string) bool {
return false return false
} }
dir := filepath.Join(vendorDir, d.Name) dir := filepath.Join(vendorDir, d.Name())
sum := hashDir(dir) sum := hashDir(dir)
return d.Sum == sum return d.Sum == sum
} }

View file

@ -13,3 +13,83 @@
// limitations under the License. // limitations under the License.
package pkg package pkg
import (
"testing"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
)
func TestKnown(t *testing.T) {
deps := map[string]deps.Dependency{
"ksonnet-lib": deps.Dependency{
Source: deps.Source{GitSource: &deps.Git{
Scheme: deps.GitSchemeHTTPS,
Host: "github.com",
User: "ksonnet",
Repo: "ksonnet-lib",
Subdir: "/ksonnet.beta.4",
}},
},
}
paths := []string{
"github.com",
"github.com/ksonnet",
"github.com/ksonnet/ksonnet-lib",
"github.com/ksonnet/ksonnet-lib/ksonnet.beta.4",
"github.com/ksonnet/ksonnet-lib/ksonnet.beta.4/k.libsonnet",
"github.com/ksonnet-util", // don't know that one
"ksonnet.beta.4", // the symlink
}
want := []string{
"github.com",
"github.com/ksonnet",
"github.com/ksonnet/ksonnet",
"github.com/ksonnet/ksonnet-lib",
"github.com/ksonnet/ksonnet-lib/ksonnet.beta.4",
"github.com/ksonnet/ksonnet-lib/ksonnet.beta.4/k.libsonnet",
}
w := make(map[string]bool)
for _, k := range want {
w[k] = true
}
for _, p := range paths {
if known(deps, p) != w[p] {
t.Fatalf("expected %s to be %v", p, w[p])
}
}
}
func TestCleanLegacyName(t *testing.T) {
deps := func(name string) map[string]deps.Dependency {
return map[string]deps.Dependency{
"ksonnet-lib": deps.Dependency{
LegacyNameCompat: name,
Source: deps.Source{GitSource: &deps.Git{
Scheme: deps.GitSchemeHTTPS,
Host: "github.com",
User: "ksonnet",
Repo: "ksonnet-lib",
Subdir: "/ksonnet.beta.4",
}},
},
}
}
cases := map[string]bool{
"ksonnet": false,
"ksonnet.beta.4": true,
}
for name, want := range cases {
list := deps(name)
CleanLegacyName(list)
if (list["ksonnet-lib"].LegacyNameCompat == "") != want {
t.Fatalf("expected `%s` to be removed: %v", name, want)
}
}
}

106
spec/deps/dependencies.go Normal file
View file

@ -0,0 +1,106 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 deps
import (
"os"
"path/filepath"
)
type Dependency struct {
Source Source `json:"source"`
Version string `json:"version"`
Sum string `json:"sum,omitempty"`
// older schema used to have `name`. We still need that data for
// `LegacyName`
LegacyNameCompat string `json:"name,omitempty"`
}
func Parse(dir, uri string) *Dependency {
if uri == "" {
return nil
}
if d := parseGit(uri); d != nil {
return d
}
return parseLocal(dir, uri)
}
func (d Dependency) Name() string {
return d.Source.Name()
}
func (d Dependency) LegacyName() string {
if d.LegacyNameCompat != "" {
return d.LegacyNameCompat
}
return d.Source.LegacyName()
}
type Source struct {
GitSource *Git `json:"git,omitempty"`
LocalSource *Local `json:"local,omitempty"`
}
func (s Source) Name() string {
switch {
case s.GitSource != nil:
return s.GitSource.Name()
case s.LocalSource != nil:
return s.LegacyName()
default:
return ""
}
}
func (s Source) LegacyName() string {
switch {
case s.GitSource != nil:
return s.GitSource.LegacyName()
case s.LocalSource != nil:
return filepath.Base(s.LocalSource.Directory)
default:
return ""
}
}
type Local struct {
Directory string `json:"directory"`
}
func parseLocal(dir, p string) *Dependency {
clean := filepath.Clean(p)
abs := filepath.Join(dir, clean)
info, err := os.Stat(abs)
if err != nil {
return nil
}
if !info.IsDir() {
return nil
}
return &Dependency{
Source: Source{
LocalSource: &Local{
Directory: clean,
},
},
Version: "",
}
}

View file

@ -0,0 +1,70 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 deps
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseDependency(t *testing.T) {
const testFolder = "test/jsonnet/foobar"
err := os.MkdirAll(testFolder, os.ModePerm)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll("test")
tests := []struct {
name string
path string
want *Dependency
}{
{
name: "Empty",
path: "",
want: nil,
},
{
name: "Invalid",
path: "github.com/foo",
want: nil,
},
{
name: "local",
path: testFolder,
want: &Dependency{
Source: Source{
LocalSource: &Local{
Directory: "test/jsonnet/foobar",
},
},
Version: "",
},
},
}
for _, tt := range tests {
_ = t.Run(tt.name, func(t *testing.T) {
dependency := Parse("", tt.path)
if tt.path == "" {
assert.Nil(t, dependency)
} else {
assert.Equal(t, tt.want, dependency)
}
})
}
}

203
spec/deps/git.go Normal file
View file

@ -0,0 +1,203 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 deps
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
)
const (
GitSchemeSSH = "ssh://git@"
GitSchemeHTTPS = "https://"
)
// Git holds all required information for cloning a package from git
type Git struct {
// Scheme (Protocol) used (https, git+ssh)
Scheme string
// Hostname the repo is located at
Host string
// User (github.com/<user>)
User string
// Repo (github.com/<user>/<repo>)
Repo string
// Subdir (github.com/<user>/<repo>/<subdir>)
Subdir string
}
// json representation of Git (for compatiblity with old format)
type jsonGit struct {
Remote string `json:"remote"`
Subdir string `json:"subdir"`
}
// MarshalJSON takes care of translating between Git and jsonGit
func (gs *Git) MarshalJSON() ([]byte, error) {
j := jsonGit{
Remote: gs.Remote(),
Subdir: strings.TrimPrefix(gs.Subdir, "/"),
}
return json.Marshal(j)
}
// UnmarshalJSON takes care of translating between Git and jsonGit
func (gs *Git) UnmarshalJSON(data []byte) error {
var j jsonGit
if err := json.Unmarshal(data, &j); err != nil {
return err
}
if j.Subdir != "" {
gs.Subdir = "/" + strings.TrimPrefix(j.Subdir, "/")
}
tmp := parseGit(j.Remote)
gs.Host = tmp.Source.GitSource.Host
gs.User = tmp.Source.GitSource.User
gs.Repo = tmp.Source.GitSource.Repo
gs.Scheme = tmp.Source.GitSource.Scheme
return nil
}
// Name returns the repository in a go-like format (github.com/user/repo/subdir)
func (gs *Git) Name() string {
return fmt.Sprintf("%s/%s/%s%s", gs.Host, gs.User, gs.Repo, gs.Subdir)
}
// LegacyName returns the last element of the packages path
// example: github.com/ksonnet/ksonnet-lib/ksonnet.beta.4 becomes ksonnet.beta.4
func (gs *Git) LegacyName() string {
return filepath.Base(gs.Repo + gs.Subdir)
}
var gitProtoFmts = map[string]string{
GitSchemeSSH: GitSchemeSSH + "%s:%s/%s.git",
GitSchemeHTTPS: GitSchemeHTTPS + "%s/%s/%s",
}
// Remote returns a remote string that can be passed to git
func (gs *Git) Remote() string {
return fmt.Sprintf(gitProtoFmts[gs.Scheme],
gs.Host, gs.User, gs.Repo,
)
}
// regular expressions for matching package uris
const (
gitSSHExp = `git\+ssh://git@(?P<host>[^:]+):(?P<user>[^/]+)/(?P<repo>[^/]+).git`
githubSlugExp = `github.com/(?P<user>[-_a-zA-Z0-9]+)/(?P<repo>[-_a-zA-Z0-9]+)`
)
var (
gitSSHRegex = regexp.MustCompile(gitSSHExp)
gitSSHWithVersionRegex = regexp.MustCompile(gitSSHExp + `@(?P<version>.*)`)
gitSSHWithPathRegex = regexp.MustCompile(gitSSHExp + `/(?P<subdir>.*)`)
gitSSHWithPathAndVersionRegex = regexp.MustCompile(gitSSHExp + `/(?P<subdir>.*)@(?P<version>.*)`)
githubSlugRegex = regexp.MustCompile(githubSlugExp)
githubSlugWithVersionRegex = regexp.MustCompile(githubSlugExp + `@(?P<version>.*)`)
githubSlugWithPathRegex = regexp.MustCompile(githubSlugExp + `/(?P<subdir>.*)`)
githubSlugWithPathAndVersionRegex = regexp.MustCompile(githubSlugExp + `/(?P<subdir>.*)@(?P<version>.*)`)
)
func parseGit(uri string) *Dependency {
var d = Dependency{
Version: "master",
Source: Source{},
}
var gs *Git
var version string
switch {
case githubSlugRegex.MatchString(uri):
gs, version = parseGitHub(uri)
case gitSSHRegex.MatchString(uri):
gs, version = parseGitSSH(uri)
default:
return nil
}
if gs.Subdir != "" {
gs.Subdir = "/" + gs.Subdir
}
d.Source.GitSource = gs
if version != "" {
d.Version = version
}
return &d
}
func parseGitSSH(p string) (gs *Git, version string) {
gs, version = match(p, []*regexp.Regexp{
gitSSHWithPathAndVersionRegex,
gitSSHWithPathRegex,
gitSSHWithVersionRegex,
gitSSHRegex,
})
gs.Scheme = GitSchemeSSH
return gs, version
}
func parseGitHub(p string) (gs *Git, version string) {
gs, version = match(p, []*regexp.Regexp{
githubSlugWithPathAndVersionRegex,
githubSlugWithPathRegex,
githubSlugWithVersionRegex,
githubSlugRegex,
})
gs.Scheme = GitSchemeHTTPS
gs.Host = "github.com"
return gs, version
}
func match(p string, exps []*regexp.Regexp) (gs *Git, version string) {
gs = &Git{}
for _, e := range exps {
if !e.MatchString(p) {
continue
}
matches := reSubMatchMap(e, p)
gs.Host = matches["host"]
gs.User = matches["user"]
gs.Repo = matches["repo"]
if sd, ok := matches["subdir"]; ok {
gs.Subdir = sd
}
return gs, matches["version"]
}
return gs, ""
}
func reSubMatchMap(r *regexp.Regexp, str string) map[string]string {
match := r.FindStringSubmatch(str)
subMatchMap := make(map[string]string)
for i, name := range r.SubexpNames() {
if i != 0 {
subMatchMap[name] = match[i]
}
}
return subMatchMap
}

68
spec/deps/git_test.go Normal file
View file

@ -0,0 +1,68 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 deps
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseGit(t *testing.T) {
tests := []struct {
name string
uri string
want *Dependency
wantRemote string
}{
{
name: "GitHub",
uri: "github.com/ksonnet/ksonnet-lib/ksonnet.beta.3",
want: &Dependency{
Version: "master",
Source: Source{GitSource: &Git{
Scheme: GitSchemeHTTPS,
Host: "github.com",
User: "ksonnet",
Repo: "ksonnet-lib",
Subdir: "/ksonnet.beta.3",
}},
},
wantRemote: "https://github.com/ksonnet/ksonnet-lib",
},
{
name: "SSH",
uri: "git+ssh://git@my.host:user/repo.git/foobar@v1",
want: &Dependency{
Version: "v1",
Source: Source{GitSource: &Git{
Scheme: GitSchemeSSH,
Host: "my.host",
User: "user",
Repo: "repo",
Subdir: "/foobar",
}},
},
wantRemote: "ssh://git@my.host:user/repo.git",
},
}
for _, c := range tests {
t.Run(c.name, func(t *testing.T) {
got := Parse("", c.uri)
assert.Equal(t, c.want, got)
assert.Equal(t, c.wantRemote, got.Source.GitSource.Remote())
})
}
}

View file

@ -17,73 +17,67 @@ package spec
import ( import (
"encoding/json" "encoding/json"
"sort" "sort"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
) )
// JsonnetFile is the structure of a `.json` file describing a set of jsonnet // JsonnetFile is the structure of a `.json` file describing a set of jsonnet
// dependencies. It is used for both, the jsonnetFile and the lockFile. // dependencies. It is used for both, the jsonnetFile and the lockFile.
type JsonnetFile struct { type JsonnetFile struct {
Dependencies map[string]Dependency // List of dependencies
Dependencies map[string]deps.Dependency
// Symlink files to old location
LegacyImports bool
} }
// New returns a new JsonnetFile with the dependencies map initialized // New returns a new JsonnetFile with the dependencies map initialized
func New() JsonnetFile { func New() JsonnetFile {
return JsonnetFile{ return JsonnetFile{
Dependencies: make(map[string]Dependency), Dependencies: make(map[string]deps.Dependency),
LegacyImports: true,
} }
} }
// jsonFile is the json representation of a JsonnetFile, which is different for // jsonFile is the json representation of a JsonnetFile, which is different for
// compatibility reasons. // compatibility reasons.
type jsonFile struct { type jsonFile struct {
Dependencies []Dependency `json:"dependencies"` Dependencies []deps.Dependency `json:"dependencies"`
LegacyImports bool `json:"legacyImports"`
} }
// UnmarshalJSON unmarshals a `jsonFile`'s json into a JsonnetFile // UnmarshalJSON unmarshals a `jsonFile`'s json into a JsonnetFile
func (jf *JsonnetFile) UnmarshalJSON(data []byte) error { func (jf *JsonnetFile) UnmarshalJSON(data []byte) error {
var s jsonFile var s jsonFile
s.LegacyImports = jf.LegacyImports // adpot default
if err := json.Unmarshal(data, &s); err != nil { if err := json.Unmarshal(data, &s); err != nil {
return err return err
} }
jf.Dependencies = make(map[string]Dependency) jf.Dependencies = make(map[string]deps.Dependency)
for _, d := range s.Dependencies { for _, d := range s.Dependencies {
jf.Dependencies[d.Name] = d jf.Dependencies[d.Name()] = d
} }
jf.LegacyImports = s.LegacyImports
return nil return nil
} }
// MarshalJSON serializes a JsonnetFile into json of the format of a `jsonFile` // MarshalJSON serializes a JsonnetFile into json of the format of a `jsonFile`
func (jf JsonnetFile) MarshalJSON() ([]byte, error) { func (jf JsonnetFile) MarshalJSON() ([]byte, error) {
var s jsonFile var s jsonFile
s.LegacyImports = jf.LegacyImports
for _, d := range jf.Dependencies { for _, d := range jf.Dependencies {
s.Dependencies = append(s.Dependencies, d) s.Dependencies = append(s.Dependencies, d)
} }
sort.SliceStable(s.Dependencies, func(i int, j int) bool { sort.SliceStable(s.Dependencies, func(i int, j int) bool {
return s.Dependencies[i].Name < s.Dependencies[j].Name return s.Dependencies[i].Name() < s.Dependencies[j].Name()
}) })
if s.Dependencies == nil {
s.Dependencies = make([]deps.Dependency, 0, 0)
}
return json.Marshal(s) return json.Marshal(s)
} }
type Dependency struct {
Name string `json:"name"`
Source Source `json:"source"`
Version string `json:"version"`
Sum string `json:"sum,omitempty"`
DepSource string `json:"-"`
}
type Source struct {
GitSource *GitSource `json:"git,omitempty"`
LocalSource *LocalSource `json:"local,omitempty"`
}
type GitSource struct {
Remote string `json:"remote"`
Subdir string `json:"subdir"`
}
type LocalSource struct {
Directory string `json:"directory"`
}

View file

@ -18,6 +18,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -25,7 +26,6 @@ import (
const jsonJF = `{ const jsonJF = `{
"dependencies": [ "dependencies": [
{ {
"name": "grafana-builder",
"source": { "source": {
"git": { "git": {
"remote": "https://github.com/grafana/jsonnet-libs", "remote": "https://github.com/grafana/jsonnet-libs",
@ -36,7 +36,7 @@ const jsonJF = `{
"sum": "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE=" "sum": "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE="
}, },
{ {
"name": "prometheus-mixin", "name": "prometheus",
"source": { "source": {
"git": { "git": {
"remote": "https://github.com/prometheus/prometheus", "remote": "https://github.com/prometheus/prometheus",
@ -46,29 +46,36 @@ const jsonJF = `{
"version": "7c039a6b3b4b2a9d7c613ac8bd3fc16e8ca79684", "version": "7c039a6b3b4b2a9d7c613ac8bd3fc16e8ca79684",
"sum": "bVGOsq3hLOw2irNPAS91a5dZJqQlBUNWy3pVwM4+kIY=" "sum": "bVGOsq3hLOw2irNPAS91a5dZJqQlBUNWy3pVwM4+kIY="
} }
] ],
"legacyImports": false
}` }`
func testData() JsonnetFile { func testData() JsonnetFile {
return JsonnetFile{ return JsonnetFile{
Dependencies: map[string]Dependency{ LegacyImports: false,
"grafana-builder": { Dependencies: map[string]deps.Dependency{
Name: "grafana-builder", "github.com/grafana/jsonnet-libs/grafana-builder": {
Source: Source{ Source: deps.Source{
GitSource: &GitSource{ GitSource: &deps.Git{
Remote: "https://github.com/grafana/jsonnet-libs", Scheme: deps.GitSchemeHTTPS,
Subdir: "grafana-builder", Host: "github.com",
User: "grafana",
Repo: "jsonnet-libs",
Subdir: "/grafana-builder",
}, },
}, },
Version: "54865853ebc1f901964e25a2e7a0e4d2cb6b9648", Version: "54865853ebc1f901964e25a2e7a0e4d2cb6b9648",
Sum: "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE=", Sum: "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE=",
}, },
"prometheus-mixin": { "github.com/prometheus/prometheus/documentation/prometheus-mixin": {
Name: "prometheus-mixin", LegacyNameCompat: "prometheus",
Source: Source{ Source: deps.Source{
GitSource: &GitSource{ GitSource: &deps.Git{
Remote: "https://github.com/prometheus/prometheus", Scheme: deps.GitSchemeHTTPS,
Subdir: "documentation/prometheus-mixin", Host: "github.com",
User: "prometheus",
Repo: "prometheus",
Subdir: "/documentation/prometheus-mixin",
}, },
}, },
Version: "7c039a6b3b4b2a9d7c613ac8bd3fc16e8ca79684", Version: "7c039a6b3b4b2a9d7c613ac8bd3fc16e8ca79684",

127
tool/rewrite/rewrite.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 rewrite provides a tool that automatically rewrites legacy imports to
// absolute ones
package rewrite
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
)
var expr = regexp.MustCompile(`(?mU)(import ["'])(.*)(\/.*["'])`)
// Rewrite changes all imports in `dir` from legacy to absolute style
// All files in `vendorDir` are ignored
func Rewrite(dir, vendorDir string, packages map[string]deps.Dependency) error {
imports := make(map[string]string)
for _, p := range packages {
if p.LegacyName() == p.Name() {
continue
}
imports[p.LegacyName()] = p.Name()
}
vendorFi, err := os.Stat(filepath.Join(dir, vendorDir))
if err != nil {
return err
}
// list all Jsonnet files
files := []string{}
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if os.SameFile(vendorFi, info) {
return filepath.SkipDir
}
if ext := filepath.Ext(path); ext == ".jsonnet" || ext == ".libsonnet" {
files = append(files, path)
}
return nil
}); err != nil {
return err
}
// change the imports
for _, s := range files {
if err := replaceFile(s, imports); err != nil {
return err
}
}
return nil
}
func wrap(s, q string) string {
return fmt.Sprintf(`import %s%s`, q, s)
}
func replaceFile(name string, imports map[string]string) error {
raw, err := ioutil.ReadFile(name)
if err != nil {
return err
}
out := replace(string(raw), imports)
return ioutil.WriteFile(name, out, 0644)
}
func replace(data string, imports map[string]string) []byte {
contents := strings.Split(string(data), "\n")
// try to fix imports line by line
buf := make([]string, 0, len(contents))
for _, line := range contents {
match := expr.FindStringSubmatch(line)
// no import in this line: push unmodified
if len(match) == 0 {
buf = append(buf, line)
continue
}
// the legacyName
matchedName := match[2]
replaced := false
for legacy, absolute := range imports {
// not this import
if matchedName != legacy {
continue
}
// fix the import
replaced = true
buf = append(buf, expr.ReplaceAllString(line, "${1}"+absolute+"${3}"))
}
// no matching known import found? push unmodified
if !replaced {
buf = append(buf, line)
}
}
return []byte(strings.Join(buf, "\n"))
}

View file

@ -0,0 +1,76 @@
// Copyright 2018 jsonnet-bundler authors
//
// 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 rewrite
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/jsonnet-bundler/jsonnet-bundler/spec/deps"
)
const sample = `
(import "k.libsonnet") + // not vendored
(import "ksonnet/abc.jsonnet") + // prefix of next
(import "ksonnet.beta.4/k.libsonnet") + // normal import
(import "github.com/ksonnet/ksonnet/def.jsonnet") + // already absolute
(import "prometheus/mixin/whatever/abc.libsonnet") + // nested
(import "mylib/foo.libsonnet") + // not managed by jb
// completely unrelated line:
[ "nice" ]
`
const want = `
(import "k.libsonnet") + // not vendored
(import "github.com/ksonnet/ksonnet/abc.jsonnet") + // prefix of next
(import "github.com/ksonnet/ksonnet-lib/ksonnet.beta.4/k.libsonnet") + // normal import
(import "github.com/ksonnet/ksonnet/def.jsonnet") + // already absolute
(import "github.com/prometheus/prometheus/mixin/whatever/abc.libsonnet") + // nested
(import "mylib/foo.libsonnet") + // not managed by jb
// completely unrelated line:
[ "nice" ]
`
func TestRewrite(t *testing.T) {
dir, err := ioutil.TempDir("", "jbrewrite")
require.Nil(t, err)
defer os.RemoveAll(dir)
name := filepath.Join(dir, "test.jsonnet")
err = ioutil.WriteFile(name, []byte(sample), 0644)
require.Nil(t, err)
vendorDir := filepath.Join(dir, "vendor")
err = os.MkdirAll(vendorDir, os.ModePerm)
require.Nil(t, err)
err = Rewrite(dir, "vendor", locks)
require.Nil(t, err)
content, err := ioutil.ReadFile(name)
require.Nil(t, err)
assert.Equal(t, want, string(content))
}
var locks = map[string]deps.Dependency{
"ksonnet": *deps.Parse("", "github.com/ksonnet/ksonnet"),
"ksonnet.beta.4": *deps.Parse("", "github.com/ksonnet/ksonnet-lib/ksonnet.beta.4"),
"prometheus": *deps.Parse("", "github.com/prometheus/prometheus"),
}