From 653db95df719017ce9fab7682fbe6cfaded20f88 Mon Sep 17 00:00:00 2001 From: Nolan Davidson Date: Tue, 3 Oct 2017 08:46:19 -0400 Subject: [PATCH] Initial implementation of a habitat provisioner First pass at loading the config data using the TF schema. Signed-off-by: Nolan Davidson --- builtin/bins/provisioner-habitat/main.go | 12 + .../habitat/resource_provisioner.go | 598 ++++++++++++++++++ command/internal_plugin_list.go | 2 + 3 files changed, 612 insertions(+) create mode 100644 builtin/bins/provisioner-habitat/main.go create mode 100644 builtin/provisioners/habitat/resource_provisioner.go diff --git a/builtin/bins/provisioner-habitat/main.go b/builtin/bins/provisioner-habitat/main.go new file mode 100644 index 0000000000..0311b4f275 --- /dev/null +++ b/builtin/bins/provisioner-habitat/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/provisioners/habitat" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProvisionerFunc: habitat.Provisioner, + }) +} diff --git a/builtin/provisioners/habitat/resource_provisioner.go b/builtin/provisioners/habitat/resource_provisioner.go new file mode 100644 index 0000000000..4519fc49bc --- /dev/null +++ b/builtin/provisioners/habitat/resource_provisioner.go @@ -0,0 +1,598 @@ +package habitat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "path" + "strings" + "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 install_url = "https://raw.githubusercontent.com/habitat-sh/habitat/master/components/hab/install.sh" + +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 `mapstructure:"version"` + Services []Service `mapstructure:"service"` + PermanentPeer bool `mapstructure:"permanent_peer"` + ListenGossip string `mapstructure:"listen_gossip"` + ListenHTTP string `mapstructure:"listen_http"` + Peer string `mapstructure:"peer"` + RingKey string `mapstructure:"ring_key"` + SkipInstall bool `mapstructure:"skip_hab_install"` + UseSudo bool `mapstructure:"use_sudo"` + ServiceType string `mapstructure:"service_type"` +} + +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: "unmanaged", + }, + "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, + }, + "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, + }, + }, + }, + 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, o) + 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 { + if err := p.installHab(o, comm); err != nil { + return err + } + } + + if err := p.startHab(o, comm); err != nil { + return err + } + + if p.Services != nil { + for _, service := range p.Services { + if err := p.startHabService(o, comm, service); err != nil { + return err + } + } + } + + return nil +} + +func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) { + return nil, nil +} + +type Service struct { + Name string `mapstructure:"name"` + Strategy string `mapstructure:"strategy"` + Topology string `mapstructure:"topology"` + Channel string `mapstructure:"channel"` + Group string `mapstructure:"group"` + URL string `mapstructure:"url"` + Binds []Bind `mapstructure:"bind"` + BindStrings []string `mapstructure:"binds"` + UserTOML string `mapstructure:"user_toml"` +} + +type Bind struct { + Alias string `mapstructure:"alias"` + Service string `mapstructure:"service"` + Group string `mapstructure:"group"` +} + +func (s *Service) getPackageName(full_name string) string { + return strings.Split(full_name, "/")[1] +} + +func (b *Bind) toBindString() string { + return fmt.Sprintf("%s:%s.%s", b.Alias, b.Service, b.Group) +} + +func decodeConfig(d *schema.ResourceData, o terraform.UIOutput) (*provisioner, error) { + // o.Output(fmt.Sprintf("%+v\n", d)) + // o.Output(fmt.Sprintf("%+v\n", d.Get("service").(*schema.Set).List())) + p := &provisioner{ + Version: d.Get("version").(string), + Peer: d.Get("peer").(string), + Services: getServices(d.Get("service").(*schema.Set).List(), o), + UseSudo: d.Get("use_sudo").(bool), + ServiceType: d.Get("service_type").(string), + RingKey: d.Get("ring_key").(string), + PermanentPeer: d.Get("permanent_peer").(bool), + ListenGossip: d.Get("listen_gossip").(string), + ListenHTTP: d.Get("listen_http").(string), + } + debug, _ := json.Marshal(p) + o.Output(string(debug)) + + return p, nil +} + +func getServices(v []interface{}, o terraform.UIOutput) []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)) + userToml := (serviceData["user_toml"].(string)) + var bindStrings []string + binds := getBinds(serviceData["bind"].(*schema.Set).List(), o) + for _, b := range serviceData["binds"].([]interface{}) { + // bindStrings = append(bindStrings, b.(string)) + 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, + } + services = append(services, service) + } + return services +} + +func getBinds(v []interface{}, o terraform.UIOutput) []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 +} + +// TODO: Add proxy support +func (p *provisioner) installHab(o terraform.UIOutput, comm communicator.Communicator) error { + // Build the install command + command := fmt.Sprintf("curl -L0 %s > install.sh", install_url) + err := p.runCommand(o, comm, command) + if 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) + } + + err = p.runCommand(o, comm, command) + if err != nil { + return err + } + + err = p.createHabUser(o, comm) + if err != nil { + return err + } + + return p.runCommand(o, comm, fmt.Sprintf("rm -f install.sh")) +} + +// TODO: Add support for options +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) + + err := p.runCommand(o, comm, command) + if 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) + } + + switch p.ServiceType { + case "unmanaged": + return p.startHabUnmanaged(o, comm, options) + case "systemd": + return p.startHabSystemd(o, comm, options) + default: + return err + } +} + +func (p *provisioner) startHabUnmanaged(o terraform.UIOutput, comm communicator.Communicator, options string) error { + // Create the sup directory for the log file + var command 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" + } + err := p.runCommand(o, comm, command) + if err != nil { + return err + } + + if p.UseSudo { + command = fmt.Sprintf("(setsid sudo hab sup run %s > /hab/sup/default/sup.log 2>&1 &) ; sleep 1", options) + } else { + command = fmt.Sprintf("(setsid hab sup run %s > /hab/sup/default/sup.log 2>&1 <&1 &) ; sleep 1", options) + } + return p.runCommand(o, comm, command) +} + +func (p *provisioner) startHabSystemd(o terraform.UIOutput, comm communicator.Communicator, options string) error { + systemd_unit := `[Unit] +Description=Habitat Supervisor + +[Service] +ExecStart=/bin/hab sup run %s +Restart=on-failure + +[Install] +WantedBy=default.target` + + systemd_unit = fmt.Sprintf(systemd_unit, options) + var command string + if p.UseSudo { + command = fmt.Sprintf("sudo echo '%s' | sudo tee /etc/systemd/system/hab-supervisor.service > /dev/null", systemd_unit) + } else { + command = fmt.Sprintf("echo '%s' | tee /etc/systemd/system/hab-supervisor.service > /dev/null", systemd_unit) + } + + err := p.runCommand(o, comm, command) + if 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) + } + err := p.runCommand(o, comm, command) + if 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) + } + err := p.runCommand(o, comm, command) + if err != nil { + return err + } + + err = p.uploadUserTOML(o, comm, service) + if err != nil { + return err + } + + 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 sup start %s %s", service.Name, options) + if p.UseSudo { + command = fmt.Sprintf("sudo %s", command) + } + return p.runCommand(o, comm, command) +} + +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 + 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) + } + err := p.runCommand(o, comm, command) + if err != nil { + return err + } + + // Use tee to lay down user.toml instead of the communicator file uploader to get around permissions issues. + command = fmt.Sprintf("sudo echo '%s' | sudo tee %s > /dev/null", service.UserTOML, path.Join(destDir, "user.toml")) + fmt.Println("Command: " + command) + o.Output("Command: " + command) + if p.UseSudo { + command = fmt.Sprintf("sudo echo '%s' | sudo tee %s > /dev/null", service.UserTOML, path.Join(destDir, "user.toml")) + } else { + command = fmt.Sprintf("echo '%s' | tee %s > /dev/null", service.UserTOML, path.Join(destDir, "user.toml")) + } + return p.runCommand(o, comm, command) +} + +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() + + 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, + } + + err := comm.Start(cmd) + if 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 +} diff --git a/command/internal_plugin_list.go b/command/internal_plugin_list.go index 6834bf5f7e..7993e9a548 100644 --- a/command/internal_plugin_list.go +++ b/command/internal_plugin_list.go @@ -6,6 +6,7 @@ package command import ( chefprovisioner "github.com/hashicorp/terraform/builtin/provisioners/chef" fileprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file" + habitatprovisioner "github.com/hashicorp/terraform/builtin/provisioners/habitat" localexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec" remoteexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec" saltmasterlessprovisioner "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless" @@ -18,6 +19,7 @@ var InternalProviders = map[string]plugin.ProviderFunc{} var InternalProvisioners = map[string]plugin.ProvisionerFunc{ "chef": chefprovisioner.Provisioner, "file": fileprovisioner.Provisioner, + "habitat": habitatprovisioner.Provisioner, "local-exec": localexecprovisioner.Provisioner, "remote-exec": remoteexecprovisioner.Provisioner, "salt-masterless": saltmasterlessprovisioner.Provisioner,