pulumi/pkg/compiler/compiler.go
joeduffy 6fb6c2de09 Add cloud target and architecture detection
This change implements most of the cloud target and architecture detection
logic, along with associated verification and a bunch of new error messages.

There are two settings for picking a cloud destination:

* Architecture: this specifies the combination of cloud (e.g., AWS, GCP, etc)
      plus scheduler (e.g., none, Swarm, ECS, etc).

* Target: a named, preconfigured entity that includes both an Architecture and
      an assortment of extra default configuration options.

The general idea here is that you can preconfigure a set of Targets for
named environments like "prod", "stage", etc.  Those can either exist in a
single Mufile, or the Mucluster file if they are shared amongst multiple
Mufiles.  This can be specified at the command line as such:

        $ mu build --target=stage

Furthermore, a given environment may be annointed the default, so that

        $ mu build

selects that environment without needing to say so explicitly.

It is also possible to specify an architecture at the command line for
scenarios where you aren't intending to target an existing named environment.
This is good for "anonymous" testing scenarios or even just running locally:

        $ mu build --arch=aws
        $ mu build --arch=aws:ecs
        $ mu build --arch=local:kubernetes
        $ .. and so on ..

This change does little more than plumb these settings around, verify them,
etc., however it sets us up to actually start dispating to the right backend.
2016-11-17 10:30:37 -08:00

320 lines
9.2 KiB
Go

