package habitat import ( "bytes" "context" "errors" "fmt" "io" "net/url" "path" "strings" "text/template" "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 ServiceName 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", }, "service_name": &schema.Schema{ Type: schema.TypeString, Optional: true, Default: "hab-supervisor", }, "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 } retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout()) defer cancel() err = communicator.Retry(retryCtx, func() error { return comm.Connect(o) }) 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), ServiceName: d.Get("service_name").(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/%s.service > /dev/null", &buf, p.ServiceName) } else { command = fmt.Sprintf("echo '%s' | tee /etc/systemd/system/%s.service > /dev/null", &buf, p.ServiceName) } if err := p.runCommand(o, comm, command); err != nil { return err } if p.UseSudo { command = fmt.Sprintf("sudo systemctl enable hab-supervisor && sudo systemctl start hab-supervisor") } else { command = fmt.Sprintf("systemctl enable hab-supervisor && systemctl start hab-supervisor") } return p.runCommand(o, comm, command) } func (p *provisioner) createHabUser(o terraform.UIOutput, comm communicator.Communicator) error { addUser := false // Install busybox to get us the user tools we need 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 } // Check for existing hab user command = fmt.Sprintf("hab pkg exec core/busybox id hab") if p.UseSudo { command = fmt.Sprintf("sudo %s", command) } if err := p.runCommand(o, comm, command); err != nil { o.Output("No existing hab user detected, creating...") addUser = true } if addUser { 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) } return nil } // In the future we'll remove the dedicated install once the synchronous load feature in hab-sup is // available. Until then we install here to provide output and a noisy failure mechanism because // if you install with the pkg load, it occurs asynchronously and fails quietly. func (p *provisioner) installHabPackage(o terraform.UIOutput, comm communicator.Communicator, service Service) error { var command string options := "" if service.Channel != "" { options += fmt.Sprintf(" --channel %s", service.Channel) } if service.URL != "" { options += fmt.Sprintf(" --url %s", service.URL) } if p.UseSudo { command = fmt.Sprintf("env HAB_NONINTERACTIVE=true sudo -E hab pkg install %s %s", service.Name, options) } else { command = fmt.Sprintf("env HAB_NONINTERACTIVE=true hab pkg install %s %s", service.Name, options) } if p.BuilderAuthToken != "" { command = fmt.Sprintf("env HAB_AUTH_TOKEN=%s %s", p.BuilderAuthToken, command) } return p.runCommand(o, comm, command) } func (p *provisioner) startHabService(o terraform.UIOutput, comm communicator.Communicator, service Service) error { var command string if err := p.installHabPackage(o, comm, service); 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 (p *provisioner) copyOutput(o terraform.UIOutput, r io.Reader) { 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() go p.copyOutput(o, outR) go p.copyOutput(o, errR) defer outW.Close() defer errW.Close() 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) } if err := cmd.Wait(); err != nil { return err } return nil } 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 }