From 3f56a8fd7a66b923294043bcaa68ad59b1228831 Mon Sep 17 00:00:00 2001 From: Luke Kingland <58986931+lkingland@users.noreply.github.com> Date: Wed, 25 Aug 2021 02:41:24 +0900 Subject: [PATCH] feat: repository and templates client api (#475) * feat: repositories accessor * feat: repository and templates client api - Templates management api - Repositories management api expansion * fix: nil pointer reference on generate * src: remove unused test functions * src: test temp directory name consistency and comment improvements --- Makefile | 2 +- client.go | 81 ++++++++- client_test.go | 136 ++++++++++----- repositories.go | 80 +++++++-- repositories_test.go | 159 +++++++++--------- repository.go | 123 ++++++++++++++ repository_test.go | 59 +++++++ templates.go | 126 +++++++++++++- templates_test.go | 78 ++++++++- .../customProvider/customRuntime/.gitinclude | 0 .../customRuntime/customTemplate/custom.impl | 0 .../go/customTemplate/custom.go | 0 .../repositoryTests/go/custom/custom.go | 0 .../repositoryTests/node/custom/custom.js | 0 14 files changed, 699 insertions(+), 145 deletions(-) create mode 100644 repository.go create mode 100644 repository_test.go create mode 100644 testdata/repositories/customProvider/customRuntime/.gitinclude create mode 100644 testdata/repositories/customProvider/customRuntime/customTemplate/custom.impl create mode 100644 testdata/repositories/customProvider/go/customTemplate/custom.go create mode 100644 testdata/repositories/repositoryTests/go/custom/custom.go create mode 100644 testdata/repositories/repositoryTests/node/custom/custom.js diff --git a/Makefile b/Makefile index 7f6527b6..f6565b29 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ $(BIN): $(CODE) env CGO_ENABLED=0 go build -ldflags $(LDFLAGS) ./cmd/$(BIN) test: $(CODE) ## Run core unit tests - go test -race -cover -coverprofile=coverage.out -v ./... + go test -race -cover -coverprofile=coverage.out ./... check: bin/golangci-lint ## Check code quality (lint) ./bin/golangci-lint run --timeout 300s diff --git a/client.go b/client.go index be3ecc02..bca749e5 100644 --- a/client.go +++ b/client.go @@ -8,23 +8,35 @@ import ( "io/ioutil" "os" "path/filepath" + "sort" + "sync" "gopkg.in/yaml.v2" ) const ( + // DefaultRegistry through which containers of Functions will be shuttled. DefaultRegistry = "docker.io" - DefaultRuntime = "node" + + // DefaultRuntime is the runtime language for a new Function, including + // the template written and builder invoked on deploy. + DefaultRuntime = "node" + // DefautlTemplate is the default Function signature / environmental context // of the resultant function. All runtimes are expected to have at least // one implementation of each supported funciton sinagure. Currently that // includes an HTTP Handler ("http") and Cloud Events handler ("events") DefaultTemplate = "http" + + // DefaultRepository is the name of the default (builtin) template repository, + // and is assumed when no template prefix is provided. + DefaultRepository = "default" ) // Client for managing Function instances. type Client struct { - Repositories *Repositories // Repository management + Repositories *Repositories // Repositories management + Templates *Templates // Templates management verbose bool // print verbose logs builder Builder // Builds a runnable image from Function source @@ -166,6 +178,7 @@ func New(options ...Option) *Client { // Instantiate client with static defaults. c := &Client{ Repositories: &Repositories{}, + Templates: &Templates{}, builder: &noopBuilder{output: os.Stdout}, pusher: &noopPusher{output: os.Stdout}, deployer: &noopDeployer{output: os.Stdout}, @@ -177,7 +190,13 @@ func New(options ...Option) *Client { emitter: &noopEmitter{}, } - // Apply passed options, which take ultimate precidence. + // TODO: Repositories default location ($XDG_CONFIG_HOME/func/repositories) + // will be relocated from CLI to here. + // c.Repositories.Path = ... + + // Templates management requires the repositories management api + c.Templates.Repositories = c.Repositories + for _, o := range options { o(c) } @@ -618,6 +637,45 @@ func (c *Client) Emit(ctx context.Context, endpoint string) error { return c.emitter.Emit(ctx, endpoint) } +// sorted set of strings. +// +// write-optimized and suitable only for fairly small values of N. +// Should this increase dramatically in size, a different implementation, +// such as a linked list, might be more appropriate. +type sortedSet struct { + members map[string]bool + sync.Mutex +} + +func newSortedSet() *sortedSet { + return &sortedSet{ + members: make(map[string]bool), + } +} + +func (s *sortedSet) Add(value string) { + s.Lock() + s.members[value] = true + s.Unlock() +} + +func (s *sortedSet) Remove(value string) { + s.Lock() + delete(s.members, value) + s.Unlock() +} + +func (s *sortedSet) Items() []string { + s.Lock() + defer s.Unlock() + n := []string{} + for k := range s.members { + n = append(n, k) + } + sort.Strings(n) + return n +} + // Manual implementations (noops) of required interfaces. // In practice, the user of this client package (for example the CLI) will // provide a concrete implementation for only the interfaces necessary to @@ -630,36 +688,49 @@ func (c *Client) Emit(ctx context.Context, endpoint string) error { // with a minimum of external dependencies. // ----------------------------------------------------- +// Builder type noopBuilder struct{ output io.Writer } func (n *noopBuilder) Build(ctx context.Context, _ Function) error { return nil } +// Pusher type noopPusher struct{ output io.Writer } func (n *noopPusher) Push(ctx context.Context, f Function) (string, error) { return "", nil } +// Deployer type noopDeployer struct{ output io.Writer } func (n *noopDeployer) Deploy(ctx context.Context, _ Function) (DeploymentResult, error) { return DeploymentResult{}, nil } +// Runner type noopRunner struct{ output io.Writer } func (n *noopRunner) Run(_ context.Context, _ Function) error { return nil } +// Remover type noopRemover struct{ output io.Writer } func (n *noopRemover) Remove(context.Context, string) error { return nil } +// Lister type noopLister struct{ output io.Writer } func (n *noopLister) List(context.Context) ([]ListItem, error) { return []ListItem{}, nil } +// Emitter +type noopEmitter struct{} + +func (p *noopEmitter) Emit(ctx context.Context, endpoint string) error { return nil } + +// DNSProvider type noopDNSProvider struct{ output io.Writer } func (n *noopDNSProvider) Provide(_ Function) error { return nil } +// ProgressListener type NoopProgressListener struct{} func (p *NoopProgressListener) SetTotal(i int) {} @@ -667,7 +738,3 @@ func (p *NoopProgressListener) Increment(m string) {} func (p *NoopProgressListener) Complete(m string) {} func (p *NoopProgressListener) Stopping() {} func (p *NoopProgressListener) Done() {} - -type noopEmitter struct{} - -func (p *noopEmitter) Emit(ctx context.Context, endpoint string) error { return nil } diff --git a/client_test.go b/client_test.go index 6917b1d7..9bb735f3 100644 --- a/client_test.go +++ b/client_test.go @@ -19,7 +19,7 @@ const ( // TestRegistry for calculating destination image during tests. // Will be optional once we support in-cluster container registries // by default. See TestRegistryRequired for details. - TestRegistry = "quay.io/alice" + TestRegistry = "example.com/alice" // TestRuntime consists of a specially designed templates directory // used exclusively for tests. @@ -31,65 +31,73 @@ const ( // thus implicitly tests Create, Build and Deploy, which are exposed // by the client API for those who prefer manual transmissions. func TestNew(t *testing.T) { - root := "testdata/example.com/testCreate" // Root from which to run the test + root := "testdata/example.com/testNew" defer using(t, root)() - // New Client client := fn.New(fn.WithRegistry(TestRegistry)) - // New Function using Client if err := client.New(context.Background(), fn.Function{Root: root}); err != nil { t.Fatal(err) } } -// TestTemplateWrites ensures a template is written. -func TestTemplateWrites(t *testing.T) { - root := "testdata/example.com/testCreateWrites" +// TestWritesTemplate ensures the config file and files from the template +// are written on new. +func TestWritesTemplate(t *testing.T) { + root := "testdata/example.com/testWritesTemplate" defer using(t, root)() client := fn.New(fn.WithRegistry(TestRegistry)) - if err := client.Create(fn.Function{Root: root}); err != nil { + + if err := client.New(context.Background(), fn.Function{Root: root}); err != nil { t.Fatal(err) } - // Assert file was written + // Assert the standard config file was written if _, err := os.Stat(filepath.Join(root, fn.ConfigFile)); os.IsNotExist(err) { t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root) } + + // Assert a file from the template was written + if _, err := os.Stat(filepath.Join(root, "README.md")); os.IsNotExist(err) { + t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root) + } } // TestExtantAborts ensures that a directory which contains an extant -// Function does not reinitialize +// Function does not reinitialize. func TestExtantAborts(t *testing.T) { - root := "testdata/example.com/testCreateInitializedAborts" + root := "testdata/example.com/testExtantAborts" defer using(t, root)() - // New once client := fn.New(fn.WithRegistry(TestRegistry)) + + // First .New should succeed... if err := client.New(context.Background(), fn.Function{Root: root}); err != nil { t.Fatal(err) } - // New again should fail as already initialized + // Calling again should abort... if err := client.New(context.Background(), fn.Function{Root: root}); err == nil { t.Fatal("error expected initilizing a path already containing an initialized Function") } } -// TestNonemptyDirectoryAborts ensures that a directory which contains any -// visible files aborts. -func TestNonemptyDirectoryAborts(t *testing.T) { - root := "testdata/example.com/testCreateNonemptyDirectoryAborts" +// TestNonemptyAborts ensures that a directory which contains any +// (visible) files aborts. +func TestNonemptyAborts(t *testing.T) { + root := "testdata/example.com/testNonemptyAborts" defer using(t, root)() - // An unexpected, non-hidden file. + client := fn.New(fn.WithRegistry(TestRegistry)) + + // Write a visible file which should cause an aboert visibleFile := filepath.Join(root, "file.txt") if err := ioutil.WriteFile(visibleFile, []byte{}, 0644); err != nil { t.Fatal(err) } - client := fn.New(fn.WithRegistry(TestRegistry)) + // Ceation should abort due to the visible file if err := client.New(context.Background(), fn.Function{Root: root}); err == nil { t.Fatal("error expected initilizing a Function in a nonempty directory") } @@ -102,16 +110,18 @@ func TestNonemptyDirectoryAborts(t *testing.T) { // conjunction with other tools (.envrc, etc) func TestHiddenFilesIgnored(t *testing.T) { // Create a directory for the Function - root := "testdata/example.com/testCreateHiddenFilesIgnored" + root := "testdata/example.com/testHiddenFilesIgnored" defer using(t, root)() + client := fn.New(fn.WithRegistry(TestRegistry)) + // Create a hidden file that should be ignored. hiddenFile := filepath.Join(root, ".envrc") if err := ioutil.WriteFile(hiddenFile, []byte{}, 0644); err != nil { t.Fatal(err) } - client := fn.New(fn.WithRegistry(TestRegistry)) + // Should succeed without error, ignoring the hidden file. if err := client.New(context.Background(), fn.Function{Root: root}); err != nil { t.Fatal(err) } @@ -121,11 +131,12 @@ func TestHiddenFilesIgnored(t *testing.T) { // Functions and persisted. func TestDefaultRuntime(t *testing.T) { // Create a root for the new Function - root := "testdata/example.com/testCreateDefaultRuntime" + root := "testdata/example.com/testDefaultRuntime" defer using(t, root)() - // Create a new function at root with all defaults. client := fn.New(fn.WithRegistry(TestRegistry)) + + // Create a new function at root with all defaults. if err := client.New(context.Background(), fn.Function{Root: root}); err != nil { t.Fatal(err) } @@ -142,7 +153,7 @@ func TestDefaultRuntime(t *testing.T) { } } -// TestExtensibleRepositories ensures that templates are extensible +// TestRepositoriesExtensible ensures that templates are extensible // using a custom path to template repositories on disk. The custom repositories // location is not defined herein but expected to be provided because, for // example, a CLI may want to use XDG_CONFIG_HOME. Assuming a repository path @@ -151,12 +162,10 @@ func TestDefaultRuntime(t *testing.T) { // $FUNC_REPOSITORIES/boson/go/json // See the CLI for full details, but a standard default location is // $HOME/.config/func/repositories/boson/go/json -func TestExtensibleRepositories(t *testing.T) { - // Create a directory for the new Function - root := "testdata/example.com/testExtensibleRepositories" +func TestRepositoriesExtensible(t *testing.T) { + root := "testdata/example.com/testRepositoriesExtensible" defer using(t, root)() - // Create a new client with a path to the extensible templates client := fn.New( fn.WithRepositories("testdata/repositories"), fn.WithRegistry(TestRegistry)) @@ -176,7 +185,6 @@ func TestExtensibleRepositories(t *testing.T) { // TestRuntimeNotFound generates an error (embedded default repository). func TestRuntimeNotFound(t *testing.T) { - // Create a directory for the Function root := "testdata/example.com/testRuntimeNotFound" defer using(t, root)() @@ -184,8 +192,7 @@ func TestRuntimeNotFound(t *testing.T) { // creating a Function with an unsupported runtime should bubble // the error generated by the underlying template initializer. - f := fn.Function{Root: root, Runtime: "invalid"} - err := client.New(context.Background(), f) + err := client.New(context.Background(), fn.Function{Root: root, Runtime: "invalid"}) if !errors.Is(err, fn.ErrRuntimeNotFound) { t.Fatalf("Expected ErrRuntimeNotFound, got %T", err) } @@ -258,7 +265,7 @@ func TestNamed(t *testing.T) { // Path which would derive to testWithHame.example.com were it not for the // explicitly provided name. - root := "testdata/example.com/testWithName" + root := "testdata/example.com/testNamed" defer using(t, root)() client := fn.New(fn.WithRegistry(TestRegistry)) @@ -289,7 +296,7 @@ func TestNamed(t *testing.T) { // this configuration parameter will become optional. func TestRegistryRequired(t *testing.T) { // Create a root for the Function - root := "testdata/example.com/testRegistry" + root := "testdata/example.com/testRegistryRequired" defer using(t, root)() client := fn.New() @@ -361,9 +368,9 @@ func TestDeriveImageDefaultRegistry(t *testing.T) { // Deploy (and confirms expected fields calculated). func TestNewDelegates(t *testing.T) { var ( - root = "testdata/example.com/testCreateDelegates" // .. in which to initialize - expectedName = "testCreateDelegates" // expected to be derived - expectedImage = "quay.io/alice/testCreateDelegates:latest" + root = "testdata/example.com/testNewDelegates" // .. in which to initialize + expectedName = "testNewDelegates" // expected to be derived + expectedImage = "example.com/alice/testNewDelegates:latest" builder = mock.NewBuilder() pusher = mock.NewPusher() deployer = mock.NewDeployer() @@ -471,7 +478,7 @@ func TestUpdate(t *testing.T) { var ( root = "testdata/example.com/testUpdate" expectedName = "testUpdate" - expectedImage = "quay.io/alice/testUpdate:latest" + expectedImage = "example.com/alice/testUpdate:latest" builder = mock.NewBuilder() pusher = mock.NewPusher() deployer = mock.NewDeployer() @@ -581,7 +588,7 @@ func TestRemoveByPath(t *testing.T) { // of the name provided, with precidence over a provided root path. func TestRemoveByName(t *testing.T) { var ( - root = "testdata/example.com/testRemoveByPath" + root = "testdata/example.com/testRemoveByName" expectedName = "explicitName.example.com" remover = mock.NewRemover() ) @@ -683,7 +690,7 @@ func TestListOutsideRoot(t *testing.T) { // fully created (ie. was only initialized, not actually built and deploys) // yields an expected, and informative, error. func TestDeployUnbuilt(t *testing.T) { - root := "testdata/example.com/testDeploy" // Root from which to run the test + root := "testdata/example.com/testDeployUnbuilt" // Root from which to run the test defer using(t, root)() // New Client @@ -731,8 +738,7 @@ func TestEmit(t *testing.T) { // Helpers ---- -// using the given directory (creating it) returns a closure which removes the -// directory, intended to be run in a defer statement. +// USING: Make specified dir. Return deferrable cleanup fn. func using(t *testing.T, root string) func() { t.Helper() mkdir(t, root) @@ -754,3 +760,51 @@ func rm(t *testing.T, dir string) { t.Fatal(err) } } + +// MKTEMP: Create and CD to a temp dir. +// Returns a deferrable cleanup fn. +func mktemp(t *testing.T) (string, func()) { + t.Helper() + tmp := tempdir(t) + owd := pwd(t) + cd(t, tmp) + return tmp, func() { + os.RemoveAll(tmp) + cd(t, owd) + } +} + +func tempdir(t *testing.T) string { + d, err := ioutil.TempDir("", "dir") + if err != nil { + t.Fatal(err) + } + return d +} + +func pwd(t *testing.T) string { + d, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return d +} + +func cd(t *testing.T, dir string) { + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } +} + +// TEST REPO URI: Return URI to repo in ./testdata of matching name. +// Suitable as URI for repository override. returns in form file:// +// Must be called prior to mktemp in tests which changes current +// working directory as it depends on a relative path. +// Repo uri: file://$(pwd)/testdata/repository.git (unix-like) +// file: //$(pwd)\testdata\repository.git (windows) +func testRepoURI(name string, t *testing.T) string { + t.Helper() + cwd, _ := os.Getwd() + repo := filepath.Join(cwd, "testdata", name+".git") + return fmt.Sprintf(`file://%s`, repo) +} diff --git a/repositories.go b/repositories.go index 1ae4fd08..f59404ca 100644 --- a/repositories.go +++ b/repositories.go @@ -10,10 +10,69 @@ import ( "github.com/go-git/go-git/v5" ) -// Repositories management. +// Repositories manager type Repositories struct { - // Path to repositories - Path string + Path string // Path to repositories +} + +// List all repositories installed at the defined root path plus builtin. +func (r *Repositories) List() ([]string, error) { + repositories, err := r.All() + if err != nil { + return []string{}, err + } + + names := []string{} + for _, repo := range repositories { + names = append(names, repo.Name) + } + return names, nil +} + +// All repositories under management (at configured Path) +func (r *Repositories) All() (repos []Repository, err error) { + repos = []Repository{} + + // Single repo override + // TODO: Create single remote repository override for WithRepository option. + + // Default (builtin) repo always first + builtin, err := NewRepositoryFromBuiltin() + if err != nil { + return + } + repos = append(repos, builtin) + + // read repos from filesystem (sorted by name) + // TODO: when manifests are introduced, the final name may be different + // than the name on the filesystem, and as such we can not rely on the + // alphanumeric ordering of underlying list, and will instead have to sort + // by configured name. + ff, err := ioutil.ReadDir(r.Path) + if err != nil { + return + } + for _, f := range ff { + if !f.IsDir() || strings.HasPrefix(f.Name(), ".") { + continue + } + var repo Repository + repo, err = NewRepositoryFromPath(filepath.Join(r.Path, f.Name())) + if err != nil { + return + } + repos = append(repos, repo) + } + return repos, nil +} + +// Get a repository by name, error if it does not exist. +func (r *Repositories) Get(name string) (repo Repository, err error) { + if name == DefaultRepository { + return NewRepositoryFromBuiltin() + } + // TODO: when WithRepository defined, only it can be defined + return NewRepositoryFromPath(filepath.Join(r.Path, name)) } // Add a repository of the given name from the URI. Name, if not provided, @@ -45,21 +104,6 @@ func (r *Repositories) Remove(name string) error { return os.RemoveAll(path) } -// List repositories installed at the defined root path. -func (r *Repositories) List() (list []string, err error) { - list = []string{} - ff, err := ioutil.ReadDir(r.Path) - if err != nil { - return - } - for _, f := range ff { - if f.IsDir() && !strings.HasPrefix(f.Name(), ".") { - list = append(list, f.Name()) - } - } - return -} - // repoNameFrom uri returns the last token with any .git suffix trimmed. // uri must be parseable as a net/URL func repoNameFrom(uri string) (name string, err error) { diff --git a/repositories_test.go b/repositories_test.go index 816bc26a..7846c99b 100644 --- a/repositories_test.go +++ b/repositories_test.go @@ -3,7 +3,6 @@ package function_test import ( - "io/ioutil" "os" "path/filepath" "testing" @@ -13,51 +12,110 @@ import ( const RepositoriesTestRepo = "repository-a" -// TestRepositoriesList ensures the base case of an empty list -// when no repositories are installed. +// TestRepositoriesList ensures the base case of listing +// repositories without error in the default scenario of builtin only. func TestRepositoriesList(t *testing.T) { root, rm := mktemp(t) defer rm() - client := fn.New(fn.WithRepositories(root)) + client := fn.New(fn.WithRepositories(root)) // Explicitly empty rr, err := client.Repositories.List() if err != nil { t.Fatal(err) } - if len(rr) != 0 { - t.Fatalf("Expected an empty repositories list, got %v", len(rr)) + // Assert contains only the default repo + if len(rr) != 1 && rr[0] != fn.DefaultRepository { + t.Fatalf("Expected repository list '[%v]', got %v", fn.DefaultRepository, rr) + } +} + +// TestRepositoriesGet ensures a repository can be accessed by name. +func TestRepositoriesGet(t *testing.T) { + client := fn.New(fn.WithRepositories("testdata/repositories")) + + // invalid should error + repo, err := client.Repositories.Get("invalid") + if err == nil { + t.Fatal("did not receive expected error getting inavlid repository") + } + + // valid should not error + repo, err = client.Repositories.Get("customProvider") + if err != nil { + t.Fatal(err) + } + + // valid should have expected name + if repo.Name != "customProvider" { + t.Fatalf("expected 'customProvider' as repository name, got: %v", repo.Name) + } +} + +// TestRepositoriesAll ensures builtin and extended repos are returned from +// .All accessor. +func TestRepositoriesAll(t *testing.T) { + uri := testRepoURI(RepositoriesTestRepo, t) + root, rm := mktemp(t) + defer rm() + + client := fn.New(fn.WithRepositories(root)) + + // Assert initially only the default is included + rr, err := client.Repositories.All() + if err != nil { + t.Fatal(err) + } + if len(rr) != 1 && rr[0].Name != fn.DefaultRepository { + t.Fatalf("Expected initial repo list to be only the default. Got %v", rr) + } + + // Add one + err = client.Repositories.Add("", uri) + if err != nil { + t.Fatal(err) + } + + // Get full list + repositories, err := client.Repositories.All() + if err != nil { + t.Fatal(err) + } + + // Assert it now includes both builtin and extended + if len(repositories) != 2 || + repositories[0].Name != fn.DefaultRepository || + repositories[1].Name != RepositoriesTestRepo { + t.Fatal("Repositories list does not pass shallow repository membership check") } } // TestRepositoriesAdd ensures that adding a repository adds it to the FS // and List output. Uses default name (repo name). func TestRepositoriesAdd(t *testing.T) { - uri := testRepoURI(t) // ./testdata/$RepositoriesTestRepo.git - root, rm := mktemp(t) // create and cd to a temp dir + uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/$RepositoriesTestRepo.git + root, rm := mktemp(t) // create and cd to a temp dir defer rm() - // Instantiate the client using the current temp directory as the - // repositories' root location. client := fn.New(fn.WithRepositories(root)) - // Create a new repository without a name (use default of repo name) + // Add repo at uri if err := client.Repositories.Add("", uri); err != nil { t.Fatal(err) } - // assert list len 1 + // Assert list now includes the test repo rr, err := client.Repositories.List() if err != nil { t.Fatal(err) } - if len(rr) != 1 || rr[0] != RepositoriesTestRepo { + if len(rr) != 2 || rr[1] != RepositoriesTestRepo { t.Fatalf("Expected '%v', got %v", RepositoriesTestRepo, rr) } // assert expected name - if rr[0] != RepositoriesTestRepo { - t.Fatalf("Expected name '%v', got %v", RepositoriesTestRepo, rr[0]) + if rr[1] != RepositoriesTestRepo { + t.Fatalf("Expected name '%v', got %v", RepositoriesTestRepo, rr[1]) } // assert repo was checked out @@ -72,8 +130,8 @@ func TestRepositoriesAdd(t *testing.T) { // TestRepositoriesAddNamed ensures that adding a repository with a specified // name takes precidence over the default of repo name. func TestRepositoriesAddNamed(t *testing.T) { - uri := testRepoURI(t) // ./testdata/$RepositoriesTestRepo.git - root, rm := mktemp(t) // create and cd to a temp dir, returning path. + uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/$RepositoriesTestRepo.git + root, rm := mktemp(t) // create and cd to a temp dir, returning path. defer rm() // Instantiate the client using the current temp directory as the @@ -89,7 +147,7 @@ func TestRepositoriesAddNamed(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rr) != 1 || rr[0] != name { + if len(rr) != 2 || rr[1] != name { t.Fatalf("Expected '%v', got %v", name, rr) } @@ -102,7 +160,7 @@ func TestRepositoriesAddNamed(t *testing.T) { // TestRepositoriesAddExistingErrors ensures that adding a repository that // already exists yields an error. func TestRepositoriesAddExistingErrors(t *testing.T) { - uri := testRepoURI(t) + uri := testRepoURI(RepositoriesTestRepo, t) root, rm := mktemp(t) // create and cd to a temp dir, returning path. defer rm() @@ -124,7 +182,7 @@ func TestRepositoriesAddExistingErrors(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rr) != 1 || rr[0] != name { + if len(rr) != 2 || rr[1] != name { t.Fatalf("Expected '[%v]', got %v", name, rr) } @@ -136,7 +194,7 @@ func TestRepositoriesAddExistingErrors(t *testing.T) { // TestRepositoriesRename ensures renaming a repository. func TestRepositoriesRename(t *testing.T) { - uri := testRepoURI(t) + uri := testRepoURI(RepositoriesTestRepo, t) root, rm := mktemp(t) // create and cd to a temp dir, returning path. defer rm() @@ -157,7 +215,7 @@ func TestRepositoriesRename(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rr) != 1 || rr[0] != "bar" { + if len(rr) != 2 || rr[1] != "bar" { t.Fatalf("Expected '[bar]', got %v", rr) } @@ -170,8 +228,8 @@ func TestRepositoriesRename(t *testing.T) { // TestRepositoriesRemove ensures that removing a repository by name // removes it from the list and FS. func TestRepositoriesRemove(t *testing.T) { - uri := testRepoURI(t) // ./testdata/repository.git - root, rm := mktemp(t) // create and cd to a temp dir, returning path. + uri := testRepoURI(RepositoriesTestRepo, t) // ./testdata/repository.git + root, rm := mktemp(t) // create and cd to a temp dir, returning path. defer rm() // Instantiate the client using the current temp directory as the @@ -192,8 +250,8 @@ func TestRepositoriesRemove(t *testing.T) { if err != nil { t.Fatal(err) } - if len(rr) != 0 { - t.Fatalf("Expected empty repo list upon remove. Got %v", rr) + if len(rr) != 1 { + t.Fatalf("Expected repo list of len 1. Got %v", rr) } // assert repo not on filesystem @@ -201,52 +259,3 @@ func TestRepositoriesRemove(t *testing.T) { t.Fatalf("Repo %v still exists on filesystem.", name) } } - -// Helpers --- - -// testRepoURI returns a file:// URI to a test repository in -// testdata. Must be called prior to mktemp in tests which changes current -// working directory. -// Repo uri: file://$(pwd)/testdata/repository.git (unix-like) -// file: //$(pwd)\testdata\repository.git (windows) -func testRepoURI(t *testing.T) string { - t.Helper() - cwd, _ := os.Getwd() - repo := filepath.Join(cwd, "testdata", RepositoriesTestRepo+".git") - return "file://" + filepath.ToSlash(repo) -} - -// mktemp creates a temp dir, returning its path -// and a function which will remove it. -func mktemp(t *testing.T) (string, func()) { - t.Helper() - tmp := mktmp(t) - owd := pwd(t) - cd(t, tmp) - return tmp, func() { - os.RemoveAll(tmp) - cd(t, owd) - } -} - -func mktmp(t *testing.T) string { - d, err := ioutil.TempDir("", "dir") - if err != nil { - t.Fatal(err) - } - return d -} - -func pwd(t *testing.T) string { - d, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - return d -} - -func cd(t *testing.T, dir string) { - if err := os.Chdir(dir); err != nil { - t.Fatal(err) - } -} diff --git a/repository.go b/repository.go new file mode 100644 index 00000000..19e860a5 --- /dev/null +++ b/repository.go @@ -0,0 +1,123 @@ +package function + +import ( + "errors" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/markbates/pkger" +) + +// Path to builtin repositories. +// note: this constant must be defined in the same file in which it is used due +// to pkger performing static analysis on source files separately. +const builtinRepositories = "/templates" + +// Repository +type Repository struct { + Name string + Templates []Template + Runtimes []string +} + +// NewRepository from path. +// Represents the file structure of 'path' at time of construction as +// a Repository with Templates, each of which has a Name and its Runtime. +// a convenience member of Runtimes is the unique, sorted list of all +// runtimes +func NewRepositoryFromPath(path string) (Repository, error) { + // TODO: read and use manifest if it exists + + r := Repository{ + Name: filepath.Base(path), + Templates: []Template{}, + Runtimes: []string{}} + + // Each subdirectory is a Runtime + runtimes, err := ioutil.ReadDir(path) + if err != nil { + return r, err + } + for _, runtime := range runtimes { + if !runtime.IsDir() || strings.HasPrefix(runtime.Name(), ".") { + continue // ignore files and hidden + } + r.Runtimes = append(r.Runtimes, runtime.Name()) + + // Each subdirectory is a Template + templates, err := ioutil.ReadDir(filepath.Join(path, runtime.Name())) + if err != nil { + return r, err + } + for _, template := range templates { + if !template.IsDir() || strings.HasPrefix(template.Name(), ".") { + continue // ignore files and hidden + } + r.Templates = append(r.Templates, Template{ + Runtime: runtime.Name(), + Repository: r.Name, + Name: template.Name()}) + } + } + return r, nil +} + +// NewRepository from builtin (encoded ./templates) +func NewRepositoryFromBuiltin() (Repository, error) { + r := Repository{ + Name: DefaultRepository, + Templates: []Template{}, + Runtimes: []string{}} + + // Read in runtimes + dir, err := pkger.Open(builtinRepositories) + if err != nil { + return r, err + } + runtimes, err := dir.Readdir(-1) + if err != nil { + return r, err + } + for _, runtime := range runtimes { + if !runtime.IsDir() || strings.HasPrefix(runtime.Name(), ".") { + continue // ignore from runtimes non-directory or hidden items + } + r.Runtimes = append(r.Runtimes, runtime.Name()) + + // Each subdirectory is a Template + templateDir, err := pkger.Open(filepath.Join(builtinRepositories, runtime.Name())) + if err != nil { + return r, err + } + templates, err := templateDir.Readdir(-1) + if err != nil { + return r, err + } + for _, template := range templates { + if !template.IsDir() || strings.HasPrefix(template.Name(), ".") { + continue // ignore from templates non-directory or hidden items + } + r.Templates = append(r.Templates, Template{ + Runtime: runtime.Name(), + Repository: r.Name, + Name: template.Name(), + }) + + } + } + return r, nil +} + +// GetTemplate from repo with given runtime +func (r *Repository) GetTemplate(runtime, name string) (Template, error) { + // TODO: return a typed RuntimeNotFound in repo X + // rather than the generic Template Not Found + for _, t := range r.Templates { + if t.Runtime == runtime && t.Name == name { + return t, nil + } + } + // TODO: Typed TemplateNotFound in repo X + return Template{}, errors.New("template not found") +} diff --git a/repository_test.go b/repository_test.go new file mode 100644 index 00000000..f675f0c1 --- /dev/null +++ b/repository_test.go @@ -0,0 +1,59 @@ +package function_test + +import ( + "reflect" + "testing" + + fn "knative.dev/kn-plugin-func" +) + +// TestRepositoryGetTemplateDefault ensures that repositories make templates +// avaialble via the Get accessor which given name and runtime. +func TestRepositoryGetTemplateDefault(t *testing.T) { + client := fn.New() + + repo, err := client.Repositories.Get(fn.DefaultRepository) + if err != nil { + t.Fatal(err) + } + template, err := repo.GetTemplate("go", "http") + if err != nil { + t.Fatal(err) + } + expected := fn.Template{ + Runtime: "go", + Repository: fn.DefaultRepository, + Name: "http", + } + if !reflect.DeepEqual(template, expected) { + t.Logf("expected: %v", expected) + t.Logf("received: %v", template) + t.Fatal("Default template not as expected") + } +} + +// TestRepositoryGetTemplateCustom ensures that repositories make templates +// avaialble via the Get accessor with given name and runtime. +func TestRepositoryGetTemplateCustom(t *testing.T) { + client := fn.New(fn.WithRepositories("testdata/repositories")) + + repo, err := client.Repositories.Get("repositoryTests") + if err != nil { + t.Fatal(err) + } + template, err := repo.GetTemplate("go", "custom") + if err != nil { + t.Fatal(err) + } + expected := fn.Template{ + Runtime: "go", + Repository: "repositoryTests", + Name: "custom", + } + if !reflect.DeepEqual(template, expected) { + t.Logf("expected: %v", expected) + t.Logf("received: %v", template) + t.Fatal("Custom template not as expected") + } + +} diff --git a/templates.go b/templates.go index b5ca2f30..e24c07e4 100644 --- a/templates.go +++ b/templates.go @@ -20,6 +20,128 @@ import ( "github.com/markbates/pkger" ) +// Path to builtin +// note: this constant must be redefined in each file used due to pkger +// performing static analysis on each source file separately. +const builtinPath = "/templates" + +// Templates Manager +type Templates struct { + Repositories *Repositories // Repository Manager +} + +// Template metadata +type Template struct { + Runtime string + Repository string + Name string +} + +// Fullname is a caluclate field of [repo]/[name] used +// to uniquely reference a template which may share a name +// with one in another repository. +func (t Template) Fullname() string { + return t.Repository + "/" + t.Name +} + +// List the full name of templates available runtime. +// Full name is the optional repository prefix plus the template's repository +// local name. Default templates grouped first sans prefix. +func (t *Templates) List(runtime string) ([]string, error) { + // TODO: if repository override was enabled, we should just return those, flat. + builtin, err := t.ListDefault(runtime) + if err != nil { + return []string{}, err + } + + extended, err := t.ListExtended(runtime) + if err != nil { + return []string{}, err + } + + // Result is an alphanumerically sorted list first grouped by + // embedded at head. + return append(builtin, extended...), nil +} + +// ListDefault (embedded) templates by runtime +func (t *Templates) ListDefault(runtime string) ([]string, error) { + var ( + names = newSortedSet() + repo, err = t.Repositories.Get(DefaultRepository) + ) + if err != nil { + return []string{}, err + } + for _, template := range repo.Templates { + if template.Runtime != runtime { + continue + } + names.Add(template.Name) + } + return names.Items(), nil +} + +// ListExtended templates returns all template full names that +// exist in all extended (config dir) repositories for a runtime. +// Prefixed, sorted. +func (t *Templates) ListExtended(runtime string) ([]string, error) { + var ( + names = newSortedSet() + repos, err = t.Repositories.All() + ) + if err != nil { + return []string{}, err + } + for _, repo := range repos { + if repo.Name == DefaultRepository { + continue // already added at head of names + } + for _, template := range repo.Templates { + if template.Runtime != runtime { + continue + } + names.Add(template.Fullname()) + } + } + return names.Items(), nil +} + +// Template returns the named template in full form '[repo]/[name]' for the +// specified runtime. +// Templates from the default repository do not require the repo name prefix, +// though it can be provided. +func (t *Templates) Get(runtime, fullname string) (Template, error) { + var ( + template Template + repoName string + tplName string + repo Repository + err error + ) + + // Split into repo and template names. + // Defaults when unprefixed to DefaultRepository + cc := strings.Split(fullname, "/") + if len(cc) == 1 { + repoName = DefaultRepository + tplName = fullname + } else { + repoName = cc[0] + tplName = cc[1] + } + + // Get specified repository + repo, err = t.Repositories.Get(repoName) + if err != nil { + return template, err + } + + return repo.GetTemplate(runtime, tplName) +} + +// Writing ------ + type filesystem interface { Stat(name string) (os.FileInfo, error) Open(path string) (file, error) @@ -38,7 +160,7 @@ type file interface { // into pkged.go, which is then made available via a pkger filesystem. Path is // relative to the go module root. func init() { - _ = pkger.Include("/templates") + _ = pkger.Include(builtinPath) } type templateWriter struct { @@ -292,7 +414,7 @@ func (a pkgerFilesystem) ReadDir(path string) ([]os.FileInfo, error) { if err != nil { return nil, err } - return f.Readdir(-1) // Really? Pkger's ReadDir is Readdir. + return f.Readdir(-1) } // billyFilesystem is a template file accessor backed by a billy FS diff --git a/templates_test.go b/templates_test.go index 02bbca5d..193d51d7 100644 --- a/templates_test.go +++ b/templates_test.go @@ -7,13 +7,89 @@ import ( "fmt" "os" "path/filepath" + "reflect" "runtime" "testing" fn "knative.dev/kn-plugin-func" ) -// TestTemplateEmbedded ensures that embedded templates are copied. +// TestTemplatesList ensures that all templates are listed taking into account +// both internal and extensible (prefixed) repositories. +func TestTemplatesList(t *testing.T) { + // A client which specifies a location of exensible repositoreis on disk + // will list all builtin plus exensible + client := fn.New(fn.WithRepositories("testdata/repositories")) + + // list templates for the "go" runtime + templates, err := client.Templates.List("go") + if err != nil { + t.Fatal(err) + } + + // Note that this list will change as the customProvider + // and builtin templates are shared. THis could be mitigated + // by creating a custom repository path for just this test, if + // that becomes a hassle. + expected := []string{ + "events", + "http", + "customProvider/customTemplate", + "repositoryTests/custom", + } + + if !reflect.DeepEqual(templates, expected) { + t.Logf("expected: %v", expected) + t.Logf("received: %v", templates) + t.Fatal("Expected templates list not received.") + } +} + +// TestTemplatesGet ensures that a template's metadata object can +// be retrieved by full name (full name prefix optional for embedded). +func TestTemplatesGet(t *testing.T) { + client := fn.New(fn.WithRepositories("testdata/repositories")) + + // Check embedded + + embedded, err := client.Templates.Get("go", "http") + if err != nil { + t.Fatal(err) + } + + expected := fn.Template{ + Runtime: "go", + Repository: "default", + Name: "http", + } + + if !reflect.DeepEqual(embedded, expected) { + t.Logf("expected: %v", expected) + t.Logf("received: %v", embedded) + t.Fatal("Template from embedded repo not as expected.") + } + + // Check extended + + extended, err := client.Templates.Get("go", "customProvider/customTemplate") + if err != nil { + t.Fatal(err) + } + + expected = fn.Template{ + Runtime: "go", + Repository: "customProvider", + Name: "customTemplate", + } + + if !reflect.DeepEqual(extended, expected) { + t.Logf("expected: %v", expected) + t.Logf("received: %v", extended) + t.Fatal("Template from extended repo not as expected.") + } +} + +// TestTemplateEmbedded ensures that embedded templates are copied on write. func TestTemplateEmbedded(t *testing.T) { // create test directory root := "testdata/testTemplateEmbedded" diff --git a/testdata/repositories/customProvider/customRuntime/.gitinclude b/testdata/repositories/customProvider/customRuntime/.gitinclude new file mode 100644 index 00000000..e69de29b diff --git a/testdata/repositories/customProvider/customRuntime/customTemplate/custom.impl b/testdata/repositories/customProvider/customRuntime/customTemplate/custom.impl new file mode 100644 index 00000000..e69de29b diff --git a/testdata/repositories/customProvider/go/customTemplate/custom.go b/testdata/repositories/customProvider/go/customTemplate/custom.go new file mode 100644 index 00000000..e69de29b diff --git a/testdata/repositories/repositoryTests/go/custom/custom.go b/testdata/repositories/repositoryTests/go/custom/custom.go new file mode 100644 index 00000000..e69de29b diff --git a/testdata/repositories/repositoryTests/node/custom/custom.js b/testdata/repositories/repositoryTests/node/custom/custom.js new file mode 100644 index 00000000..e69de29b