terraform/builtin/provisioners/habitat/resource_provisioner.go

782 lines
20 KiB
Go

package habitat
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net/url"
"path"
"strings"
"text/template"
"time"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/communicator/remote"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/terraform"
linereader "github.com/mitchellh/go-linereader"
)
const installURL = "https://raw.githubusercontent.com/habitat-sh/habitat/master/components/hab/install.sh"
const systemdUnit = `
[Unit]
Description=Habitat Supervisor
[Service]
ExecStart=/bin/hab sup run {{ .SupOptions }}
Restart=on-failure
{{ if .BuilderAuthToken -}}
Environment="HAB_AUTH_TOKEN={{ .BuilderAuthToken }}"
{{ end -}}
[Install]
WantedBy=default.target
`
var serviceTypes = map[string]bool{"unmanaged": true, "systemd": true}
var updateStrategies = map[string]bool{"at-once": true, "rolling": true, "none": true}
var topologies = map[string]bool{"leader": true, "standalone": true}
type provisionFn func(terraform.UIOutput, communicator.Communicator) error
type provisioner struct {
Version string
Services []Service
PermanentPeer bool
ListenGossip string
ListenHTTP string
Peer string
RingKey string
RingKeyContent string
SkipInstall bool
UseSudo bool
ServiceType string
URL string
Channel string
Events string
OverrideName string
Organization string
BuilderAuthToken string
SupOptions string
}
func Provisioner() terraform.ResourceProvisioner {
return &schema.Provisioner{
Schema: map[string]*schema.Schema{
"version": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"peer": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"service_type": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Default: "systemd",
},
"use_sudo": &schema.Schema{
Type: schema.TypeBool,
Optional: true,
Default: true,
},
"permanent_peer": &schema.Schema{
Type: schema.TypeBool,
Optional: true,
Default: false,
},
"listen_gossip": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"listen_http": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"ring_key": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"ring_key_content": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"url": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"channel": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"events": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"override_name": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"organization": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"builder_auth_token": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"service": &schema.Schema{
Type: schema.TypeSet,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"name": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
"binds": &schema.Schema{
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
},
"bind": &schema.Schema{
Type: schema.TypeSet,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"alias": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
"service": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
"group": &schema.Schema{
Type: schema.TypeString,
Required: true,
},
},
},
Optional: true,
},
"topology": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"user_toml": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"strategy": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"channel": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"group": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"url": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"application": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"environment": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"override_name": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"service_key": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
},
},
Optional: true,
},
},
ApplyFunc: applyFn,
ValidateFunc: validateFn,
}
}
func applyFn(ctx context.Context) error {
o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput)
s := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState)
d := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData)
p, err := decodeConfig(d)
if err != nil {
return err
}
comm, err := communicator.New(s)
if err != nil {
return err
}
err = retryFunc(comm.Timeout(), func() error {
err = comm.Connect(o)
return err
})
if err != nil {
return err
}
defer comm.Disconnect()
if !p.SkipInstall {
o.Output("Installing habitat...")
if err := p.installHab(o, comm); err != nil {
return err
}
}
if p.RingKeyContent != "" {
o.Output("Uploading supervisor ring key...")
if err := p.uploadRingKey(o, comm); err != nil {
return err
}
}
o.Output("Starting the habitat supervisor...")
if err := p.startHab(o, comm); err != nil {
return err
}
if p.Services != nil {
for _, service := range p.Services {
o.Output("Starting service: " + service.Name)
if err := p.startHabService(o, comm, service); err != nil {
return err
}
}
}
return nil
}
func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) {
serviceType, ok := c.Get("service_type")
if ok {
if !serviceTypes[serviceType.(string)] {
es = append(es, errors.New(serviceType.(string)+" is not a valid service_type."))
}
}
builderURL, ok := c.Get("url")
if ok {
if _, err := url.ParseRequestURI(builderURL.(string)); err != nil {
es = append(es, errors.New(builderURL.(string)+" is not a valid URL."))
}
}
// Validate service level configs
services, ok := c.Get("service")
if ok {
for _, service := range services.([]map[string]interface{}) {
strategy, ok := service["strategy"].(string)
if ok && !updateStrategies[strategy] {
es = append(es, errors.New(strategy+" is not a valid update strategy."))
}
topology, ok := service["topology"].(string)
if ok && !topologies[topology] {
es = append(es, errors.New(topology+" is not a valid topology"))
}
builderURL, ok := service["url"].(string)
if ok {
if _, err := url.ParseRequestURI(builderURL); err != nil {
es = append(es, errors.New(builderURL+" is not a valid URL."))
}
}
}
}
return ws, es
}
type Service struct {
Name string
Strategy string
Topology string
Channel string
Group string
URL string
Binds []Bind
BindStrings []string
UserTOML string
AppName string
Environment string
OverrideName string
ServiceGroupKey string
}
type Bind struct {
Alias string
Service string
Group string
}
func (s *Service) getPackageName(fullName string) string {
return strings.Split(fullName, "/")[1]
}
func (b *Bind) toBindString() string {
return fmt.Sprintf("%s:%s.%s", b.Alias, b.Service, b.Group)
}
func decodeConfig(d *schema.ResourceData) (*provisioner, error) {
p := &provisioner{
Version: d.Get("version").(string),
Peer: d.Get("peer").(string),
Services: getServices(d.Get("service").(*schema.Set).List()),
UseSudo: d.Get("use_sudo").(bool),
ServiceType: d.Get("service_type").(string),
RingKey: d.Get("ring_key").(string),
RingKeyContent: d.Get("ring_key_content").(string),
PermanentPeer: d.Get("permanent_peer").(bool),
ListenGossip: d.Get("listen_gossip").(string),
ListenHTTP: d.Get("listen_http").(string),
URL: d.Get("url").(string),
Channel: d.Get("channel").(string),
Events: d.Get("events").(string),
OverrideName: d.Get("override_name").(string),
Organization: d.Get("organization").(string),
BuilderAuthToken: d.Get("builder_auth_token").(string),
}
return p, nil
}
func getServices(v []interface{}) []Service {
services := make([]Service, 0, len(v))
for _, rawServiceData := range v {
serviceData := rawServiceData.(map[string]interface{})
name := (serviceData["name"].(string))
strategy := (serviceData["strategy"].(string))
topology := (serviceData["topology"].(string))
channel := (serviceData["channel"].(string))
group := (serviceData["group"].(string))
url := (serviceData["url"].(string))
app := (serviceData["application"].(string))
env := (serviceData["environment"].(string))
override := (serviceData["override_name"].(string))
userToml := (serviceData["user_toml"].(string))
serviceGroupKey := (serviceData["service_key"].(string))
var bindStrings []string
binds := getBinds(serviceData["bind"].(*schema.Set).List())
for _, b := range serviceData["binds"].([]interface{}) {
bind, err := getBindFromString(b.(string))
if err != nil {
return nil
}
binds = append(binds, bind)
}
service := Service{
Name: name,
Strategy: strategy,
Topology: topology,
Channel: channel,
Group: group,
URL: url,
UserTOML: userToml,
BindStrings: bindStrings,
Binds: binds,
AppName: app,
Environment: env,
OverrideName: override,
ServiceGroupKey: serviceGroupKey,
}
services = append(services, service)
}
return services
}
func getBinds(v []interface{}) []Bind {
binds := make([]Bind, 0, len(v))
for _, rawBindData := range v {
bindData := rawBindData.(map[string]interface{})
alias := bindData["alias"].(string)
service := bindData["service"].(string)
group := bindData["group"].(string)
bind := Bind{
Alias: alias,
Service: service,
Group: group,
}
binds = append(binds, bind)
}
return binds
}
func (p *provisioner) uploadRingKey(o terraform.UIOutput, comm communicator.Communicator) error {
command := fmt.Sprintf("echo '%s' | hab ring key import", p.RingKeyContent)
if p.UseSudo {
command = fmt.Sprintf("echo '%s' | sudo hab ring key import", p.RingKeyContent)
}
return p.runCommand(o, comm, command)
}
func (p *provisioner) installHab(o terraform.UIOutput, comm communicator.Communicator) error {
// Build the install command
command := fmt.Sprintf("curl -L0 %s > install.sh", installURL)
if err := p.runCommand(o, comm, command); err != nil {
return err
}
// Run the install script
if p.Version == "" {
command = fmt.Sprintf("env HAB_NONINTERACTIVE=true bash ./install.sh ")
} else {
command = fmt.Sprintf("env HAB_NONINTERACTIVE=true bash ./install.sh -v %s", p.Version)
}
if p.UseSudo {
command = fmt.Sprintf("sudo %s", command)
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
if err := p.createHabUser(o, comm); err != nil {
return err
}
return p.runCommand(o, comm, fmt.Sprintf("rm -f install.sh"))
}
func (p *provisioner) startHab(o terraform.UIOutput, comm communicator.Communicator) error {
// Install the supervisor first
var command string
if p.Version == "" {
command += fmt.Sprintf("hab install core/hab-sup")
} else {
command += fmt.Sprintf("hab install core/hab-sup/%s", p.Version)
}
if p.UseSudo {
command = fmt.Sprintf("sudo -E %s", command)
}
command = fmt.Sprintf("env HAB_NONINTERACTIVE=true %s", command)
if err := p.runCommand(o, comm, command); err != nil {
return err
}
// Build up sup options
options := ""
if p.PermanentPeer {
options += " -I"
}
if p.ListenGossip != "" {
options += fmt.Sprintf(" --listen-gossip %s", p.ListenGossip)
}
if p.ListenHTTP != "" {
options += fmt.Sprintf(" --listen-http %s", p.ListenHTTP)
}
if p.Peer != "" {
options += fmt.Sprintf(" --peer %s", p.Peer)
}
if p.RingKey != "" {
options += fmt.Sprintf(" --ring %s", p.RingKey)
}
if p.URL != "" {
options += fmt.Sprintf(" --url %s", p.URL)
}
if p.Channel != "" {
options += fmt.Sprintf(" --channel %s", p.Channel)
}
if p.Events != "" {
options += fmt.Sprintf(" --events %s", p.Events)
}
if p.OverrideName != "" {
options += fmt.Sprintf(" --override-name %s", p.OverrideName)
}
if p.Organization != "" {
options += fmt.Sprintf(" --org %s", p.Organization)
}
p.SupOptions = options
switch p.ServiceType {
case "unmanaged":
return p.startHabUnmanaged(o, comm, options)
case "systemd":
return p.startHabSystemd(o, comm, options)
default:
return errors.New("Unsupported service type")
}
}
func (p *provisioner) startHabUnmanaged(o terraform.UIOutput, comm communicator.Communicator, options string) error {
// Create the sup directory for the log file
var command string
var token string
if p.UseSudo {
command = "sudo mkdir -p /hab/sup/default && sudo chmod o+w /hab/sup/default"
} else {
command = "mkdir -p /hab/sup/default && chmod o+w /hab/sup/default"
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
if p.BuilderAuthToken != "" {
token = fmt.Sprintf("env HAB_AUTH_TOKEN=%s", p.BuilderAuthToken)
}
if p.UseSudo {
command = fmt.Sprintf("(%s setsid sudo -E hab sup run %s > /hab/sup/default/sup.log 2>&1 &) ; sleep 1", token, options)
} else {
command = fmt.Sprintf("(%s setsid hab sup run %s > /hab/sup/default/sup.log 2>&1 <&1 &) ; sleep 1", token, options)
}
return p.runCommand(o, comm, command)
}
func (p *provisioner) startHabSystemd(o terraform.UIOutput, comm communicator.Communicator, options string) error {
// Create a new template and parse the client config into it
unitString := template.Must(template.New("hab-supervisor.service").Parse(systemdUnit))
var buf bytes.Buffer
err := unitString.Execute(&buf, p)
if err != nil {
return fmt.Errorf("Error executing %s template: %s", "hab-supervisor.service", err)
}
var command string
if p.UseSudo {
command = fmt.Sprintf("sudo echo '%s' | sudo tee /etc/systemd/system/hab-supervisor.service > /dev/null", &buf)
} else {
command = fmt.Sprintf("echo '%s' | tee /etc/systemd/system/hab-supervisor.service > /dev/null", &buf)
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
if p.UseSudo {
command = fmt.Sprintf("sudo systemctl start hab-supervisor")
} else {
command = fmt.Sprintf("systemctl start hab-supervisor")
}
return p.runCommand(o, comm, command)
}
func (p *provisioner) createHabUser(o terraform.UIOutput, comm communicator.Communicator) error {
// Create the hab user
command := fmt.Sprintf("env HAB_NONINTERACTIVE=true hab install core/busybox")
if p.UseSudo {
command = fmt.Sprintf("sudo %s", command)
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
command = fmt.Sprintf("hab pkg exec core/busybox adduser -D -g \"\" hab")
if p.UseSudo {
command = fmt.Sprintf("sudo %s", command)
}
return p.runCommand(o, comm, command)
}
func (p *provisioner) startHabService(o terraform.UIOutput, comm communicator.Communicator, service Service) error {
var command string
if p.UseSudo {
command = fmt.Sprintf("env HAB_NONINTERACTIVE=true sudo -E hab pkg install %s", service.Name)
} else {
command = fmt.Sprintf("env HAB_NONINTERACTIVE=true hab pkg install %s", service.Name)
}
if p.BuilderAuthToken != "" {
command = fmt.Sprintf("env HAB_AUTH_TOKEN=%s %s", p.BuilderAuthToken, command)
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
if err := p.uploadUserTOML(o, comm, service); err != nil {
return err
}
// Upload service group key
if service.ServiceGroupKey != "" {
p.uploadServiceGroupKey(o, comm, service.ServiceGroupKey)
}
options := ""
if service.Topology != "" {
options += fmt.Sprintf(" --topology %s", service.Topology)
}
if service.Strategy != "" {
options += fmt.Sprintf(" --strategy %s", service.Strategy)
}
if service.Channel != "" {
options += fmt.Sprintf(" --channel %s", service.Channel)
}
if service.URL != "" {
options += fmt.Sprintf("--url %s", service.URL)
}
if service.Group != "" {
options += fmt.Sprintf(" --group %s", service.Group)
}
for _, bind := range service.Binds {
options += fmt.Sprintf(" --bind %s", bind.toBindString())
}
command = fmt.Sprintf("hab svc load %s %s", service.Name, options)
if p.UseSudo {
command = fmt.Sprintf("sudo -E %s", command)
}
if p.BuilderAuthToken != "" {
command = fmt.Sprintf("env HAB_AUTH_TOKEN=%s %s", p.BuilderAuthToken, command)
}
return p.runCommand(o, comm, command)
}
func (p *provisioner) uploadServiceGroupKey(o terraform.UIOutput, comm communicator.Communicator, key string) error {
keyName := strings.Split(key, "\n")[1]
o.Output("Uploading service group key: " + keyName)
keyFileName := fmt.Sprintf("%s.box.key", keyName)
destPath := path.Join("/hab/cache/keys", keyFileName)
keyContent := strings.NewReader(key)
if p.UseSudo {
tempPath := path.Join("/tmp", keyFileName)
if err := comm.Upload(tempPath, keyContent); err != nil {
return err
}
command := fmt.Sprintf("sudo mv %s %s", tempPath, destPath)
return p.runCommand(o, comm, command)
}
return comm.Upload(destPath, keyContent)
}
func (p *provisioner) uploadUserTOML(o terraform.UIOutput, comm communicator.Communicator, service Service) error {
// Create the hab svc directory to lay down the user.toml before loading the service
o.Output("Uploading user.toml for service: " + service.Name)
destDir := fmt.Sprintf("/hab/svc/%s", service.getPackageName(service.Name))
command := fmt.Sprintf("mkdir -p %s", destDir)
if p.UseSudo {
command = fmt.Sprintf("sudo %s", command)
}
if err := p.runCommand(o, comm, command); err != nil {
return err
}
userToml := strings.NewReader(service.UserTOML)
if p.UseSudo {
if err := comm.Upload("/tmp/user.toml", userToml); err != nil {
return err
}
command = fmt.Sprintf("sudo mv /tmp/user.toml %s", destDir)
return p.runCommand(o, comm, command)
}
return comm.Upload(path.Join(destDir, "user.toml"), userToml)
}
func retryFunc(timeout time.Duration, f func() error) error {
finish := time.After(timeout)
for {
err := f()
if err == nil {
return nil
}
log.Printf("Retryable error: %v", err)
select {
case <-finish:
return err
case <-time.After(3 * time.Second):
}
}
}
func (p *provisioner) copyOutput(o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
defer close(doneCh)
lr := linereader.New(r)
for line := range lr.Ch {
o.Output(line)
}
}
func (p *provisioner) runCommand(o terraform.UIOutput, comm communicator.Communicator, command string) error {
outR, outW := io.Pipe()
errR, errW := io.Pipe()
var err error
outDoneCh := make(chan struct{})
errDoneCh := make(chan struct{})
go p.copyOutput(o, outR, outDoneCh)
go p.copyOutput(o, errR, errDoneCh)
cmd := &remote.Cmd{
Command: command,
Stdout: outW,
Stderr: errW,
}
if err = comm.Start(cmd); err != nil {
return fmt.Errorf("Error executing command %q: %v", cmd.Command, err)
}
cmd.Wait()
if cmd.ExitStatus != 0 {
err = fmt.Errorf(
"Command %q exited with non-zero exit status: %d", cmd.Command, cmd.ExitStatus)
}
outW.Close()
errW.Close()
<-outDoneCh
<-errDoneCh
return err
}
func getBindFromString(bind string) (Bind, error) {
t := strings.FieldsFunc(bind, func(d rune) bool {
switch d {
case ':', '.':
return true
}
return false
})
if len(t) != 3 {
return Bind{}, errors.New("Invalid bind specification: " + bind)
}
return Bind{Alias: t[0], Service: t[1], Group: t[2]}, nil
}