updater: add kn-based implementation

This commit is contained in:
Luke K 2020-04-18 16:13:43 +00:00
parent 3656f532b6
commit fe12839e97
No known key found for this signature in database
GPG Key ID: 4896F75BAF2E1966
11 changed files with 343 additions and 117 deletions

View File

@ -2,44 +2,22 @@
Function as a Service CLI
## Requirements
## Setup and Configuration
Go 1.13+
## Install
Build and install the resultant binary.
With Go 1.13+ installed, build and install the binary to your path:
```
go install
```
## Build
Install dependent binaries:
Build binary into the local directory.
```shell
go build
```
## Usage
* `kn` https://github.com/knative/client/releases
* `kubectl` https://kubernetes.io/docs/tasks/tools/install-kubectl/
* `docker` https://docs.docker.com/get-docker/
See help:
```shell
faas
```
Configure Image repository:
## Configuration
### Cluster Prerequisites
see https://github.com/lkingland/config for cluster setup and configuration. Broadly, requirements are:
* Kubernetes
* Knative Serving and Eventing
* Knative Domains patched to enable domains
* Knative Network patched to enable subdomains
* Kourier
### Container Registry
Both the image registry and user/org namespace need to be defined either by
Both the image repository and user/org namespace need to be defined either by
using the --registry and --namespace flags on the `create` command, or by
configuring as environment variables. For example to configure all images
to be pushed to `quay.io/alice`, use:
@ -48,15 +26,33 @@ export FAAS_REGISTRY=quay.io
export FAAS_NAMESPACE=alice
```
Cluster connection:
It is expected that kubectl and kn be configured to connect to a kubernetes cluster with the following configuration:
* Knative Serving and Eventing
* Knative Domains patched to enable your chosen domain
* Knative Network patched to enable subdomains
* Kourier
* Cert-manager
see https://github.com/lkingland/config for cluster setup and configuration details.
## Usage
See help:
```shell
faas
```
## Examples
Create a new Function Service:
Create a new Service Function:
```shell
> mkdir -p example.com/www
> cd example.com/www
> faas create go
OK www.example.com
https://www.example.com
> curl https://www.example.com
OK
```

View File

@ -30,7 +30,7 @@ func NewInitializer() *Initializer {
return &Initializer{}
}
// Initialize a new funciton of the given name, of the given language, at the given path.
// Initialize a new function of the given name, of the given language, at the given path.
func (n *Initializer) Initialize(name, language, path string) error {
// Check for the appsody binary explicitly so that we can return
// an extra-friendly error message.

View File

@ -28,7 +28,7 @@ func TestInitialize(t *testing.T) {
t.Skip()
}
// Create the directory in which the function service will be initialized,
// Create the directory in which the service function will be initialized,
// removing it on test completion.
if err := os.Mkdir("testdata/example.com/www", 0700); err != nil {
t.Fatal(err)

View File

@ -14,7 +14,7 @@ func TestRun(t *testing.T) {
t.Skip()
}
// Testdata Function Service
// Testdata Service Function
//
// The directory has been pre-populated with a runnable base function by running
// init and committing the result, such that this test is not dependent on a

View File

@ -24,8 +24,9 @@ type Client struct {
builder Builder // Builds a runnable image from function source
pusher Pusher // Pushes a built image to a registry
deployer Deployer // Deploys a Service Function
updater Updater // Updates a deployed Service Function
runner Runner // Runs the function locally
remover Remover // Removes remote services.
remover Remover // Removes remote services
}
// DNSProvider exposes DNS services necessary for serving the Service Function.
@ -43,14 +44,14 @@ type Initializer interface {
// Builder of function source to runnable image.
type Builder interface {
// Build a function service of the given name with source located at path.
// Build a service function of the given name with source located at path.
// returns the image name built.
Build(name, path string) (image string, err error)
}
// Pusher of function image to a registry.
type Pusher interface {
// Push the image of the function service.
// Push the image of the service function.
Push(image string) error
}
@ -60,6 +61,12 @@ type Deployer interface {
Deploy(name, image string) (address string, err error)
}
// Updater of a deployed service function with new image.
type Updater interface {
// Deploy a service function of given name, using given backing image.
Update(name, image string) error
}
// Runner runs the function locally.
type Runner interface {
// Run the function locally.
@ -148,6 +155,13 @@ func WithDeployer(d Deployer) Option {
}
}
// WithUpdater provides the concrete implementation of an updater.
func WithUpdater(u Updater) Option {
return func(c *Client) {
c.updater = u
}
}
// WithRunner provides the concrete implementation of a deployer.
func WithRunner(r Runner) Option {
return func(c *Client) {
@ -168,27 +182,29 @@ func WithRemover(r Remover) Option {
func New(options ...Option) (c *Client, err error) {
// Client with defaults overridden by optional parameters
c = &Client{
dnsProvider: &noopDNSProvider{output: os.Stdout},
initializer: &noopInitializer{output: os.Stdout},
builder: &noopBuilder{output: os.Stdout},
pusher: &noopPusher{output: os.Stdout},
deployer: &noopDeployer{output: os.Stdout},
updater: &noopUpdater{output: os.Stdout},
runner: &noopRunner{output: os.Stdout},
remover: &noopRemover{output: os.Stdout},
domainSearchLimit: -1, // no recursion limit deriving domain by default.
dnsProvider: &manualDNSProvider{output: os.Stdout},
initializer: &manualInitializer{output: os.Stdout},
builder: &manualBuilder{output: os.Stdout},
pusher: &manualPusher{output: os.Stdout},
deployer: &manualDeployer{output: os.Stdout},
runner: &manualRunner{output: os.Stdout},
remover: &manualRemover{output: os.Stdout},
}
for _, o := range options {
o(c)
}
// Convert the specified root to an absolute path.
// If no root is provided, the root is the current working directory.
// Working Directory
// Convert the specified root to an absolute path. If no root is provided,
// the root is the current working directory.
c.root, err = filepath.Abs(c.root)
if err != nil {
return
}
// Derive name
// Service Name
// If not explicity set via the WithName option, we attempt to derive the
// name from the effective root path.
if c.name == "" {
@ -263,15 +279,60 @@ func (c *Client) Create(language string) (err error) {
return
}
// TODO
// Dervive the cluster address of the service.
// Derive the public domain of the service from the directory path.
// Ensure that the allocated final address is enabled with the
// configured DNS provider.
// NOTE:
// DNS and TLS are provisioned by Knative Serving + cert-manager,
// but DNS subdomain CNAME to the Kourier Load Balancer is
// still manual, and the initial cluster config to suppot the TLD
// is still manual.
c.dnsProvider.Provide(c.name, address)
// Associate the public domain to the cluster-defined address.
return
}
// Update a previously created service function.
func (c *Client) Update() (err error) {
// TODO: detect and error if `create` was never run, failed, or the
// service is othewise un-updatable.
// Build an image from the current state of the service function's codebase.
image, err := c.builder.Build(c.name, c.root)
if err != nil {
return
}
// Push the image for the named service to the configured registry
if err = c.pusher.Push(image); err != nil {
return
}
// Update the previously-deployed service function, returning its publicly
// addressible name for possible registration.
return c.updater.Update(c.name, image)
}
// Run the function whose code resides at root.
func (c *Client) Run() error {
// delegate to concrete implementation of runner entirely.
return c.runner.Run(c.root)
}
// Remove a function from remote, bringing the service funciton
// to the same state as if it had been created --local only.
// Name is optional, as the presently associated service function
// is inferred, but a client is allowed to remove any service
// function for which the user has permission to remove, as this
// is used for repairing broken local->remote associations.
func (c *Client) Remove(name string) error {
if name == "" {
name = c.name
}
// delegate to concrete implementation of remover entirely.
return c.remover.Remove(name)
}
// Convert a path to a domain.
// Searches up the path string until a domain (TLD+1) is detected.
// Subdirectories are considered subdomains.
@ -352,26 +413,6 @@ func pathToDomain(path string, maxLevels int) string {
return domain
}
// Run the function whose code resides at root.
func (c *Client) Run() error {
// delegate to concrete implementation of runner entirely.
return c.runner.Run(c.root)
}
// Remove a function from remote, bringing the service funciton
// to the same state as if it had been created --local only.
// Name is optional, as the presently associated service function
// is inferred, but a client is allowed to remove any service
// function for which the user has permission to remove, as this
// is used for repairing broken local->remote associations.
func (c *Client) Remove(name string) error {
if name == "" {
name = c.name
}
// delegate to concrete implementation of remover entirely.
return c.remover.Remove(name)
}
// Manual implementations (noops) of required interfaces.
// In practice, the user of this client package (for example the CLI) will
// provide a concrete implementation for all of the interfaces. For testing or
@ -380,68 +421,58 @@ func (c *Client) Remove(name string) error {
// serve to keep the core logic here separate from the imperitive.
// -----------------------------------------------------
type manualDNSProvider struct {
output io.Writer
type noopDNSProvider struct{ output io.Writer }
func (p *noopDNSProvider) Provide(name, address string) {
fmt.Fprintln(p.output, "skipping DNS update: client not initialized WithDNSProvider")
}
func (p *manualDNSProvider) Provide(name, address string) {
if address == "" {
address = "[manually configured address]"
}
fmt.Fprintf(p.output, "Please manually configure '%v' to route requests to '%v' \n", name, address)
}
type noopInitializer struct{ output io.Writer }
type manualInitializer struct {
output io.Writer
}
func (i *manualInitializer) Initialize(name, language, root string) error {
fmt.Fprintf(i.output, "Please create a base function for '%v' (language '%v') at path '%v'\n", name, language, root)
func (i *noopInitializer) Initialize(name, language, root string) error {
fmt.Fprintln(i.output, "skipping initialize: client not initialized WithInitializer")
return nil
}
type manualBuilder struct {
output io.Writer
}
type noopBuilder struct{ output io.Writer }
func (i *manualBuilder) Build(name, root string) (image string, err error) {
fmt.Fprintf(i.output, "Please manually build image for '%v' using code at '%v'\n", name, root)
func (i *noopBuilder) Build(name, root string) (image string, err error) {
fmt.Fprintln(i.output, "skipping build: client not initialized WithBuilder")
return "", nil
}
type manualPusher struct {
output io.Writer
}
type noopPusher struct{ output io.Writer }
func (i *manualPusher) Push(image string) error {
fmt.Fprintf(i.output, "Please manually push image '%v'\n", image)
func (i *noopPusher) Push(image string) error {
fmt.Fprintln(i.output, "skipping push: client not initialized WithPusher")
return nil
}
type manualDeployer struct {
output io.Writer
}
type noopDeployer struct{ output io.Writer }
func (i *manualDeployer) Deploy(name, image string) (string, error) {
fmt.Fprintf(i.output, "Please manually deploy '%v'\n", name)
func (i *noopDeployer) Deploy(name, image string) (string, error) {
fmt.Fprintln(i.output, "skipping deploy: client not initialized WithDeployer")
return "", nil
}
type manualRunner struct {
output io.Writer
}
type noopUpdater struct{ output io.Writer }
func (i *manualRunner) Run(root string) error {
fmt.Fprintf(i.output, "Please manually run using code at '%v'\n", root)
func (i *noopUpdater) Update(name, image string) error {
fmt.Fprintln(i.output, "skipping deploy: client not initialized WithDeployer")
return nil
}
type manualRemover struct {
output io.Writer
type noopRunner struct{ output io.Writer }
func (i *noopRunner) Run(root string) error {
fmt.Fprintln(i.output, "skipping run: client not initialized WithRunner")
return nil
}
func (i *manualRemover) Remove(name string) error {
fmt.Fprintf(i.output, "Please manually remove service '%v'\n", name)
type noopRemover struct{ output io.Writer }
func (i *noopRemover) Remove(name string) error {
fmt.Fprintln(i.output, "skipping remove: client not initialized WithRemover")
return nil
}

View File

@ -55,12 +55,12 @@ func TestCreate(t *testing.T) {
t.Fatal(err)
}
// create a Function Service call missing language should error
// create a Service Function call missing language should error
if err := client.Create(""); err == nil {
t.Fatal("missing language did not generate error")
}
// create a Function Service call witn an unsupported language should bubble
// create a Service Function call witn an unsupported language should bubble
// the error generated by the underlying initializer.
if err := client.Create("cobol"); err == nil {
t.Fatal("unsupported language did not generate error")
@ -134,7 +134,7 @@ func TestCreateDelegates(t *testing.T) {
t.Fatalf("builder expected path '%v', got '%v'", expectedPath, path)
}
// The final image name will be determined by the builder implementation,
// but whatever it is (in this case fabricarted); it should be returned
// but whatever it is (in this case fabricated); it should be returned
// and later provided to the pusher.
return image, nil
}
@ -307,6 +307,85 @@ func TestRun(t *testing.T) {
}
}
// TestUpdate ensures that the updater properly invokes the build/push/deploy
// process, erroring if run on a directory uncreated.
func TestUpdate(t *testing.T) {
var (
path = "testdata/example.com/admin" // .. expected to be initialized
name = "admin.example.com" // expected to be derived
image = "my.hub/user/admin.exampe.com" // expected image
builder = mock.NewBuilder()
pusher = mock.NewPusher()
updater = mock.NewUpdater()
)
client, err := client.New(
client.WithRoot(path), // set function root
client.WithBuilder(builder), // builds an image
client.WithPusher(pusher), // pushes images to a registry
client.WithUpdater(updater), // updates deployed image
)
if err != nil {
t.Fatal(err)
}
// Register function delegates on the mocks which validate assertions
// -------------
// The builder should be invoked with a service name and path to its source
// function code. For this test, it is a name derived from the test path.
// An example image name is returned.
builder.BuildFn = func(name2, path2 string) (string, error) {
if name != name {
t.Fatalf("builder expected name %v, got '%v'", name, name2)
}
// 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)
}
// 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("deployer expected name '%v', got '%v'", name, name2)
}
if image2 != image {
t.Fatalf("deployer expected image '%v', got '%v'", image, image2)
}
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(); err != nil {
t.Fatal(err)
}
if !builder.BuildInvoked {
t.Fatal("builder was not invoked")
}
if !pusher.PushInvoked {
t.Fatal("pusher was not invoked")
}
if !updater.UpdateInvoked {
t.Fatal("updater was not invoked")
}
}
// TestRemove ensures that the remover is invoked with the name of the
// client's associated service function.
func TestRemove(t *testing.T) {

17
client/mock/updater.go Normal file
View File

@ -0,0 +1,17 @@
package mock
type Updater struct {
UpdateInvoked bool
UpdateFn func(name, image string) error
}
func NewUpdater() *Updater {
return &Updater{
UpdateFn: func(string, string) error { return nil },
}
}
func (i *Updater) Update(name, image string) error {
i.UpdateInvoked = true
return i.UpdateFn(name, image)
}

View File

@ -17,7 +17,7 @@ func init() {
root.AddCommand(createCmd)
// register command flags and bind them to environment variables.
createCmd.Flags().BoolP("local", "l", false, "create the function service locally only.")
createCmd.Flags().BoolP("local", "l", false, "create the service function locally only.")
viper.BindPFlag("local", createCmd.Flags().Lookup("local"))
createCmd.Flags().StringP("name", "n", "", "optionally specify an explicit name for the serive, overriding path-derivation. $FAAS_NAME")

View File

@ -3,7 +3,13 @@ package cmd
import (
"errors"
"github.com/ory/viper"
"github.com/spf13/cobra"
"github.com/lkingland/faas/appsody"
"github.com/lkingland/faas/client"
"github.com/lkingland/faas/docker"
"github.com/lkingland/faas/kn"
)
func init() {
@ -18,6 +24,39 @@ var updateCmd = &cobra.Command{
RunE: update,
}
func update(cmd *cobra.Command, args []string) error {
return errors.New("Update not implemented.")
func update(cmd *cobra.Command, args []string) (err error) {
var (
verbose = viper.GetBool("verbose") // Verbose logging
registry = viper.GetString("registry") // Registry (ex: docker.io)
namespace = viper.GetString("namespace") // namespace at registry (user or org name)
)
// Namespace can not be defaulted.
if namespace == "" {
return errors.New("image registry namespace (--namespace or FAAS_NAMESPACE is required)")
}
// Builder creates images from function source.
builder := appsody.NewBuilder(registry, namespace)
builder.Verbose = verbose
// Pusher of images
pusher := docker.NewPusher()
pusher.Verbose = verbose
// Deployer of built images.
updater := kn.NewUpdater()
updater.Verbose = verbose
client, err := client.New(
client.WithVerbose(verbose),
client.WithBuilder(builder),
client.WithPusher(pusher),
client.WithUpdater(updater),
)
if err != nil {
return
}
return client.Update()
}

View File

@ -8,7 +8,7 @@ import (
// Version
// Printed on subcommand `version` or flag `--version`
const Version = "v0.0.8"
const Version = "v0.0.12"
func init() {
root.AddCommand(versionCmd)

64
kn/updater.go Normal file
View File

@ -0,0 +1,64 @@
package kn
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"time"
"github.com/lkingland/faas/k8s"
)
// Updater implemented using the kn binary.
type Updater struct {
// Verbose logging.
Verbose bool
}
// NewUpdater creates an instance of the kubectl-based deployer.
func NewUpdater() *Updater {
return &Updater{}
}
// Update the named service with the new image.
func (d *Updater) Update(name, image string) (err error) {
// assert kubectl
if _, err = exec.LookPath("kn"); err != nil {
return errors.New("please install 'kn'")
}
// Convert the project name proper (a valid domain) to how it is being
// represented in kubernetes: as a domain label (RFC1035)
// for use as the service's deployed name.
project, err := k8s.ToSubdomain(name)
if err != nil {
return
}
timestamp := fmt.Sprintf("BUILT=%v", time.Now().Format("20060102T150405"))
// TODO: use knative client directly.
// TODO: use tags and traffic splitting.
cmd := exec.Command("kn", "service", "update", project, "--env", timestamp)
// If verbose logging is enabled, echo appsody's chatty stdout.
if d.Verbose {
fmt.Println(cmd)
cmd.Stdout = os.Stdout
}
// Capture stderr for echoing on failure.
var stderr bytes.Buffer
cmd.Stderr = &stderr
// Run the command, echoing captured stderr as well ass the cmd internal error.
if err = cmd.Run(); err != nil {
// TODO: sanitize stderr from appsody, or submit a PR to remove duplicates etc.
return errors.New(fmt.Sprintf("%v. %v", string(stderr.Bytes()), err.Error()))
}
// TODO: explicitly pull address:
return nil
}