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
This commit is contained in:
Luke Kingland 2021-08-25 02:41:24 +09:00 committed by GitHub
parent a4b15ad992
commit 3f56a8fd7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 699 additions and 145 deletions

View File

@ -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

View File

@ -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 }

View File

@ -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)
}

View File

@ -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) {

View File

@ -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)
}
}

123
repository.go Normal file
View File

@ -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")
}

59
repository_test.go Normal file
View File

@ -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")
}
}

View File

@ -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

View File

@ -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"