// Copyright 2016 Marapongo, Inc. All rights reserved.
package compiler
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/golang/glog"
"github.com/marapongo/mu/pkg/ast"
"github.com/marapongo/mu/pkg/compiler/clouds"
"github.com/marapongo/mu/pkg/compiler/core"
"github.com/marapongo/mu/pkg/compiler/schedulers"
"github.com/marapongo/mu/pkg/diag"
"github.com/marapongo/mu/pkg/errors"
"github.com/marapongo/mu/pkg/workspace"
)
// Compiler provides an interface into the many phases of the Mu compilation process.
type Compiler interface {
core.Phase
// Context returns the current compiler context.
Context() *core.Context
// Build detects and compiles inputs from the given location, storing build artifacts in the given destination.
Build(inp string, outp string)
}
// compiler is the canonical implementation of the Mu compiler.
type compiler struct {
ctx *core.Context
opts Options
}
// NewCompiler creates a new instance of the Mu compiler, with the given initialization settings.
func NewCompiler(opts Options) Compiler {
return &compiler{
ctx: &core.Context{},
opts: opts,
}
}
func (c *compiler) Context() *core.Context {
return c.ctx
}
func (c *compiler) Diag() diag.Sink {
return c.opts.Diag
}
func (c *compiler) Build(inp string, outp string) {
glog.Infof("Building target '%v' (out='%v')", inp, outp)
if glog.V(2) {
defer func() {
glog.V(2).Infof("Building target '%v' completed w/ %v warnings and %v errors",
inp, c.Diag().Warnings(), c.Diag().Errors())
}()
}
// Perform the front-end passes to generate a stack AST.
doc, stack, ok := c.loadAndParseStack(inp)
if !ok {
return
}
// Figure out which cloud architecture we will be targeting during code-gen.
target, arch, ok := c.discoverTargetArch(doc, stack)
if !ok {
return
}
if glog.V(2) {
tname := "n/a"
if target != nil {
tname = target.Name
}
glog.V(2).Infof("Stack %v targets target=%v cloud=%v", stack.Name, tname, arch)
}
// Perform the semantic analysis passes to validate, transform, and/or update the AST.
stack, ok = c.analyzeStack(doc, stack)
if !ok {
return
}
// TODO: lower the ASTs to the target backend's representation, emit it.
// TODO: delta generation, deployment, etc.
}
// loadAndParseStack takes an input path, discovers a Mufile, parses and validates it, and returns a stack AST. If
// anything goes wrong during this process, the number of errors will be non-zero, and the bool will be false.
func (c *compiler) loadAndParseStack(inp string) (*diag.Document, *ast.Stack, bool) {
// First find the root of the current package based on the location of its Mufile.
mufile := c.detectMufile(inp)
if mufile == "" {
c.Diag().Errorf(errors.MissingMufile, inp)
return nil, nil, false
}
// Read in the contents of the document and make it available to subsequent stages.
doc, err := diag.ReadDocument(mufile)
if err != nil {
c.Diag().Errorf(errors.CouldNotReadMufile.WithFile(mufile), err)
return doc, nil, false
}
// To build the Mu package, first parse the input file.
p := NewParser(c)
stack := p.Parse(doc)
if p.Diag().Errors() > 0 {
// If any errors happened during parsing, exit.
return doc, stack, false
}
// Do a pass over the parse tree to ensure that all is well.
ptAnalyzer := NewPTAnalyzer(c)
ptAnalyzer.Analyze(doc, stack)
if p.Diag().Errors() > 0 {
// If any errors happened during parse tree analysis, exit.
return doc, stack, false
}
return doc, stack, true
}
// discoverTargetArch uses a variety of mechanisms to discover the target architecture, returning it. If no
// architecture was discovered, an error is issued, and the bool return will be false.
func (c *compiler) discoverTargetArch(doc *diag.Document, stack *ast.Stack) (*ast.Target, Arch, bool) {
// Target and architectures settings may come from one of three places, in order of search preference:
// 1) command line arguments.
// 2) settings specific to this stack.
// 3) cluster-wide settings in a Mucluster file.
// In other words, 1 overrides 2 which overrides 3.
arch := c.opts.Arch
// If a target was specified, look it up and load up its options.
var target *ast.Target
if c.opts.Target != "" {
// First, check the stack to see if it has a targets section.
if t, exists := stack.Targets[c.opts.Target]; exists {
target = &t
} else {
// If that didn't work, see if there's a clusters file we can consult.
// TODO: support Mucluster files.
c.Diag().Errorf(errors.CloudTargetNotFound.WithDocument(doc), c.opts.Target)
return target, arch, false
}
}
// If no target was specified or discovered yet, see if there is a default one to use.
if target == nil {
for _, t := range stack.Targets {
if t.Default {
target = &t
break
}
}
}
if target == nil {
// If no target was found, and we don't have an architecture, error out.
if arch.Cloud == clouds.NoArch {
c.Diag().Errorf(errors.NoTargetSpecified.WithDocument(doc))
return target, arch, false
}
} else {
// If a target was found, go ahead and extract and validate the target architecture.
a, ok := c.getTargetArch(doc, target, arch)
if !ok {
return target, arch, false
}
arch = a
}
return target, arch, true
}
// getTargetArch gets and validates the architecture from an existing target.
func (c *compiler) getTargetArch(doc *diag.Document, target *ast.Target, existing Arch) (Arch, bool) {
targetCloud := existing.Cloud
targetScheduler := existing.Scheduler
// If specified, look up the target's architecture settings.
if target.Cloud != "" {
tc, ok := clouds.ArchMap[target.Cloud]
if !ok {
c.Diag().Errorf(errors.UnrecognizedCloudArch.WithDocument(doc), target.Cloud)
return existing, false
}
targetCloud = tc
}
if target.Scheduler != "" {
ts, ok := schedulers.ArchMap[target.Scheduler]
if !ok {
c.Diag().Errorf(errors.UnrecognizedSchedulerArch.WithDocument(doc), target.Scheduler)
return existing, false
}
targetScheduler = ts
}
// Ensure there aren't any conflicts, comparing compiler options to target settings.
tarch := Arch{targetCloud, targetScheduler}
if targetCloud != existing.Cloud && existing.Cloud != clouds.NoArch {
c.Diag().Errorf(errors.ConflictingTargetArchSelection.WithDocument(doc), existing, target.Name, tarch)
return tarch, false
}
if targetScheduler != existing.Scheduler && existing.Scheduler != schedulers.NoArch {
c.Diag().Errorf(errors.ConflictingTargetArchSelection.WithDocument(doc), existing, target.Name, tarch)
return tarch, false
}
return tarch, true
}
// analyzeStack performs semantic analysis on a stack -- validating, transforming, and/or updating it -- and then
// returns the result. If a problem occurs, errors will have been emitted, and the bool return will be false.
func (c *compiler) analyzeStack(doc *diag.Document, stack *ast.Stack) (*ast.Stack, bool) {
// TODO: load dependencies.
binder := NewBinder(c)
binder.Bind(doc, stack)
if c.Diag().Errors() > 0 {
// If any errors happened during binding, exit.
return stack, false
}
// TODO: perform semantic analysis on the bound tree.
return stack, true
}
// detectMufile locates the closest Mufile-looking file from the given path, searching "upwards" in the directory
// hierarchy. If no Mufile is found, an empty path is returned.
func (c *compiler) detectMufile(from string) string {
abs, err := filepath.Abs(from)
if err != nil {
glog.Fatalf("An IO error occurred while searching for a Mufile: %v", err)
return ""
}
// It's possible the target is already the file we seek; if so, return right away.
if c.isMufile(abs) {
return abs
}
curr := abs
for {
stop := false
// If the target is a directory, enumerate its files, checking each to see if it's a Mufile.
files, err := ioutil.ReadDir(curr)
if err != nil {
glog.Fatalf("An IO error occurred while searching for a Mufile: %v", err)
return ""
}
for _, file := range files {
name := file.Name()
path := filepath.Join(curr, name)
if c.isMufile(path) {
return path
} else if name == workspace.Muspace {
// If we hit a .muspace file, stop looking.
stop = true
}
}
// If we encountered a stop condition, break out of the loop.
if stop {
break
}
// If neither succeeded, keep looking in our parent directory.
curr = filepath.Dir(curr)
if os.IsPathSeparator(curr[len(curr)-1]) {
break
}
}
return ""
}
// isMufile returns true if the path references what appears to be a valid Mufile.
func (c *compiler) isMufile(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
// Directories can't be Mufiles.
if info.IsDir() {
return false
}
// Ensure the base name is expected.
name := info.Name()
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
if base != workspace.MufileBase {
if strings.EqualFold(base, workspace.MufileBase) {
// If the strings aren't equal, but case-insensitively match, issue a warning.
c.Diag().Warningf(errors.WarnIllegalMufileCasing.WithFile(name))
}
return false
}
// Check all supported extensions.
for _, mufileExt := range workspace.MufileExts {
if name == workspace.MufileBase+mufileExt {
return true
}
}
// If we got here, it means the base name matched, but not the extension. Warn and return.
c.Diag().Warningf(errors.WarnIllegalMufileExt.WithFile(name), ext)
return false
}