diff --git a/cmd/provision.go b/cmd/provision.go index e0f01d7f..16d3fe07 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -1,8 +1,11 @@ package cmd import ( + "bytes" "flag" + "fmt" "os" + "os/exec" "strings" "github.com/mitchellh/cli" @@ -19,12 +22,14 @@ func NewProvisionCommand(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand } type ProvisionCommand struct { - UI cli.Ui - flags *flag.FlagSet - extraVars string - tags string - Trellis *trellis.Trellis - verbose bool + UI cli.Ui + flags *flag.FlagSet + extraVars string + interactive bool + playbookName string + tags string + Trellis *trellis.Trellis + verbose bool } func (c *ProvisionCommand) init() { @@ -33,6 +38,8 @@ func (c *ProvisionCommand) init() { c.flags.StringVar(&c.extraVars, "extra-vars", "", "Additional variables which are passed through to Ansible as 'extra-vars'") c.flags.StringVar(&c.tags, "tags", "", "only run roles and tasks tagged with these values") c.flags.BoolVar(&c.verbose, "verbose", false, "Enable Ansible's verbose mode") + c.flags.BoolVar(&c.interactive, "interactive", false, "Enable interactive mode to select tags to provision") + c.flags.BoolVar(&c.interactive, "i", false, "Enable interactive mode to select tags to provision") } func (c *ProvisionCommand) Run(args []string) int { @@ -65,11 +72,15 @@ func (c *ProvisionCommand) Run(args []string) int { return 1 } - galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis} - galaxyInstallCommand.Run([]string{}) + c.playbookName = "server.yml" + + if environment == "development" { + c.playbookName = "dev.yml" + os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false") + } playbook := ansible.Playbook{ - Name: "server.yml", + Name: c.playbookName, Env: environment, Verbose: c.verbose, } @@ -79,15 +90,41 @@ func (c *ProvisionCommand) Run(args []string) int { } if c.tags != "" { + if c.interactive { + c.UI.Error("--interactive and --tags cannot be used together. Please use one or the other.") + } + playbook.AddArg("--tags", c.tags) } + if c.interactive { + _, err := exec.LookPath("fzf") + if err != nil { + c.UI.Error("No `fzf` command found. fzf is required to use interactive mode.") + } + + tags, err := c.getTags() + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + selectedTags, err := c.selectedTagsFromFzf(tags) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + playbook.AddArg("--tags", strings.Join(selectedTags, ",")) + } + if environment == "development" { - os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false") - playbook.SetName("dev.yml") playbook.SetInventory(findDevInventory(c.Trellis, c.UI)) } + galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis} + galaxyInstallCommand.Run([]string{}) + provision := command.WithOptions( command.WithUiOutput(c.UI), command.WithLogging(c.UI), @@ -130,11 +167,16 @@ Provision and provide extra vars to Ansible: $ trellis provision --extra-vars key=value production +Provision using interactive mode to select tags: + + $ trellis provision -i production + Arguments: ENVIRONMENT Name of environment (ie: production) Options: --extra-vars (multiple) Set additional variables as key=value or YAML/JSON, if filename prepend with @ + -i, --interactive Enter interactive mode to select tags to provision (requires fzf) --tags (multiple) Only run roles and tasks tagged with these values --verbose Enable Ansible's verbose mode -h, --help Show this help @@ -149,8 +191,65 @@ func (c *ProvisionCommand) AutocompleteArgs() complete.Predictor { func (c *ProvisionCommand) AutocompleteFlags() complete.Flags { return complete.Flags{ - "--extra-vars": complete.PredictNothing, - "--tags": complete.PredictNothing, - "--verbose": complete.PredictNothing, + "-i": complete.PredictNothing, + "--interactive": complete.PredictNothing, + "--extra-vars": complete.PredictNothing, + "--tags": complete.PredictNothing, + "--verbose": complete.PredictNothing, } } + +func (c *ProvisionCommand) getTags() ([]string, error) { + tagsPlaybook := ansible.Playbook{ + Name: c.playbookName, + Env: c.flags.Arg(0), + Args: []string{"--list-tags"}, + } + + tagsProvision := command.WithOptions( + command.WithUiOutput(c.UI), + ).Cmd("ansible-playbook", tagsPlaybook.CmdArgs()) + + output := &bytes.Buffer{} + tagsProvision.Stdout = output + + if err := tagsProvision.Run(); err != nil { + return nil, err + } + + tags := ansible.ParseTags(output.String()) + + return tags, nil +} + +func (c *ProvisionCommand) selectedTagsFromFzf(tags []string) ([]string, error) { + output := &bytes.Buffer{} + input := strings.NewReader(strings.Join(tags, "\n")) + + previewCmd := fmt.Sprintf("trellis exec ansible-playbook %s --list-tasks --tags {}", c.playbookName) + + fzf := command.WithOptions(command.WithTermOutput()).Cmd( + "fzf", + []string{ + "-m", + "--height", "50%", + "--reverse", + "--border", + "--border-label", "Select tags to provision (use TAB to select multiple tags)", + "--border-label-pos", "5", + "--preview", previewCmd, + "--preview-label", "Tasks for tag", + }, + ) + fzf.Stdin = input + fzf.Stdout = output + + err := fzf.Run() + if err != nil { + return nil, err + } + + selectedTags := strings.Split(strings.TrimSpace(output.String()), "\n") + + return selectedTags, nil +} diff --git a/pkg/ansible/playbook.go b/pkg/ansible/playbook.go index a2a107b0..b813ef2e 100644 --- a/pkg/ansible/playbook.go +++ b/pkg/ansible/playbook.go @@ -10,11 +10,11 @@ type Playbook struct { Env string Verbose bool ExtraVars map[string]string - args []string + Args []string } func (p *Playbook) AddArg(name string, value string) *Playbook { - p.args = append(p.args, name+"="+value) + p.Args = append(p.Args, name+"="+value) return p } @@ -28,7 +28,7 @@ func (p *Playbook) AddExtraVar(name string, value string) *Playbook { } func (p *Playbook) AddExtraVars(extraVars string) *Playbook { - p.args = append(p.args, fmt.Sprintf("-e %s", extraVars)) + p.Args = append(p.Args, fmt.Sprintf("-e %s", extraVars)) return p } @@ -52,7 +52,7 @@ func (p *Playbook) CmdArgs() []string { args = append(args, "-vvvv") } - args = append(args, p.args...) + args = append(args, p.Args...) if p.Env != "" { p.AddExtraVar("env", p.Env) diff --git a/pkg/ansible/tags.go b/pkg/ansible/tags.go new file mode 100644 index 00000000..8cd99ccb --- /dev/null +++ b/pkg/ansible/tags.go @@ -0,0 +1,44 @@ +package ansible + +import ( + "regexp" + "sort" + "strings" +) + +/* +Parse output from ansible-playbook --list-tags + +Example output: + +``` +playbook: dev.yml + + play #1 (web:&development): WordPress Server: Install LEMP Stack with PHP and MariaDB MySQL TAGS: [] + TASK TAGS: [common, composer, dotenv, fail2ban, ferm, letsencrypt, logrotate, mail, mailhog, mailpit, mariadb, memcached, nginx, nginx-includes, nginx-sites, ntp, php, sshd, wordpress, wordpress-install, wordpress-install-directories, wordpress-setup, wordpress-setup-database, wordpress-setup-nginx, wordpress-setup-nginx-client-cert, wordpress-setup-self-signed-certificate, wp-cli, xdebug] + +``` +*/ +func ParseTags(output string) []string { + re := regexp.MustCompile(`TASK TAGS:\s*\[([^\]]*)\]`) + match := re.FindStringSubmatch(output) + + if len(match) < 2 { + return []string{} + } + + // Split by comma and trim each tag + rawTags := strings.Split(match[1], ",") + var tags []string + + for _, tag := range rawTags { + trimmed := strings.TrimSpace(tag) + + if trimmed != "" { + tags = append(tags, trimmed) + } + } + + sort.Strings(tags) + return tags +}