func/client/client.go

610 lines
18 KiB
Go

package client
import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"golang.org/x/net/publicsuffix"
"gopkg.in/yaml.v2"
)
// Client for a given Service Function.
type Client struct {
verbose bool // print verbose logs
local bool // Run in local-only mode
name string // Service function DNS address (configurable)
root string // root path of function on which to operate
domainSearchLimit int // max dirs to recurse up when deriving domain
dnsProvider DNSProvider // Provider of DNS services
initializer Initializer // Creates initial local function implementation
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
lister Lister // Lists remote services
}
// ConfigFileName is an optional file checked for in the function root.
const ConfigFileName = ".faas.yaml"
// Config object which provides another mechanism for overriding client static
// defaults. Applied prior to the WithX options, such that the options take
// precedence if they are provided.
type Config struct {
// Name specifies the name to be used for this function. As a config option,
// this value, if provided, takes precidence over the path-derived name but
// not over the Option WithName, if provided.
Name string `yaml:"name"`
// Add new values to the applyConfig function as necessary.
}
// DNSProvider exposes DNS services necessary for serving the Service Function.
type DNSProvider interface {
// Provide the given name by routing requests to address.
Provide(name, address string)
}
// Initializer creates the initial/stub Service Function code on first create.
type Initializer interface {
// Initialize a Service Function of the given name, using the templates for
// the given language, written into the given path.
Initialize(name, language, path string) error
}
// Builder of function source to runnable image.
type Builder interface {
// 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 service function.
Push(image string) error
}
// Deployer of function source to running status.
type Deployer interface {
// Deploy a service function of given name, using given backing image.
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.
Run(path string) error
}
// Remover of deployed services.
type Remover interface {
// Remove the service function from remote.
Remove(name string) error
}
// Lister of deployed services.
type Lister interface {
// List the service functions currently deployed.
List() ([]string, error)
}
// Option defines a function which when passed to the Client constructor optionally
// mutates private members at time of instantiation.
type Option func(*Client)
// WithVerbose toggles verbose logging.
func WithVerbose(v bool) Option {
return func(c *Client) {
c.verbose = v
}
}
// WithName sets the explicit name for the Service Function, disabling
// name inference from path.
func WithName(name string) Option {
return func(c *Client) {
c.name = name
}
}
// WithDomainSearchLimit sets the maximum levels of upward recursion used when
// attempting to derive effective DNS name from root path. Ignored if DNS was
// explicitly set via WithName.
func WithDomainSearchLimit(limit int) Option {
return func(c *Client) {
c.domainSearchLimit = limit
}
}
// WithDNSProvider proivdes a DNS provider implementation for registering the
// effective DNS name which is either explicitly set via WithName or is derived
// from the root path.
func WithDNSProvider(provider DNSProvider) Option {
return func(c *Client) {
c.dnsProvider = provider
}
}
// WithInitializer provides the concrete implementation of the Service Function
// initializer (generates stub code on initial create).
func WithInitializer(i Initializer) Option {
return func(c *Client) {
c.initializer = i
}
}
// WithBuilder provides the concrete implementation of a builder.
func WithBuilder(d Builder) Option {
return func(c *Client) {
c.builder = d
}
}
// WithPusher provides the concrete implementation of a pusher.
func WithPusher(d Pusher) Option {
return func(c *Client) {
c.pusher = d
}
}
// WithDeployer provides the concrete implementation of a deployer.
func WithDeployer(d Deployer) Option {
return func(c *Client) {
c.deployer = d
}
}
// 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) {
c.runner = r
}
}
// WithRemover provides the concrete implementation of a remover.
func WithRemover(r Remover) Option {
return func(c *Client) {
c.remover = r
}
}
// WithLister provides the concrete implementation of a lister.
func WithLister(l Lister) Option {
return func(c *Client) {
c.lister = l
}
}
// New client for a function service rooted at the given directory (default .) or
// that explicitly set via the option. Will fail if the directory already contains
// config files or other non-hidden files.
func New(root string, options ...Option) (c *Client, err error) {
if root == "" {
root = "."
}
// Instantiate client with static defaults.
c = &Client{
root: root,
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.
}
// Apply config file in the given path if it exists, overriding static defaults above.
if err = applyConfig(c, c.root); err != nil {
return
}
// Apply passed options, which take ultimate precidence.
for _, o := range options {
o(c)
}
// 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
}
// Service Name
// If not explicity set via the WithName option, we attempt to derive the
// name from the effective root path.
if c.name == "" {
c.name = pathToDomain(c.root, c.domainSearchLimit)
}
if c.name == "" {
return c, errors.New("Function name must be provided or be derivable from path.")
}
return
}
// SetLocal mode (skips push, deploy, etc.)
func (c *Client) SetLocal(local bool) {
c.local = local
}
// Create a service function
func (c *Client) Create(language string) (err error) {
// Language is required
// Whether or not the given language is supported is dependant on
// the implementation of the initializer.
if language == "" {
return errors.New("language not specified")
}
// Assert the root does not contain contentious hidden files (configs).
var files []string
if files, err = contentiousFilesIn(c.root); err != nil {
return
} else if len(files) > 0 {
err = errors.New(fmt.Sprintf("The directory has extant faas config files. Has the service funciton already been created? Either use a different directory, delete the service function if it exists, or remove the files manually: %v", files))
return
}
// Assert the local directory is empty of all non-hidden files/dirs, and of
// the hidden files .appsody-config.yaml and .faas.yaml.
var empty bool
if empty, err = isEffectivelyEmpty(c.root); err != nil {
return
} else if !empty {
err = errors.New("The directory contains visible and/or recognized config files.")
return
}
// Initialize the specified root with a function template.
// TODO: detect extant and abort.
if err = c.initializer.Initialize(c.name, language, c.root); err != nil {
return
}
// Write the effective config once initialization was successful.
if err = writeConfig(c); err != nil {
return
}
// Build the now-initialized service function
image, err := c.builder.Build(c.name, c.root)
if err != nil {
return
}
// If running local-only, we're done.
if c.local {
return
}
// Push the image for the names service to the configured registry
if err = c.pusher.Push(image); err != nil {
return
}
// Deploy the initialized service function, returning its publicly
// addressible name for possible registration.
address, err := c.deployer.Deploy(c.name, image)
if err != nil {
return
}
// 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)
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)
}
// List currently deployed service functions.
func (c *Client) List() ([]string, error) {
// delegate to concrete implementation of lister entirely.
return c.lister.List()
}
// Remove a function from remote, bringing the service funciton
// to the same state as if it had been created --local only.
// Name is the presently configured client's name, which was
// either derived from the path, specified by the extant config,
// or provided as an option (in ascending precedence).
func (c *Client) Remove() error {
// delegate to concrete implementation of remover entirely.
return c.remover.Remove(c.name)
}
// Convert a path to a domain.
// Searches up the path string until a domain (TLD+1) is detected.
// Subdirectories are considered subdomains.
// Ex: Path: "/home/users/jane/src/example.com/admin/www"
// Returns: "www.admin.example.com"
// maxLevels is the number of directories to walk upwards beyond the current
// directory to determine domain (i.e. current directory is always considered.
// Zero indicates only consider last path element.)
func pathToDomain(path string, maxLevels int) string {
var (
// parts of the path, separated by os separator
parts = strings.Split(path, string(os.PathSeparator))
// subdomains derived from the path
subdomains []string
// domain derived from the path
domain string
)
// Loop over parts from back to front (recursing upwards), building
// optional subdomains until a root domain (TLD+1) is detected.
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
// Support limited recursion
// Tests, for instance, need to be allowed to reliably fail by having their
// recursion contained within ./testdata if recursion is set to -1, there
// is no limit. 0 indicates only the current directory is considered.
iteration := len(parts) - 1 - i
if maxLevels >= 0 && iteration > maxLevels {
break
}
// Detect TLD+1
// If the current directory has a valid TLD plus one, it is a match.
// This is determined by using the public suffices list, which includes
// both ICANN managed TLDs as well as an extended list (matching, for
// instance 'cluster.local')
if suffix, _ := publicsuffix.EffectiveTLDPlusOne(part); suffix != "" {
domain = part
break // no directories above the nearest TLD+1 should be considered.
}
// Skip blanks
// Path elements which are blank, such as in the case of a trailing slash
// are ignored and the recursion continues, effectively collapsing ex: '//'.
if part == "" {
continue
}
// Build subdomain
// Each path element which appears before the TLD+1 is a subdomain.
// ex: '/home/users/jane/src/example.com/us-west-2/admin/www' creates the
// subdomain []string{'www', 'admin', 'us-west-2'}
subdomains = append(subdomains, part)
}
// Unable to derive domain
// If the entire path was searched, but no parts matched a TLD+1, the domain
// will be blank. In this case, the path was insufficient to derive a domain
// ex "/home/users/jane/src/test" contains no TLD, thus the final domain must
// be explicitly provided.
if domain == "" {
return ""
}
// Prepend subdomains
// If the path was a subdirectory within a TLD+1, these sudbomains
// are prepended to the TLD+1 to create the final domain.
// ex: '/home/users/jane/src/example.com/us-west-2/admin/www' yields
// www.admin.use-west-2.example.com
if len(subdomains) > 0 {
subdomains = append(subdomains, domain)
return strings.Join(subdomains, ".")
}
return domain
}
// 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
// development, however, it is usefule that they are defaulted to noops and
// provded only when necessary. Unit tests for the concrete implementations
// serve to keep the core logic here separate from the imperitive.
// -----------------------------------------------------
type noopDNSProvider struct{ output io.Writer }
func (p *noopDNSProvider) Provide(name, address string) {
fmt.Fprintln(p.output, "skipping DNS update: client not initialized WithDNSProvider")
}
type noopInitializer struct{ output io.Writer }
func (i *noopInitializer) Initialize(name, language, root string) error {
fmt.Fprintln(i.output, "skipping initialize: client not initialized WithInitializer")
return nil
}
type noopBuilder struct{ output io.Writer }
func (i *noopBuilder) Build(name, root string) (image string, err error) {
fmt.Fprintln(i.output, "skipping build: client not initialized WithBuilder")
return "", nil
}
type noopPusher struct{ output io.Writer }
func (i *noopPusher) Push(image string) error {
fmt.Fprintln(i.output, "skipping push: client not initialized WithPusher")
return nil
}
type noopDeployer struct{ output io.Writer }
func (i *noopDeployer) Deploy(name, image string) (string, error) {
fmt.Fprintln(i.output, "skipping deploy: client not initialized WithDeployer")
return "", nil
}
type noopUpdater struct{ output io.Writer }
func (i *noopUpdater) Update(name, image string) error {
fmt.Fprintln(i.output, "skipping deploy: client not initialized WithDeployer")
return nil
}
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
}
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
}
// contentiousFiles are files which, if extant, preclude the creation of a
// service function rooted in the given directory.
var contentiousFiles = []string{
".faas.yaml",
".appsody-config.yaml",
}
// contentiousFilesIn the given directoy
func contentiousFilesIn(dir string) (contentious []string, err error) {
files, err := ioutil.ReadDir(dir)
for _, file := range files {
for _, name := range contentiousFiles {
if file.Name() == name {
contentious = append(contentious, name)
}
}
}
return
}
// effectivelyEmpty directories are those which have no visible files,
// and no explicitly enumerated contentious files.
func isEffectivelyEmpty(dir string) (empty bool, err error) {
var contentious []string
if contentious, err = contentiousFilesIn(dir); len(contentious) > 0 {
return
}
files, err := ioutil.ReadDir(dir)
for _, file := range files {
if !strings.HasPrefix(file.Name(), ".") {
return
}
}
return true, nil
}
// Apply the config, if it exists, to the client.
// if an entry exists in the config file and is empty, this is interpreted as
// the intent to zero-value that field.
func applyConfig(c *Client, root string) error {
// abort if the config file does not exist.
filename := filepath.Join(root, ConfigFileName)
if _, err := os.Stat(filename); os.IsNotExist(err) {
return nil
}
// Read in as bytes
bb, err := ioutil.ReadFile(filepath.Join(root, ConfigFileName))
if err != nil {
return err
}
// Create a config with defaults set to the current value of the Client object.
// These gymnastics are necessary because we want the Client's members to be
// private to disallow mutation post instantiation, and thus they are unavailable
// to be set automatically
cfg := newConfig(c)
// Decode yaml, overriding values in the config if they were defined in the yaml.
if err := yaml.Unmarshal(bb, &cfg); err != nil {
return err
}
// Apply the config to the client object, which effectiely writes back the default
// if it was not defined in the yaml.
c.name = cfg.Name
// NOTE: cleverness < clarity
return nil
}
// newConfig creates a config object from a client, effectively exporting mutable
// fields for the config file while preserving the immutability of the client
// post-instantiation.
func newConfig(c *Client) Config {
return Config{
Name: c.name,
}
}
// writeConfig out to disk.
func writeConfig(c *Client) (err error) {
var (
cfg = newConfig(c)
cfgFile = filepath.Join(c.root, ConfigFileName)
bb []byte
)
if bb, err = yaml.Marshal(&cfg); err != nil {
return
}
return ioutil.WriteFile(cfgFile, bb, 0644)
}