mirror of https://github.com/knative/func.git
610 lines
18 KiB
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)
|
|
}
|