diff --git a/cmd/provision.go b/cmd/provision.go index 8b976e5b..7302d5df 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/hashicorp/cli" @@ -19,13 +22,15 @@ func NewProvisionCommand(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand } type ProvisionCommand struct { - UI cli.Ui - flags *flag.FlagSet - extraVars string - tags string - skipTags string - Trellis *trellis.Trellis - verbose bool + UI cli.Ui + flags *flag.FlagSet + extraVars string + interactive bool + playbookName string + tags string + skipTags string + Trellis *trellis.Trellis + verbose bool } func (c *ProvisionCommand) init() { @@ -35,6 +40,8 @@ func (c *ProvisionCommand) init() { c.flags.StringVar(&c.tags, "tags", "", "only run roles and tasks tagged with these values") c.flags.StringVar(&c.skipTags, "skip-tags", "", "skip 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 { @@ -70,8 +77,10 @@ func (c *ProvisionCommand) Run(args []string) int { galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis} galaxyInstallCommand.Run([]string{}) + c.playbookName = "server.yml" + playbook := ansible.Playbook{ - Name: "server.yml", + Name: c.playbookName, Env: environment, Verbose: c.verbose, } @@ -81,6 +90,11 @@ 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.") + return 1 + } + playbook.AddArg("--tags", c.tags) } @@ -88,9 +102,32 @@ func (c *ProvisionCommand) Run(args []string) int { playbook.AddArg("--skip-tags", c.skipTags) } + if c.interactive { + _, err := exec.LookPath("fzf") + if err != nil { + c.UI.Error("No `fzf` command found. fzf is required to use interactive mode.") + return 1 + } + + 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") + c.playbookName = "dev.yml" playbook.SetInventory(c.Trellis.VmInventoryPath()) } @@ -136,11 +173,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) --skip-tags (multiple) Skip roles and tasks tagged with these values --tags (multiple) Only run roles and tasks tagged with these values --verbose Enable Ansible's verbose mode @@ -156,9 +198,66 @@ func (c *ProvisionCommand) AutocompleteArgs() complete.Predictor { func (c *ProvisionCommand) AutocompleteFlags() complete.Flags { return complete.Flags{ - "--extra-vars": complete.PredictNothing, - "--skip-tags": complete.PredictNothing, - "--tags": complete.PredictNothing, - "--verbose": complete.PredictNothing, + "-i": complete.PredictNothing, + "--interactive": complete.PredictNothing, + "--extra-vars": complete.PredictNothing, + "--skip-tags": 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 161afd9e..886ecee5 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 +}