From d33fb2d694b434799cac1ce718f15d90c205f3cf Mon Sep 17 00:00:00 2001 From: Luke K Date: Sat, 29 Aug 2020 00:47:24 +0900 Subject: [PATCH] feat: test suite - updated tests to new api throughout - expanded tests where appropriate - lint issues - minor code review comments addressed --- .gitignore | 2 + client.go | 192 ++-- client_test.go | 969 +++++++++--------- client_unit_test.go | 1 - cmd/delete.go | 4 +- cmd/init.go | 4 +- cmd/list.go | 4 +- config.go | 20 +- function.go | 23 +- function_unit_test.go | 67 -- mock/builder.go | 10 +- mock/deployer.go | 10 +- mock/pusher.go | 10 +- mock/runner.go | 6 +- mock/updater.go | 10 +- templates.go | 16 +- templates_test.go | 117 +-- testdata/README.md | 3 +- testdata/example.com/region1/README.md | 3 + testdata/example.com/www/.appsody-config.yaml | 3 - testdata/example.com/www/.faas.yaml | 2 - testdata/example.com/www/go.mod | 5 - testdata/example.com/www/handle.go | 32 - testdata/example.com/www/handle_test.go | 115 --- .../boson-experimental/go/json/json.go | 0 25 files changed, 669 insertions(+), 959 deletions(-) delete mode 100644 client_unit_test.go create mode 100644 testdata/example.com/region1/README.md delete mode 100644 testdata/example.com/www/.appsody-config.yaml delete mode 100644 testdata/example.com/www/.faas.yaml delete mode 100644 testdata/example.com/www/go.mod delete mode 100644 testdata/example.com/www/handle.go delete mode 100644 testdata/example.com/www/handle_test.go create mode 100644 testdata/templates/boson-experimental/go/json/json.go diff --git a/.gitignore b/.gitignore index 61884524..6f91c0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ target node_modules +/coverage.out +/bin diff --git a/client.go b/client.go index fa72c757..47cde636 100644 --- a/client.go +++ b/client.go @@ -18,8 +18,6 @@ const ( // Client for managing Function instances. type Client struct { verbose bool // print verbose logs - local bool // Run in local-only mode - internal bool // Deploy without publicly accessible route builder Builder // Builds a runnable image from Function source pusher Pusher // Pushes the image assocaited with a Function. deployer Deployer // Deploys a Function @@ -151,20 +149,6 @@ func WithVerbose(v bool) Option { } } -// WithLocal sets the local mode -func WithLocal(l bool) Option { - return func(c *Client) { - c.local = l - } -} - -// WithInternal sets the internal (no public route) mode for deployed Function. -func WithInternal(i bool) Option { - return func(c *Client) { - c.internal = i - } -} - // WithBuilder provides the concrete implementation of a builder. func WithBuilder(d Builder) Option { return func(c *Client) { @@ -266,6 +250,60 @@ func WithRepository(repository string) Option { } } +// Create a Function. +// Includes Initialization, Building, and Deploying. +func (c *Client) Create(cfg Function) (err error) { + c.progressListener.SetTotal(4) + defer c.progressListener.Done() + + // Initialize, writing out a template implementation and a config file. + // TODO: the Function's Initialize parameters are slightly different than + // the Initializer interface, and can thus cause confusion (one passes an + // optional name the other passes root path). This could easily cause + // confusion and thus we may want to rename Initalizer to the more specific + // task it performs: ContextTemplateWriter or similar. + c.progressListener.Increment("Initializing new Function project") + err = c.Initialize(cfg) + if err != nil { + return + } + + // Load the now-initialized Function. + f, err := NewFunction(cfg.Root) + if err != nil { + return + } + + // Build the now-initialized Function + c.progressListener.Increment("Building container image") + if err = c.Build(f.Root); err != nil { + return + } + + // Deploy the initialized Function, returning its publicly + // addressible name for possible registration. + c.progressListener.Increment("Deploying Function to cluster") + if err = c.Deploy(f.Root); err != nil { + return + } + + // Create an external route to the Function + c.progressListener.Increment("Creating route to Function") + if err = c.Route(f.Root); err != nil { + return + } + + c.progressListener.Complete("Create complete") + + // TODO: use the knative client during deployment such that the actual final + // route can be returned from the deployment step, passed to the DNS Router + // for routing actual traffic, and returned here. + if c.verbose { + fmt.Printf("https://%v/\n", f.Name) + } + return +} + // Initialize creates a new Function project locally using the settings // provided on a Function object. func (c *Client) Initialize(cfg Function) (err error) { @@ -275,17 +313,8 @@ func (c *Client) Initialize(cfg Function) (err error) { return } - // Do not initialize if already initialized. - if f.Initialized() { - err = fmt.Errorf("Function at '%v' already initialized.", cfg.Root) - return - } - - // Do not re-initialize unless the directory is empty This is to protect the - // user from inadvertently overwriting local files which share the same name - // as those in the applied template if, for instance, an incorrect path is - // supplied for the new Function. This assertion is that the target path is - // empty of all but unrelated hidden files. + // Assert the specified root is free of visible files and contentious + // hidden files (the ConfigFile, which indicates it is already initialized) if err = assertEmptyRoot(f.Root); err != nil { return } @@ -304,8 +333,6 @@ func (c *Client) Initialize(cfg Function) (err error) { f.Runtime = cfg.Runtime if f.Runtime == "" { f.Runtime = DefaultRuntime - } else { - f.Runtime = cfg.Runtime } // Assert trigger was provided, or default. @@ -404,61 +431,6 @@ func (c *Client) Route(path string) (err error) { return c.dnsProvider.Provide(f) } -// Create a Function of the given runtime. -// Name and Root are optional: -// Name is derived from root if possible. -// Root is defaulted to the current working directory. -func (c *Client) Create(cfg Function) (err error) { - c.progressListener.SetTotal(4) - defer c.progressListener.Done() - - // Initialize, writing out a template implementation and a config file. - // TODO: the Function's Initialize parameters are slightly different than - // the Initializer interface, and can thus cause confusion (one passes an - // optional name the other passes root path). This could easily cause - // confusion and thus we may want to rename Initalizer to the more specific - // task it performs: ContextTemplateWriter or similar. - c.progressListener.Increment("Initializing new Function project") - err = c.Initialize(cfg) - if err != nil { - return - } - - // Load the now-initialized Function. - f, err := NewFunction(cfg.Root) - if err != nil { - return - } - - // Build the now-initialized Function - c.progressListener.Increment("Building container image") - err = c.Build(f.Root) - if err != nil { - return - } - - // Deploy the initialized Function, returning its publicly - // addressible name for possible registration. - c.progressListener.Increment("Deploying Function to cluster") - if err = c.Deploy(f.Root); err != nil { - return - } - - // Create an external route to the Function - c.progressListener.Increment("Creating route to Function") - if err = c.Route(f.Root); err != nil { - return - } - - c.progressListener.Complete("Create complete") - - // TODO: use the knative client during deployment such that the actual final - // route can be returned from the deployment step, passed to the DNS Router - // for routing actual traffic, and returned here. - fmt.Printf("https://%v/\n", f.Name) - return -} - // Update a previously created Function. func (c *Client) Update(root string) (err error) { @@ -539,20 +511,20 @@ func (c *Client) Describe(name, root string) (fd FunctionDescription, err error) } // Remove a Function. Name takes precidence. If no name is provided, -// the Function defined at root is used. -func (c *Client) Remove(name, root string) error { +// the Function defined at root is used if it exists. +func (c *Client) Remove(cfg Function) error { // If name is provided, it takes precidence. // Otherwise load the Function deined at root. - if name != "" { - return c.remover.Remove(name) + if cfg.Name != "" { + return c.remover.Remove(cfg.Name) } - f, err := NewFunction(root) + f, err := NewFunction(cfg.Root) if err != nil { return err } if !f.Initialized() { - return fmt.Errorf("%v is not initialized", f.Name) + return fmt.Errorf("Function at %v can not be removed unless initialized. Try removing by name.", f.Root) } return c.remover.Remove(f.Name) } @@ -567,59 +539,35 @@ func (c *Client) Remove(name, root string) error { type noopBuilder struct{ output io.Writer } -func (n *noopBuilder) Build(_ Function) error { - fmt.Fprintln(n.output, "skipping build: client not initialized WithBuilder") - return nil -} +func (n *noopBuilder) Build(_ Function) error { return nil } type noopPusher struct{ output io.Writer } -func (n *noopPusher) Push(_ Function) error { - fmt.Fprintln(n.output, "skipping push: client not initialized WithPusher") - return nil -} +func (n *noopPusher) Push(_ Function) error { return nil } type noopDeployer struct{ output io.Writer } -func (n *noopDeployer) Deploy(_ Function) error { - fmt.Fprintln(n.output, "skipping deploy: client not initialized WithDeployer") - return nil -} +func (n *noopDeployer) Deploy(_ Function) error { return nil } type noopUpdater struct{ output io.Writer } -func (n *noopUpdater) Update(_ Function) error { - fmt.Fprintln(n.output, "skipping deploy: client not initialized WithDeployer") - return nil -} +func (n *noopUpdater) Update(_ Function) error { return nil } type noopRunner struct{ output io.Writer } -func (n *noopRunner) Run(_ Function) error { - fmt.Fprintln(n.output, "skipping run: client not initialized WithRunner") - return nil -} +func (n *noopRunner) Run(_ Function) error { return nil } type noopRemover struct{ output io.Writer } -func (n *noopRemover) Remove(string) error { - fmt.Fprintln(n.output, "skipping remove: client not initialized WithRemover") - return nil -} +func (n *noopRemover) Remove(string) error { return nil } type noopLister struct{ output io.Writer } -func (n *noopLister) List() ([]string, error) { - fmt.Fprintln(n.output, "skipping list: client not initialized WithLister") - return []string{}, nil -} +func (n *noopLister) List() ([]string, error) { return []string{}, nil } type noopDNSProvider struct{ output io.Writer } -func (n *noopDNSProvider) Provide(_ Function) error { - // Note: at this time manual DNS provisioning required for name -> knative serving netowrk load-balancer - return nil -} +func (n *noopDNSProvider) Provide(_ Function) error { return nil } type noopProgressListener struct{} diff --git a/client_test.go b/client_test.go index 4d449351..1196e017 100644 --- a/client_test.go +++ b/client_test.go @@ -2,6 +2,7 @@ package faas_test import ( "fmt" + "io/ioutil" "os" "path/filepath" "testing" @@ -10,48 +11,161 @@ import ( "github.com/boson-project/faas/mock" ) -// TestNew ensures that instantiation succeeds or fails as expected. -func TestNew(t *testing.T) { - // New client with all defaults - _, err := faas.New() - if err != nil { - t.Fatal(err) - } +// TestRepository for calculating destination image during tests. +// Will be optional once we support in-cluster container registries +// by default. See TestRepositoryRequired for details. +const TestRepository = "quay.io/alice" - // New client with optional verbosity enabled - _, err = faas.New(faas.WithVerbose(true)) - if err != nil { - t.Fatal(err) - } -} - -// TestNewWithInterferingFiles asserts that attempting to create a new client rooted -// to a directory with any visible files or any known contentious files (configs) fails. -func TestNewWithInterferingFiles(t *testing.T) { - // TODO -} - -// TestCreate ensures that creation of a supported runtime succeeds with all -// defaults (base case). +// TestCreate completes without error using all defaults and zero values. The base case. func TestCreate(t *testing.T) { - // Client with all defaults other than an initializer that verifies the - // specified runtime. - client, err := faas.New( - faas.WithInitializer(mock.NewInitializer())) - if err != nil { - t.Fatal(err) - } + root := "testdata/example.com/testCreate" // Root from which to run the test - // Create the test Function root - root := "testdata/example.com/admin" - err = os.MkdirAll(root, 0700) - if err != nil { - panic(err) + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) } defer os.RemoveAll(root) - // A supported langauge should not error - if err := client.Create("go", "", "", "", root); err != nil { + client := faas.New(faas.WithRepository(TestRepository)) + + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } +} + +// TestCreateWritesTemplate to disk at root. +func TestCreateWritesTemplate(t *testing.T) { + // Create the root path for the function + root := "testdata/example.com/testCreateWrites" + if err := os.MkdirAll(root, 0744); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create the function at root + client := faas.New(faas.WithRepository(TestRepository)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + // Test that the config file was written + if _, err := os.Stat(filepath.Join(root, faas.ConfigFile)); os.IsNotExist(err) { + t.Fatalf("Initialize did not result in '%v' being written to '%v'", faas.ConfigFile, root) + } +} + +// TestCreateInitializedAborts ensures that a directory which contains an initialized +// function does not reinitialize +func TestCreateInitializedAborts(t *testing.T) { + root := "testdata/example.com/testCreateInitializedAborts" // contains only a .faas.config + client := faas.New() + if err := client.Initialize(faas.Function{Root: root}); err == nil { + t.Fatal("error expected initilizing a path already containing an initialized Funciton") + } +} + +// TestCreateNonemptyDirectoryAborts ensures that a directory which contains any visible +// files aborts. +func TestCreateNonemptyDirectoryAborts(t *testing.T) { + root := "testdata/example.com/testCreateNonemptyDirectoryAborts" // contains only a single visible file. + client := faas.New() + if err := client.Initialize(faas.Function{Root: root}); err == nil { + t.Fatal("error expected initilizing a Function in a nonempty directory") + } +} + +// TestCreateHiddenFilesIgnored ensures that initializing in a directory that +// only contains hidden files does not error, protecting against the naieve +// implementation of aborting initialization if any files exist, which would +// break functions tracked in source control (.git), or when used in +// conjunction with other tools (.envrc, etc) +func TestCreateHiddenFilesIgnored(t *testing.T) { + // Create a directory for the Function + root := "testdata/example.com/testCreateHiddenFilesIgnored" + if err := os.MkdirAll(root, 0744); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // 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 := faas.New() + var err error + if err = client.Initialize(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } +} + +// TestCreateDefaultRuntime ensures that the default runtime is applied to new +// Functions and persisted. +func TestCreateDefaultRuntime(t *testing.T) { + // Create a root for the new Function + root := "testdata/example.com/testCreateDefaultRuntime" + if err := os.MkdirAll(root, 0744); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create a new function at root with all defaults. + client := faas.New(faas.WithRepository(TestRepository)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + // Load the function + f, err := faas.NewFunction(root) + if err != nil { + t.Fatal(err) + } + + // Ensure it has defaulted runtime + if f.Runtime != faas.DefaultRuntime { + t.Fatal("The default runtime was not applied or persisted.") + } +} + +// TestCreateDefaultTemplate ensures that the default template is +// applied when not provided. +func TestCreateDefaultTrigger(t *testing.T) { + // TODO: need to either expose accessor for introspection, or compare + // the files written to those in the embedded repisotory? +} + +// TestExtensibleTemplates templates. Ensures that templates are extensible +// using a custom path to a template repository on disk. Custom repository +// 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 +// $FAAS_TEMPLATES, a Go template named 'json' which is provided in the +// repository repository 'boson-experimental', would be expected to be in the +// location: +// $FAAS_TEMPLATES/boson-experimental/go/json +// See the CLI for full details, but a standard default location is +// $HOME/.config/templates/boson-experimental/go/json +func TestExtensibleTemplates(t *testing.T) { + // Create a directory for the new Function + root := "testdata/example.com/testExtensibleTemplates" + if err := os.MkdirAll(root, 0744); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create a new client with a path to the extensible templates + client := faas.New( + faas.WithTemplates("testdata/templates"), + faas.WithRepository(TestRepository)) + + // Create a Function specifying a template, 'json' that only exists in the extensible set + if err := client.Create(faas.Function{Root: root, Trigger: "boson-experimental/json"}); err != nil { + t.Fatal(err) + } + + // Ensure that a file from that only exists in that template set was actually written 'json.go' + if _, err := os.Stat(filepath.Join(root, "json.go")); os.IsNotExist(err) { + t.Fatalf("Initializing a custom did not result in json.go being written to '%v'", root) + } else if err != nil { t.Fatal(err) } } @@ -59,154 +173,272 @@ func TestCreate(t *testing.T) { // TestCreateUnderivableName ensures that attempting to create a new Function // when the name is underivable (and no explicit name is provided) generates // an error. -func TestCreateUnderivableName(t *testing.T) { - - // Create the test Function root - root := "testdata/example.com/admin" - err := os.MkdirAll(root, 0700) - if err != nil { - panic(err) +func TestUnderivableName(t *testing.T) { + // Create a directory for the Function + root := "testdata/example.com/testUnderivableName" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) } defer os.RemoveAll(root) // Instantiation without an explicit service name, but no derivable service // name (because of limiting path recursion) should fail. - client, err := faas.New( - faas.WithDomainSearchLimit(0)) // limit ability to derive from path. - if err != nil { - t.Fatal(err) - } + client := faas.New(faas.WithDomainSearchLimit(0)) // create a Function with a missing name, but when the name is // underivable (in this case due to limited recursion, but would equally // apply if run from /tmp or similar) - if err := client.Create("go", "", "", "", ""); err == nil { - t.Fatal("did not receive expected error") - } - -} - -// TestCreateMissingRuntime ensures that instantiation fails if the required -// runtime parameter is not passed to Create. -func TestCreateMissingRuntime(t *testing.T) { - client, err := faas.New( - faas.WithInitializer(mock.NewInitializer())) - if err != nil { - t.Fatal(err) - } - - // create a Function call missing runtime should error - if err := client.Create("", "", "", "", ""); err == nil { - t.Fatal("missing runtime did not generate error") + if err := client.Create(faas.Function{Root: root}); err == nil { + t.Fatal("did not receive error creating with underivable name") } } -// TestCreateUnsupportedRuntime ensures that instantiation fails if the required -// runtime parameter is of an unsupported runtime. -func TestCreateUnsupportedRuntime(t *testing.T) { - client, err := faas.New( - faas.WithInitializer(mock.NewInitializer())) // validtes runtime passed - if err != nil { +// TestUnsupportedRuntime generates an error. +func TestUnsupportedRuntime(t *testing.T) { + // Create a directory for the Function + root := "testdata/example.com/testUnsupportedRuntime" + if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } + defer os.RemoveAll(root) + + client := faas.New() // create a Function call witn an unsupported runtime should bubble // the error generated by the underlying initializer. - if err := client.Create("cobol", "", "", "", ""); err == nil { + var err error + if err = client.Create(faas.Function{Root: root, Runtime: "invalid"}); err == nil { t.Fatal("unsupported runtime did not generate error") } } -// TestCreateDelegeates ensures that a call to Create invokes the Function -// Initializer, Builder, Pusher and Deployer with expected parameters. -func TestCreateDelegates(t *testing.T) { - var ( - root = "testdata/example.com/admin" // .. in which to initialize - name = "admin.example.com" // expected to be derived - image = "my.hub/user/imagestamp" // expected image - route = "https://admin.example.com/" // expected final route - initializer = mock.NewInitializer() - builder = mock.NewBuilder() - pusher = mock.NewPusher() - deployer = mock.NewDeployer() - ) - - // Create the test Function root - err := os.MkdirAll(root, 0700) - if err != nil { - panic(err) +// TestDeriveDomain ensures that the name of the service is a domain derived +// from the current path if possible. +// see unit tests on the pathToDomain for more detailed logic. +func TestDeriveName(t *testing.T) { + // Create the root Function directory + root := "testdata/example.com/testDeriveDomain" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) } defer os.RemoveAll(root) - client, err := faas.New( - faas.WithInitializer(initializer), // will receive the final value - faas.WithBuilder(builder), // builds an image - faas.WithPusher(pusher), // pushes images to a registry - faas.WithDeployer(deployer), // deploys images as a running service - ) + client := faas.New(faas.WithRepository(TestRepository)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + f, err := faas.NewFunction(root) if err != nil { t.Fatal(err) } + if f.Name != "testDeriveDomain.example.com" { + t.Fatalf("unexpected function name '%v'", f.Name) + } +} + +// TestDeriveSubdomans ensures that a subdirectory structure is interpreted as +// multilevel subdomains when calculating a derived name for a service. +func TestDeriveSubdomains(t *testing.T) { + // Create the test Function root + root := "testdata/example.com/region1/testDeriveSubdomains" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + client := faas.New(faas.WithRepository(TestRepository)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + f, err := faas.NewFunction(root) + if err != nil { + t.Fatal(err) + } + + if f.Name != "testDeriveSubdomains.region1.example.com" { + t.Fatalf("unexpected function name '%v'", f.Name) + } +} + +// TestNamed ensures that an explicitly passed name is used in leau of the +// path derived name when provided, and persists through instantiations. +func TestNamed(t *testing.T) { + // Explicit name to use + name := "service.example.com" + + // Path which would derive to testWithHame.example.com were it not for the + // explicitly provided name. + root := "testdata/example.com/testWithName" + + // Create a root directory for the Function + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + client := faas.New(faas.WithRepository(TestRepository)) + + if err := client.Create(faas.Function{Root: root, Name: name}); err != nil { + t.Fatal(err) + } + + f, err := faas.NewFunction(root) + if err != nil { + t.Fatal(err) + } + + if f.Name != name { + t.Fatalf("expected name '%v' got '%v", name, f.Name) + } +} + +// TestRepository ensures that a repository is required, and is +// prepended with the DefaultRegistry if a single token. +// Repository is the namespace at the container image registry. +// If not prepended with the registry, it will be defaulted: +// Examples: "docker.io/alice" +// "quay.io/bob" +// "charlie" (becomes [DefaultRegistry]/charlie +// At this time a repository namespace is required as we rely on a third-party +// registry in all cases. When we support in-cluster container registries, +// this configuration parameter will become optional. +func TestRepositoryRequired(t *testing.T) { + // Create a root for the Function + root := "testdata/example.com/testRepository" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + client := faas.New() + if err := client.Create(faas.Function{Root: root}); err == nil { + t.Fatal("did not receive expected error creating a Function without specifying Registry") + } + +} + +// TestDeriveImage ensures that the full image (tag) of the resultant OCI +// container is populated based of a derivation using configured repository +// plus the service name. +func TestDeriveImage(t *testing.T) { + // Create the root Function directory + root := "testdata/example.com/testDeriveImage" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create the function which calculates fields such as name and image. + client := faas.New(faas.WithRepository(TestRepository)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + // Load the function with the now-populated fields. + f, err := faas.NewFunction(root) + if err != nil { + t.Fatal(err) + } + + // In form: [Default Registry]/[Repository Namespace]/[Service Name]:latest + expected := TestRepository + "/" + f.Name + ":latest" + if f.Image != expected { + t.Fatalf("expected image '%v' got '%v'", expected, f.Image) + } +} + +// TestDeriveImageDefaultRegistry ensures that a Repository which does not have +// a registry prefix has the DefaultRegistry prepended. +// For example "alice" becomes "docker.io/alice" +func TestDeriveImageDefaultRegistry(t *testing.T) { + // Create the root Function directory + root := "testdata/example.com/testDeriveImageDefaultRegistry" + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create the function which calculates fields such as name and image. + // Rather than use TestRepository, use a single-token name and expect + // the DefaultRegistry to be prepended. + client := faas.New(faas.WithRepository("alice")) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + // Load the function with the now-populated fields. + f, err := faas.NewFunction(root) + if err != nil { + t.Fatal(err) + } + // Expected image is [DefaultRegistry]/[namespace]/[servicename]:latest + expected := faas.DefaultRegistry + "/alice/" + f.Name + ":latest" + if f.Image != expected { + t.Fatalf("expected image '%v' got '%v'", expected, f.Image) + } +} + +// TestDelegation ensures that Create invokes each of the individual +// subcomponents via delegation through Build, Push and +// Deploy (and confirms expected fields calculated). +func TestCreateDelegates(t *testing.T) { + var ( + root = "testdata/example.com/testCreateDelegates" // .. in which to initialize + expectedName = "testCreateDelegates.example.com" // expected to be derived + expectedImage = "quay.io/alice/testCreateDelegates.example.com:latest" + builder = mock.NewBuilder() + pusher = mock.NewPusher() + deployer = mock.NewDeployer() + ) + + // Create a directory for the new Function + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // Create a client with mocks for each of the subcomponents. + client := faas.New( + faas.WithRepository(TestRepository), + faas.WithBuilder(builder), // builds an image + faas.WithPusher(pusher), // pushes images to a registry + faas.WithDeployer(deployer), // deploys images as a running service + ) + // Register Function delegates on the mocks which validate assertions // ------------- - // The initializer should receive the name expected from the path, - // the passed runtime, and an absolute path to the funciton soruce. - initializer.InitializeFn = func(runtime, context, path string) error { - if runtime != "go" { - t.Fatalf("initializer expected runtime 'go', got '%v'", runtime) - } - if context != "" { - t.Fatalf("initializer expected empty context template name, got '%v'", name) - } - expectedPath, err := filepath.Abs("./testdata/example.com/admin") - if err != nil { - t.Fatal(err) - } - if path != expectedPath { - t.Fatalf("initializer expected path '%v', got '%v'", expectedPath, path) - } - return nil - } - // The builder should be invoked with a path to a Function project's source // An example image name is returned. - builder.BuildFn = func(name2 string) (string, error) { + builder.BuildFn = func(f faas.Function) error { expectedPath, err := filepath.Abs(root) - if expectedPath != name2 { - t.Fatalf("builder expected path %v, got '%v'", expectedPath, name2) - } if err != nil { t.Fatal(err) } - // The final image name will be determined by the builder implementation, - // but whatever it is (in this case fabricated); it should be returned - // and later provided to the pusher. - return image, nil - } - - // The pusher should be invoked with the image to push. - pusher.PushFn = func(image2 string) error { - if image2 != image { - t.Fatalf("pusher expected image '%v', got '%v'", image, image2) + if expectedPath != f.Root { + t.Fatalf("builder expected path %v, got '%v'", expectedPath, f.Root) } - // image of given name wouold be pushed to the configured registry. return nil } - // The deployer should be invoked with the service name and image, and return - // the final accessible address. - deployer.DeployFn = func(name2, image2 string) (address string, err error) { - if name2 != name { - t.Fatalf("deployer expected name '%v', got '%v'", name, name2) + pusher.PushFn = func(f faas.Function) error { + if f.Image != expectedImage { + t.Fatalf("pusher expected image '%v', got '%v'", expectedImage, f.Image) } - if image2 != image { - t.Fatalf("deployer expected image '%v', got '%v'", image, image2) + return nil + } + + deployer.DeployFn = func(f faas.Function) error { + if f.Name != expectedName { + t.Fatalf("deployer expected name '%v', got '%v'", expectedName, f.Name) } - // service of given name would be deployed using the given image and - // allocated route returned. - return route, nil + if f.Image != expectedImage { + t.Fatalf("deployer expected image '%v', got '%v'", expectedImage, f.Image) + } + return nil } // Invocation @@ -214,14 +446,11 @@ func TestCreateDelegates(t *testing.T) { // Invoke the creation, triggering the Function delegates, and // perform follow-up assertions that the Functions were indeed invoked. - if err := client.Create("go", "", name, image, root); err != nil { + if err := client.Create(faas.Function{Root: root}); err != nil { t.Fatal(err) } // Confirm that each delegate was invoked. - if !initializer.InitializeInvoked { - t.Fatal("initializer was not invoked") - } if !builder.BuildInvoked { t.Fatal("builder was not invoked") } @@ -233,146 +462,28 @@ func TestCreateDelegates(t *testing.T) { } } -// TestCreateLocal ensures that when set to local-only mode, Create only invokes -// the initializer and builder. -func TestCreateLocal(t *testing.T) { - var ( - root = "testdata/example.com/admin" - initializer = mock.NewInitializer() - builder = mock.NewBuilder() - pusher = mock.NewPusher() - deployer = mock.NewDeployer() - dnsProvider = mock.NewDNSProvider() - ) - - // Create the test Function root - err := os.MkdirAll(root, 0700) - if err != nil { - panic(err) - } - defer os.RemoveAll(root) - - // Create the test Function root - err = os.MkdirAll(root, 0700) - if err != nil { - panic(err) - } - defer os.RemoveAll(root) - - client, err := faas.New( - faas.WithInitializer(initializer), // will receive the final value - faas.WithBuilder(builder), // builds an image - faas.WithPusher(pusher), // pushes images to a registry - faas.WithDeployer(deployer), // deploys images as a running service - faas.WithDNSProvider(dnsProvider), // will receive the final value - faas.WithLocal(true), // set to local Function mode. - ) - if err != nil { - t.Fatal(err) - } - - // Create a new Function - if err := client.Create("go", "", "", "", root); err != nil { - t.Fatal(err) - } - // Ensure that none of the remote delegates were invoked - if pusher.PushInvoked { - t.Fatal("Push invoked in local mode.") - } - if deployer.DeployInvoked { - t.Fatal("Deploy invoked in local mode.") - } - if dnsProvider.ProvideInvoked { - t.Fatal("DNS provider invoked in local mode.") - } - -} - -// TestCreateInternal ensures that when set to internal mode, Creation invokes the deployer with the "no public route" option and subsequent updates also are flagged to not create the route. -func TestCreateInternal(t *testing.T) { - fmt.Printf("TODO: TestCreateInternal") -} - -// TestCreateDomain ensures that the effective domain is dervied from -// directory structure. See the unit tests for pathToDomain for details. -func TestCreateDomain(t *testing.T) { - // Create the test function root - root := "testdata/example.com/admin" - err := os.MkdirAll(root, 0700) - if err != nil { - panic(err) - } - defer os.RemoveAll(root) - - // the mock dns provider does nothing but receive the caluclated - // domain name via it's Provide(domain) method, which is the value - // being tested here. - dnsProvider := mock.NewDNSProvider() - - client, err := faas.New( - faas.WithDomainSearchLimit(1), // Limit recursion to one level - faas.WithDNSProvider(dnsProvider), // will receive the final value - ) - if err != nil { - t.Fatal(err) - } - - if err := client.Create("go", "", "", "", root); err != nil { - t.Fatal(err) - } - if !dnsProvider.ProvideInvoked { - t.Fatal("dns provider was not invoked") - } - if dnsProvider.NameRequested != "admin.example.com" { - t.Fatalf("expected 'example.com', got '%v'", dnsProvider.NameRequested) - } -} - -// TestCreateSubdomain ensures that a subdirectory is interpreted as a subdomain -// when calculating final domain. See the unit tests for pathToDomain for the -// details and edge cases of this caluclation. -func TestCreateSubdomain(t *testing.T) { - // Create the test Function root - root := "testdata/example.com/admin" - err := os.MkdirAll(root, 0700) - if err != nil { - panic(err) - } - defer os.RemoveAll(root) - - dnsProvider := mock.NewDNSProvider() - client, err := faas.New( - faas.WithDomainSearchLimit(2), - faas.WithDNSProvider(dnsProvider), - ) - if err != nil { - t.Fatal(err) - } - if err := client.Create("go", "", "", "", root); err != nil { - t.Fatal(err) - } - if !dnsProvider.ProvideInvoked { - t.Fatal("dns provider was not invoked") - } - if dnsProvider.NameRequested != "admin.example.com" { - t.Fatalf("expected 'admin.example.com', got '%v'", dnsProvider.NameRequested) - } -} - // TestRun ensures that the runner is invoked with the absolute path requested. func TestRun(t *testing.T) { - // a previously-initilized Function's root - root := "testdata/example.com/www" - - runner := mock.NewRunner() - client, err := faas.New( - faas.WithRunner(runner)) - if err != nil { + // Create the root Function directory + root := "testdata/example.com/testRun" + if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } + defer os.RemoveAll(root) + + // Create a client with the mock runner and the new test Function + runner := mock.NewRunner() + client := faas.New(faas.WithRepository(TestRepository), faas.WithRunner(runner)) + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + // Run the newly created function if err := client.Run(root); err != nil { t.Fatal(err) } + + // Assert the runner was invoked, and with the expected root. if !runner.RunInvoked { t.Fatal("run did not invoke the runner") } @@ -389,66 +500,64 @@ func TestRun(t *testing.T) { // process, erroring if run on a directory uncreated. func TestUpdate(t *testing.T) { var ( - root = "testdata/example.com/www" // .. expected to be initialized - name = "www.example.com" // expected to be derived - image = "my.hub/user/admin.exampe.com" // expected image - builder = mock.NewBuilder() - pusher = mock.NewPusher() - updater = mock.NewUpdater() + root = "testdata/example.com/testUpdate" + expectedName = "testUpdate.example.com" + expectedImage = "quay.io/alice/testUpdate.example.com:latest" + builder = mock.NewBuilder() + pusher = mock.NewPusher() + updater = mock.NewUpdater() ) - client, err := faas.New( - faas.WithBuilder(builder), // builds an image - faas.WithPusher(pusher), // pushes images to a registry - faas.WithUpdater(updater), // updates deployed image - ) - if err != nil { + // Create the root Function directory + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + // A client with mocks whose implementaton will validate input. + client := faas.New( + faas.WithRepository(TestRepository), + faas.WithBuilder(builder), + faas.WithPusher(pusher), + faas.WithUpdater(updater)) + + // create the new Function which will be updated + if err := client.Create(faas.Function{Root: root}); err != nil { t.Fatal(err) } - // Register Function delegates on the mocks which validate assertions - // ------------- - - // The builder should be invoked with a path to the Function source. - // An example image name is returned. - builder.BuildFn = func(expectedPath string) (string, error) { + // Builder whose implementation verifies the expected root + builder.BuildFn = func(f faas.Function) error { rootPath, err := filepath.Abs(root) if err != nil { t.Fatal(err) } - if expectedPath != rootPath { - t.Fatalf("builder expected path %v, got '%v'", expectedPath, rootPath) + if f.Root != rootPath { + t.Fatalf("builder expected path %v, got '%v'", rootPath, f.Root) } - // The final image name will be determined by the builder implementation, - // but whatever it is (in this case fabricated); it should be returned - // and later provided to the pusher. - return image, nil + return nil } - // The pusher should be invoked with the image to push. - pusher.PushFn = func(image2 string) error { - if image2 != image { - t.Fatalf("pusher expected image '%v', got '%v'", image, image2) + // Pusher whose implementaiton verifies the expected image + pusher.PushFn = func(f faas.Function) error { + if f.Image != expectedImage { + t.Fatalf("pusher expected image '%v', got '%v'", expectedImage, f.Image) } // image of given name wouold be pushed to the configured registry. return nil } - // The updater should be invoked with the service name and image. - // Actual logic of updating is an implementation detail. - updater.UpdateFn = func(name2, image2 string) error { - if name2 != name { - t.Fatalf("updater expected name '%v', got '%v'", name, name2) + // Update whose implementaiton verifed the expected name and image + updater.UpdateFn = func(f faas.Function) error { + if f.Name != expectedName { + t.Fatalf("updater expected name '%v', got '%v'", expectedName, f.Name) } - if image2 != image { - t.Fatalf("updater expected image '%v', got '%v'", image, image2) + if f.Image != expectedImage { + t.Fatalf("updater expected image '%v', got '%v'", expectedImage, f.Image) } return nil } - // Invocation - // ------------- - // Invoke the creation, triggering the Function delegates, and // perform follow-up assertions that the Functions were indeed invoked. if err := client.Update(root); err != nil { @@ -466,42 +575,96 @@ func TestUpdate(t *testing.T) { } } -// TestRemove ensures that the remover is invoked with the name provided, and that -func TestRemove(t *testing.T) { +// TestRemoveByPath ensures that the remover is invoked to remove +// the funciton with the name of the function at the provided root. +func TestRemoveByPath(t *testing.T) { var ( - name = "admin.example.com" - remover = mock.NewRemover() + root = "testdata/example.com/testRemoveByPath" + expectedName = "testRemoveByPath.example.com" + remover = mock.NewRemover() ) - client, err := faas.New(faas.WithRemover(remover)) - if err != nil { + if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } - remover.RemoveFn = func(name2 string) error { - if name2 != name { - t.Fatalf("remover expected name '%v' got '%v'", name, name2) + defer os.RemoveAll(root) + + client := faas.New( + faas.WithRepository(TestRepository), + faas.WithRemover(remover)) + + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + remover.RemoveFn = func(name string) error { + if name != expectedName { + t.Fatalf("Expected to remove '%v', got '%v'", expectedName, name) } return nil } - // Call with explicit name and no root. - if err := client.Remove(name, ""); err != nil { + if err := client.Remove(faas.Function{Root: root}); err != nil { t.Fatal(err) } - // Call with explicit name and root; name should take precidence. - if err := client.Remove(name, "testdata/example.com/www"); err != nil { + if !remover.RemoveInvoked { + t.Fatal("remover was not invoked") + } + +} + +// TestRemoveByName ensures that the remover is invoked to remove the function +// of the name provided, with precidence over a provided root path. +func TestRemoveByName(t *testing.T) { + var ( + root = "testdata/example.com/testRemoveByPath" + expectedName = "explicitName.example.com" + remover = mock.NewRemover() + ) + + if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } + defer os.RemoveAll(root) + + client := faas.New( + faas.WithRepository(TestRepository), + faas.WithRemover(remover)) + + if err := client.Create(faas.Function{Root: root}); err != nil { + t.Fatal(err) + } + + remover.RemoveFn = func(name string) error { + if name != expectedName { + t.Fatalf("Expected to remove '%v', got '%v'", expectedName, name) + } + return nil + } + + // Run remove with only a name + if err := client.Remove(faas.Function{Name: expectedName}); err != nil { + t.Fatal(err) + } + + // Run remove with a name and a root, which should be ignored in favor of the name. + if err := client.Remove(faas.Function{Name: expectedName, Root: root}); err != nil { + t.Fatal(err) + } + + if !remover.RemoveInvoked { + t.Fatal("remover was not invoked") + } } -// TestRemoveUninitializedFailes ensures that attempting to remove a Function +// TestRemoveUninitializedFails ensures that attempting to remove a Function // by path only (no name) fails unless the funciton has been initialized. I.e. -// the name will not be derived from path and the Function removed by thi -// derived name; that could be unexpected and destructive. +// the name will not be derived from path and the Function removed by this +// derived name; which could be unexpected and destructive. func TestRemoveUninitializedFails(t *testing.T) { var ( - root = "testdata/example.com/admin" + root = "testdata/example.com/testRemoveUninitializedFails" remover = mock.NewRemover() ) err := os.MkdirAll(root, 0700) @@ -510,136 +673,29 @@ func TestRemoveUninitializedFails(t *testing.T) { } defer os.RemoveAll(root) - // Create a remover delegate which fails if invoked. + // remover fails if invoked remover.RemoveFn = func(name string) error { return fmt.Errorf("remove invoked for unitialized Function %v", name) } // Instantiate the client with the failing remover. - client, err := faas.New(faas.WithRemover(remover)) - if err != nil { - t.Fatal(err) - } + client := faas.New( + faas.WithRepository(TestRepository), + faas.WithRemover(remover)) - // Attempt to remove by path, expecting an error. - if err := client.Remove("", root); err == nil { + // Attempt to remove by path (uninitialized), expecting an error. + if err := client.Remove(faas.Function{Root: root}); err == nil { t.Fatalf("did not received expeced error removing an uninitialized func") } } -// TestRemoveDefaultCurrent ensures that, if a name is not provided but a path is, -// the funciton defined by path will be removed. -// Note that the prior test RemoveUninitializedFails ensures that only -// initialized Functions are removed, which prevents the case of a Function -// being removed by path-derived name without having been initialized (an -// easily destrcutive and likely unwanted behavior) -func TestRemoveDefaultCurrent(t *testing.T) { - var ( - root = "testdata/example.com/www" // an initialized Function (has config) - remover = mock.NewRemover() - ) - - // remover delegate which ensures the correct name is received. - remover.RemoveFn = func(name2 string) error { - if name2 != "www.example.com" { - t.Fatalf("remover expected name 'www.example.com' got '%v'", name2) - } - return nil - } - - client, err := faas.New(faas.WithRemover(remover)) - if err != nil { - t.Fatal(err) - } - - // Without a name provided, but with a path, the Function will be loaded and - // its name used by default. Note that it will fail if uninitialized (no path - // derivation for removal.) - if err := client.Remove("", root); err != nil { - t.Fatal(err) - } -} - -// TestWithName ensures that an explicitly passed name is used in leau of the -// path derived name when provided, and persists through instantiations. -// This also ensures that an initialized Function's name persists if -// the path is changed after creation. -func TestWithName(t *testing.T) { - // Explicit name to use - name := "service.example.com" - - // Path which would derive to service.groupA.example.com were it not for the - // explicitly provided name. - path := "testdata/example.com/groupA/service" - err := os.MkdirAll(path, 0700) - if err != nil { - panic(err) - } - defer os.RemoveAll(path) - - initializer := mock.NewInitializer() - c, err := faas.New( - faas.WithInitializer(initializer)) - if err != nil { - t.Fatal(err) - } - - // Ensure that initializing receives the specified path. - initializer.InitializeFn = func(runtime, context, path2 string) error { - expectedPath, err := filepath.Abs(path) - if err != nil { - t.Fatal(err) - } - if path2 != expectedPath { - t.Fatalf("initializer expected path '%v', got '%v'", expectedPath, path2) - } - return nil - } - - // Create the service with the explict name at the non-matching path. - if err := c.Create("go", "", name, "", path); err != nil { - t.Fatal(err) - } - - // TODO: create a Function about the path and check the name is loaded. - - // Create a new client about the now initialized path and test that - // the explicitly-provided name is sent to the updater, proving that - // it was - updater := mock.NewUpdater() - c, err = faas.New( - faas.WithUpdater(updater)) - if err != nil { - t.Fatal(err) - } - - // Ensure that updating takes place using the previously initialized name - updater.UpdateFn = func(name2, image string) error { - if name2 != name { - t.Fatalf("updater expected name '%v', got '%v'", name, name2) - } - return nil - } - - // Invoke update - if err := c.Update(path); err != nil { - t.Fatal(err) - } - -} - -// TestList ensures that the client invokes the configured lister. +// TestList merely ensures that the client invokes the configured lister. func TestList(t *testing.T) { - var lister = mock.NewLister() + lister := mock.NewLister() - client, err := faas.New( - faas.WithLister(lister)) // lists deployed Functions. - if err != nil { - t.Fatal(err) - } + client := faas.New(faas.WithLister(lister)) // lists deployed Functions. - _, err = client.List() - if err != nil { + if _, err := client.List(); err != nil { t.Fatal(err) } @@ -653,22 +709,17 @@ func TestList(t *testing.T) { // can be run from anywhere, thus ensuring that the client itself makes // a distinction between Function-scoped methods and not. func TestListOutsideRoot(t *testing.T) { - var lister = mock.NewLister() + lister := mock.NewLister() // Instantiate in the current working directory, with no name, and explicitly // disallowing name path inferrence by limiting recursion. This emulates // running the client (and subsequently list) from some arbitrary location // without a derivable funciton context. - client, err := faas.New( + client := faas.New( faas.WithDomainSearchLimit(0), - faas.WithLister(lister), - ) - if err != nil { - t.Fatal(err) - } + faas.WithLister(lister)) - _, err = client.List() - if err != nil { + if _, err := client.List(); err != nil { t.Fatal(err) } diff --git a/client_unit_test.go b/client_unit_test.go deleted file mode 100644 index cbcee213..00000000 --- a/client_unit_test.go +++ /dev/null @@ -1 +0,0 @@ -package faas diff --git a/cmd/delete.go b/cmd/delete.go index 01961516..a87c40c7 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -31,11 +31,13 @@ func runDelete(cmd *cobra.Command, args []string) (err error) { remover := knative.NewRemover() remover.Verbose = config.Verbose + function := faas.Function{Root: config.Path, Name: config.Name} + client := faas.New( faas.WithVerbose(verbose), faas.WithRemover(remover)) - return client.Remove(config.Name, config.Path) + return client.Remove(function) } type deleteConfig struct { diff --git a/cmd/init.go b/cmd/init.go index 66ec45c7..ceba4cb0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -19,9 +19,7 @@ func init() { initCmd.Flags().StringP("trigger", "t", faas.DefaultTrigger, "Function trigger (ex: 'http','events') - $FAAS_TRIGGER") initCmd.Flags().BoolP("yes", "y", false, "When in interactive mode (attached to a TTY), skip prompts. - $FAAS_YES") - var err error - err = initCmd.RegisterFlagCompletionFunc("runtime", CompleteRuntimeList) - if err != nil { + if err := initCmd.RegisterFlagCompletionFunc("runtime", CompleteRuntimeList); err != nil { fmt.Println("Error while calling RegisterFlagCompletionFunc: ", err) } } diff --git a/cmd/list.go b/cmd/list.go index d3c4dbd8..a67e52c7 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -4,7 +4,6 @@ import ( "encoding/json" "encoding/xml" "fmt" - "io" "github.com/ory/viper" "github.com/spf13/cobra" @@ -99,6 +98,8 @@ func newListConfig() listConfig { } // DEPRECATED BELOW (?): +// TODO: regenerate completions, which may necessitate the below change: +/* var validFormats []string @@ -129,3 +130,4 @@ func fmtYAML(writer io.Writer, names []string) error { encoder := yaml.NewEncoder(writer) return encoder.Encode(names) } +*/ diff --git a/config.go b/config.go index 5b148efb..2d461089 100644 --- a/config.go +++ b/config.go @@ -8,12 +8,12 @@ import ( "gopkg.in/yaml.v2" ) -// ConfigFileName is the name of the config's serialized form. -const ConfigFileName = ".faas.config" +// ConfigFile is the name of the config's serialized form. +const ConfigFile = ".faas.yaml" // Config represents the serialized state of a Function's metadata. // See the Function struct for attribute documentation. -type Config struct { +type config struct { Name string `yaml:"name"` Namespace string `yaml:"namespace"` Runtime string `yaml:"runtime"` @@ -26,8 +26,8 @@ type Config struct { // errors accessing an extant config file, or the contents of the file do not // unmarshall. A missing file at a valid path does not error but returns the // empty value of Config. -func newConfig(root string) (c Config, err error) { - filename := filepath.Join(root, ConfigFileName) +func newConfig(root string) (c config, err error) { + filename := filepath.Join(root, ConfigFile) if _, err = os.Stat(filename); os.IsNotExist(err) { err = nil // do not consider a missing config file an error return // return the zero value of the config @@ -42,7 +42,7 @@ func newConfig(root string) (c Config, err error) { // fromConfig returns a Function populated from config. // Note that config does not include ancillary fields not serialized, such as Root. -func fromConfig(c Config) (f Function) { +func fromConfig(c config) (f Function) { return Function{ Name: c.Name, Namespace: c.Namespace, @@ -52,8 +52,8 @@ func fromConfig(c Config) (f Function) { } // toConfig serializes a Function to a config object. -func toConfig(f Function) Config { - return Config{ +func toConfig(f Function) config { + return config{ Name: f.Name, Namespace: f.Namespace, Runtime: f.Runtime, @@ -63,9 +63,9 @@ func toConfig(f Function) Config { // writeConfig for the given Function out to disk at root. func writeConfig(f Function) (err error) { - path := filepath.Join(f.Root, ConfigFileName) + path := filepath.Join(f.Root, ConfigFile) c := toConfig(f) - bb := []byte{} + var bb []byte if bb, err = yaml.Marshal(&c); err != nil { return } diff --git a/function.go b/function.go index 244541ca..7b4770f2 100644 --- a/function.go +++ b/function.go @@ -97,6 +97,13 @@ func (f Function) Initialized() bool { // Default if not provided is --repository (a required global setting) // followed by the provided (or derived) image name. func DerivedImage(root, repository string) (image string, err error) { + // Repository is currently required until such time as we support + // pushing to an implicitly-available in-cluster registry by default. + if repository == "" { + err = errors.New("Repository name is required.") + return + } + f, err := NewFunction(root) if err != nil { // an inability to load the funciton means it is not yet initialized @@ -127,6 +134,12 @@ func DerivedImage(root, repository string) (image string, err error) { } else { err = fmt.Errorf("repository should be either 'namespace' or 'registry/namespace'") } + + // Explicitly append :latest. We currently expect source control to drive + // versioning, rather than rely on Docker Hub tags with explicit version + // numbers, as is seen in many serverless solutions. This will be updated + // to branch name when we add source-driven canary/ bluegreen deployments. + image = image + ":latest" return } @@ -165,7 +178,7 @@ func assertEmptyRoot(path string) (err error) { // contentiousFiles are files which, if extant, preclude the creation of a // Function rooted in the given directory. var contentiousFiles = []string{ - ".faas.yaml", + ConfigFile, ".appsody-config.yaml", } @@ -182,14 +195,8 @@ func contentiousFilesIn(dir string) (contentious []string, err error) { return } -// effectivelyEmpty directories are those which have no visible files, -// and none of the explicitly enumerated contentious files. +// effectivelyEmpty directories are those which have no visible files func isEffectivelyEmpty(dir string) (bool, error) { - // Check for contentious files - if contentious, err := contentiousFilesIn(dir); len(contentious) > 0 { - return false, err - } - // Check for any non-hidden files files, err := ioutil.ReadDir(dir) if err != nil { diff --git a/function_unit_test.go b/function_unit_test.go index 56dca7f8..d53cba63 100644 --- a/function_unit_test.go +++ b/function_unit_test.go @@ -1,10 +1,6 @@ package faas import ( - "fmt" - "io/ioutil" - "os" - "path/filepath" "testing" ) @@ -77,66 +73,3 @@ func TestPathToDomain(t *testing.T) { } } } - -// TestApplyConfig ensures that -// - a directory with no config has nothing applied without error. -// - a directory with an invalid config errors. -// - a directory with a valid config has it applied. -func TestApplyConfig(t *testing.T) { - // Create a temporary directory - root := "./testdata/example.com/cfgtest" - cfgFile := filepath.Join(root, ConfigFileName) - err := os.MkdirAll(root, 0700) - if err != nil { - fmt.Println("Error on TestApplyConfig: ", err) - return - } - defer os.RemoveAll(root) - - c := Function{Name: "staticDefault"} - - // Assert config optional. - // Ensure that applying a directory with no config does not error. - if err := applyConfig(&c, root); err != nil { - t.Fatalf("unexpected error applying a nonexistent config: %v", err) - } - - // Assert an extant, but empty config file causes no errors, - // and leaves data intact on the client instance. - if err := ioutil.WriteFile(cfgFile, []byte(""), 0644); err != nil { - t.Fatal(err) - } - if err := applyConfig(&c, root); err != nil { - t.Fatalf("unexpected error applying an empty config: %v", err) - } - - // Assert an unparseable config file errors - if err := ioutil.WriteFile(cfgFile, []byte("=invalid="), 0644); err != nil { - t.Fatal(err) - } - if err := applyConfig(&c, root); err == nil { - t.Fatal("Did not receive expected error from invalid config.") - } - - // Assert a valid config with no value zeroes out the default - if err := ioutil.WriteFile(cfgFile, []byte("name:"), 0644); err != nil { - t.Fatal(err) - } - if err := applyConfig(&c, root); err != nil { - t.Fatal(err) - } - if c.Name != "" { - t.Fatalf("Expected name to be zeroed by config, but got '%v'", c.Name) - } - - // Assert a valid config with a value for name is applied. - if err := ioutil.WriteFile(cfgFile, []byte("name: www.example.com"), 0644); err != nil { - t.Fatal(err) - } - if err := applyConfig(&c, root); err != nil { - t.Fatal(err) - } - if c.Name != "www.example.com" { - t.Fatalf("Expected name 'www.example.com', got '%v'", c.Name) - } -} diff --git a/mock/builder.go b/mock/builder.go index 7b22615a..270994ea 100644 --- a/mock/builder.go +++ b/mock/builder.go @@ -1,17 +1,19 @@ package mock +import "github.com/boson-project/faas" + type Builder struct { BuildInvoked bool - BuildFn func(tag string) (image string, err error) + BuildFn func(faas.Function) error } func NewBuilder() *Builder { return &Builder{ - BuildFn: func(string) (string, error) { return "", nil }, + BuildFn: func(faas.Function) error { return nil }, } } -func (i *Builder) Build(tag string) (string, error) { +func (i *Builder) Build(f faas.Function) error { i.BuildInvoked = true - return i.BuildFn(tag) + return i.BuildFn(f) } diff --git a/mock/deployer.go b/mock/deployer.go index 932dfb0e..dc12099b 100644 --- a/mock/deployer.go +++ b/mock/deployer.go @@ -1,17 +1,19 @@ package mock +import "github.com/boson-project/faas" + type Deployer struct { DeployInvoked bool - DeployFn func(name, image string) (address string, err error) + DeployFn func(faas.Function) error } func NewDeployer() *Deployer { return &Deployer{ - DeployFn: func(string, string) (string, error) { return "", nil }, + DeployFn: func(faas.Function) error { return nil }, } } -func (i *Deployer) Deploy(name, image string) (address string, err error) { +func (i *Deployer) Deploy(f faas.Function) error { i.DeployInvoked = true - return i.DeployFn(name, image) + return i.DeployFn(f) } diff --git a/mock/pusher.go b/mock/pusher.go index d64be586..b55c727d 100644 --- a/mock/pusher.go +++ b/mock/pusher.go @@ -1,17 +1,19 @@ package mock +import "github.com/boson-project/faas" + type Pusher struct { PushInvoked bool - PushFn func(tag string) error + PushFn func(faas.Function) error } func NewPusher() *Pusher { return &Pusher{ - PushFn: func(tag string) error { return nil }, + PushFn: func(faas.Function) error { return nil }, } } -func (i *Pusher) Push(tag string) error { +func (i *Pusher) Push(f faas.Function) error { i.PushInvoked = true - return i.PushFn(tag) + return i.PushFn(f) } diff --git a/mock/runner.go b/mock/runner.go index e0ed678e..34f592fe 100644 --- a/mock/runner.go +++ b/mock/runner.go @@ -1,5 +1,7 @@ package mock +import "github.com/boson-project/faas" + type Runner struct { RunInvoked bool RootRequested string @@ -9,8 +11,8 @@ func NewRunner() *Runner { return &Runner{} } -func (r *Runner) Run(root string) error { +func (r *Runner) Run(f faas.Function) error { r.RunInvoked = true - r.RootRequested = root + r.RootRequested = f.Root return nil } diff --git a/mock/updater.go b/mock/updater.go index 053f996f..13fff773 100644 --- a/mock/updater.go +++ b/mock/updater.go @@ -1,17 +1,19 @@ package mock +import "github.com/boson-project/faas" + type Updater struct { UpdateInvoked bool - UpdateFn func(name, image string) error + UpdateFn func(faas.Function) error } func NewUpdater() *Updater { return &Updater{ - UpdateFn: func(string, string) error { return nil }, + UpdateFn: func(faas.Function) error { return nil }, } } -func (i *Updater) Update(name, image string) error { +func (i *Updater) Update(f faas.Function) error { i.UpdateInvoked = true - return i.UpdateFn(name, image) + return i.UpdateFn(f) } diff --git a/templates.go b/templates.go index d8a6fbcc..dd3b094e 100644 --- a/templates.go +++ b/templates.go @@ -20,8 +20,8 @@ import ( // an HTTP Handler ("http") and Cloud Events ("events") const DefaultTemplate = "http" -// FileAccessor encapsulates methods for accessing template files. -type FileAccessor interface { +// fileAccessor encapsulates methods for accessing template files. +type fileAccessor interface { Stat(name string) (os.FileInfo, error) Open(p string) (file, error) } @@ -35,7 +35,7 @@ type file interface { // When pkger is run, code analysis detects this Include statement, // triggering the serializaation of the templates directory and all // its contents into pkged.go, which is then made available via -// a pkger FileAccessor. +// a pkger fileAccessor. // Path is relative to the go module root. func init() { _ = pkger.Include("/templates") @@ -112,7 +112,7 @@ func (a filesystemAccessor) Open(path string) (file, error) { return os.Open(path) } -func copy(src, dest string, accessor FileAccessor) (err error) { +func copy(src, dest string, accessor fileAccessor) (err error) { node, err := accessor.Stat(src) if err != nil { return @@ -124,7 +124,7 @@ func copy(src, dest string, accessor FileAccessor) (err error) { } } -func copyNode(src, dest string, accessor FileAccessor) (err error) { +func copyNode(src, dest string, accessor fileAccessor) (err error) { node, err := accessor.Stat(src) if err != nil { return @@ -135,7 +135,7 @@ func copyNode(src, dest string, accessor FileAccessor) (err error) { return } - children, err := ReadDir(src, accessor) + children, err := readDir(src, accessor) if err != nil { return } @@ -147,7 +147,7 @@ func copyNode(src, dest string, accessor FileAccessor) (err error) { return } -func ReadDir(src string, accessor FileAccessor) ([]os.FileInfo, error) { +func readDir(src string, accessor fileAccessor) ([]os.FileInfo, error) { f, err := accessor.Open(src) if err != nil { return nil, err @@ -161,7 +161,7 @@ func ReadDir(src string, accessor FileAccessor) ([]os.FileInfo, error) { return list, nil } -func copyLeaf(src, dest string, accessor FileAccessor) (err error) { +func copyLeaf(src, dest string, accessor fileAccessor) (err error) { srcFile, err := accessor.Open(src) if err != nil { return diff --git a/templates_test.go b/templates_test.go index dbec6003..922a4092 100644 --- a/templates_test.go +++ b/templates_test.go @@ -6,108 +6,19 @@ import ( "testing" ) -// TestInitialize ensures that on initialization of a the reference runtime -// (Go), the template is written. -func TestInitialize(t *testing.T) { - var ( - path = "testdata/example.org/www" - testFile = "handle.go" - template = "http" - ) - err := os.MkdirAll(path, 0744) - if err != nil { - panic(err) - } - defer os.RemoveAll(path) - - err = NewInitializer("").Initialize("go", template, path) - if err != nil { - t.Fatal(err) - } - - // Test that the directory is not empty - if _, err := os.Stat(filepath.Join(path, testFile)); os.IsNotExist(err) { - t.Fatalf("Initialize did not result in '%v' being written to '%v'", testFile, path) - } -} - -// TestDefaultTemplate ensures that if no template is provided, files are still written. -func TestDefaultTemplate(t *testing.T) { - var ( - path = "testdata/example.org/www" - testFile = "handle.go" - template = "" - ) - err := os.MkdirAll(path, 0744) - if err != nil { - panic(err) - } - defer os.RemoveAll(path) - - err = NewInitializer("").Initialize("go", template, path) - if err != nil { - t.Fatal(err) - } - - if _, err := os.Stat(filepath.Join(path, testFile)); os.IsNotExist(err) { - t.Fatalf("Initializing without providing a template did not result in '%v' being written to '%v'", testFile, path) - } -} - -// TestCustom ensures that a custom repository can be used as a template. -// Custom repository 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 $FAAS_TEMPLATES, a Go template named 'json' -// which is provided in the repository repository 'boson-experimental', -// would be expected to be in the location: -// $FAAS_TEMPLATES/boson-experimental/go/json -// See the CLI for full details, but a standard default location is -// $HOME/.config/templates/boson-experimental/go/json -func TestCustom(t *testing.T) { - var ( - path = "testdata/example.org/www" - testFile = "handle.go" - template = "boson-experimental/json" - // repos = "testdata/templates" - ) - err := os.MkdirAll(path, 0744) - if err != nil { - panic(err) - } - defer os.RemoveAll(path) - - // Unrecognized runtime/template should error - err = NewInitializer("").Initialize("go", template, path) - if err == nil { - t.Fatal("An unrecognized runtime/template should generate an error") - } - - // Recognized external (non-embedded) path should succeed - err = NewInitializer("testdata/templates").Initialize("go", template, path) - if err != nil { - t.Fatal(err) - } - - // The template should have been written to the given path. - if _, err := os.Stat(filepath.Join(path, testFile)); os.IsNotExist(err) { - t.Fatalf("Initializing a custom did not result in the expected '%v' being written to '%v'", testFile, path) - } else if err != nil { - t.Fatal(err) - } -} - -// TestEmbeddedFileMode ensures that files from the embedded templates are +// TestTemplatesEmbeddedFileMode ensures that files from the embedded templates are // written with the same mode from whence they came -func TestEmbeddedFileMode(t *testing.T) { - var path = "testdata/example.org/www" +func TestTemplatesEmbeddedFileMode(t *testing.T) { + var path = "testdata/example.com/www" err := os.MkdirAll(path, 0744) if err != nil { panic(err) } defer os.RemoveAll(path) - // Initialize a quarkus app from the embedded templates. - if err := NewInitializer("").Initialize("quarkus", "events", path); err != nil { + client := New() + function := Function{Root: path, Runtime: "quarkus", Trigger: "events"} + if err := client.Initialize(function); err != nil { t.Fatal(err) } @@ -124,12 +35,13 @@ func TestEmbeddedFileMode(t *testing.T) { } } -// TestCustomFileMode ensures that files from a file-system derived repository -// of templates are written with the same mode from whence they came -func TestFileMode(t *testing.T) { +// TestTemplatesExtensibleFileMode ensures that files from a file-system +// derived template is written with mode retained. +func TestTemplatesExtensibleFileMode(t *testing.T) { var ( - path = "testdata/example.org/www" - template = "boson-experimental/http" + path = "testdata/example.com/www" + template = "boson-experimental/http" + templates = "testdata/templates" ) err := os.MkdirAll(path, 0744) if err != nil { @@ -137,8 +49,9 @@ func TestFileMode(t *testing.T) { } defer os.RemoveAll(path) - // Initialize a quarkus app from the custom repo in ./testdata - if err = NewInitializer("testdata/templates").Initialize("quarkus", template, path); err != nil { + client := New(WithTemplates(templates)) + function := Function{Root: path, Runtime: "quarkus", Trigger: template} + if err := client.Initialize(function); err != nil { t.Fatal(err) } diff --git a/testdata/README.md b/testdata/README.md index 455c8913..0e28668a 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -1,5 +1,4 @@ # testdata -Used by tests to hold files necessary for completing and as a place to create -service functions of varying configurations. +Contains test templates and directory targets for domain and subdomain-level tests. diff --git a/testdata/example.com/region1/README.md b/testdata/example.com/region1/README.md new file mode 100644 index 00000000..4f0eb245 --- /dev/null +++ b/testdata/example.com/region1/README.md @@ -0,0 +1,3 @@ +# region1 + +Used as a test target. diff --git a/testdata/example.com/www/.appsody-config.yaml b/testdata/example.com/www/.appsody-config.yaml deleted file mode 100644 index 302b762d..00000000 --- a/testdata/example.com/www/.appsody-config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -id: "20200507014534.69605855" -project-name: www-example-com -stack: quay.io/boson/go-ce-functions:0.0 diff --git a/testdata/example.com/www/.faas.yaml b/testdata/example.com/www/.faas.yaml deleted file mode 100644 index 8c9706e5..00000000 --- a/testdata/example.com/www/.faas.yaml +++ /dev/null @@ -1,2 +0,0 @@ -name: www.example.com -runtime: go diff --git a/testdata/example.com/www/go.mod b/testdata/example.com/www/go.mod deleted file mode 100644 index d7e301af..00000000 --- a/testdata/example.com/www/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module function - -go 1.13 - -require github.com/cloudevents/sdk-go v0.10.2 diff --git a/testdata/example.com/www/handle.go b/testdata/example.com/www/handle.go deleted file mode 100644 index 193fd6fd..00000000 --- a/testdata/example.com/www/handle.go +++ /dev/null @@ -1,32 +0,0 @@ -package function - -import ( - "context" - "fmt" - "os" - - cloudevents "github.com/cloudevents/sdk-go" -) - -// Handle a CloudEvent. -// Supported function signatures: -// func() -// func() error -// func(context.Context) -// func(context.Context) error -// func(cloudevents.Event) -// func(cloudevents.Event) error -// func(context.Context, cloudevents.Event) -// func(context.Context, cloudevents.Event) error -// func(cloudevents.Event, *cloudevents.EventResponse) -// func(cloudevents.Event, *cloudevents.EventResponse) error -// func(context.Context, cloudevents.Event, *cloudevents.EventResponse) -// func(context.Context, cloudevents.Event, *cloudevents.EventResponse) error -func Handle(ctx context.Context, event cloudevents.Event) error { - if err := event.Validate(); err != nil { - fmt.Fprintf(os.Stderr, "invalid event received. %v", err) - return err - } - fmt.Printf("%v\n", event) - return nil -} diff --git a/testdata/example.com/www/handle_test.go b/testdata/example.com/www/handle_test.go deleted file mode 100644 index e7664578..00000000 --- a/testdata/example.com/www/handle_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package function - -import ( - "context" - "fmt" - "net/url" - "testing" - - cloudevents "github.com/cloudevents/sdk-go" -) - -// TestHandle ensures that Handle accepts a valid CloudEvent without error. -func TestHandle(t *testing.T) { - // A minimal, but valid, event. - event := cloudevents.NewEvent() - event.SetID("TEST-EVENT-01") - event.SetType("com.example.cloudevents.test") - event.SetSource("http://localhost:8080/") - - // Invoke the defined handler. - if err := Handle(context.Background(), event); err != nil { - t.Fatal(err) - } -} - -// TestHandleInvalid ensures that an invalid input event generates an error. -func TestInvalidInput(t *testing.T) { - invalidEvent := cloudevents.NewEvent() // missing required fields - - // Attempt to handle the invalid event, ensuring that the handler validats events. - if err := Handle(context.Background(), invalidEvent); err == nil { - t.Fatalf("handler did not generate error on invalid event. Missing .Validate() check?") - } -} - -// TestE2E also tests the Handle function, but does so by creating an actual -// CloudEvents HTTP sending and receiving clients. This is a bit redundant -// with TestHandle, but illustrates how clients are configured and used. -func TestE2E(t *testing.T) { - var ( - receiver cloudevents.Client - address string // at which the receiver beings listening (os-chosen port) - sender cloudevents.Client // sends an event to the receiver via HTTP - handler = Handle // test the user-defined Handler - err error - ) - - if receiver, address, err = newReceiver(t); err != nil { - t.Fatal(err) - } - - if sender, err = newSender(t, address); err != nil { - t.Fatal(err) - } - - go func() { - if err := receiver.StartReceiver(context.Background(), handler); err != nil { - t.Fatal(err) - } - }() - - _, resp, err := sender.Send(context.Background(), newEvent(t, TestData{Sequence: 1, Message: "test message"})) - if err != nil { - t.Fatal(err) - } - - fmt.Printf("OK:\n%v\n", resp) -} - -type TestData struct { - Sequence int `json:"id"` - Message string `json:"message"` -} - -func newReceiver(t *testing.T) (c cloudevents.Client, address string, err error) { - t.Helper() - transport, err := cloudevents.NewHTTPTransport( - cloudevents.WithPort(0), // use an OS-chosen unused port. - cloudevents.WithPath("/")) - if err != nil { - return - } - address = fmt.Sprintf("http://127.0.0.1:%v/", transport.GetPort()) - c, err = cloudevents.NewClient(transport) - return -} - -func newSender(t *testing.T, address string) (c cloudevents.Client, err error) { - t.Helper() - transport, err := cloudevents.NewHTTPTransport( - cloudevents.WithTarget(address), - cloudevents.WithEncoding(cloudevents.HTTPStructuredV01)) - if err != nil { - return - } - return cloudevents.NewClient(transport, cloudevents.WithTimeNow()) -} - -func newEvent(t *testing.T, data TestData) (event cloudevents.Event) { - source, err := url.Parse("https://example.com/cloudfunction/cloudevent/cmd/runner") - if err != nil { - t.Fatal(err) - } - contentType := "application/json" - event = cloudevents.Event{ - Context: cloudevents.EventContextV01{ - EventID: "test-event-01", - EventType: "com.cloudevents.sample.sent", - Source: cloudevents.URLRef{URL: *source}, - ContentType: &contentType, - }.AsV01(), - Data: &data, - } - return -} diff --git a/testdata/templates/boson-experimental/go/json/json.go b/testdata/templates/boson-experimental/go/json/json.go new file mode 100644 index 00000000..e69de29b