Skip to content

Commit 3e3c052

Browse files
committed
Add interactive mode to provision
Uses fzf to interactively let you select tags to be used for the provision.
1 parent efc1bcb commit 3e3c052

File tree

5 files changed

+223
-18
lines changed

5 files changed

+223
-18
lines changed

cmd/provision.go

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"flag"
6+
"fmt"
57
"os"
8+
"os/exec"
69
"strings"
710

811
"github.com/hashicorp/cli"
@@ -19,12 +22,14 @@ func NewProvisionCommand(ui cli.Ui, trellis *trellis.Trellis) *ProvisionCommand
1922
}
2023

2124
type ProvisionCommand struct {
22-
UI cli.Ui
23-
flags *flag.FlagSet
24-
extraVars string
25-
tags string
26-
Trellis *trellis.Trellis
27-
verbose bool
25+
UI cli.Ui
26+
flags *flag.FlagSet
27+
extraVars string
28+
interactive bool
29+
playbookName string
30+
tags string
31+
Trellis *trellis.Trellis
32+
verbose bool
2833
}
2934

3035
func (c *ProvisionCommand) init() {
@@ -33,6 +38,8 @@ func (c *ProvisionCommand) init() {
3338
c.flags.StringVar(&c.extraVars, "extra-vars", "", "Additional variables which are passed through to Ansible as 'extra-vars'")
3439
c.flags.StringVar(&c.tags, "tags", "", "only run roles and tasks tagged with these values")
3540
c.flags.BoolVar(&c.verbose, "verbose", false, "Enable Ansible's verbose mode")
41+
c.flags.BoolVar(&c.interactive, "interactive", false, "Enable interactive mode to select tags to provision")
42+
c.flags.BoolVar(&c.interactive, "i", false, "Enable interactive mode to select tags to provision")
3643
}
3744

3845
func (c *ProvisionCommand) Run(args []string) int {
@@ -65,11 +72,15 @@ func (c *ProvisionCommand) Run(args []string) int {
6572
return 1
6673
}
6774

68-
galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis}
69-
galaxyInstallCommand.Run([]string{})
75+
c.playbookName = "server.yml"
76+
77+
if environment == "development" {
78+
c.playbookName = "dev.yml"
79+
os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false")
80+
}
7081

7182
playbook := ansible.Playbook{
72-
Name: "server.yml",
83+
Name: c.playbookName,
7384
Env: environment,
7485
Verbose: c.verbose,
7586
}
@@ -79,15 +90,43 @@ func (c *ProvisionCommand) Run(args []string) int {
7990
}
8091

8192
if c.tags != "" {
93+
if c.interactive {
94+
c.UI.Error("Error: --interactive and --tags cannot be used together. Please use one or the other.")
95+
return 1
96+
}
97+
8298
playbook.AddArg("--tags", c.tags)
8399
}
84100

101+
if c.interactive {
102+
_, err := exec.LookPath("fzf")
103+
if err != nil {
104+
c.UI.Error("Error: `fzf` command found. fzf is required to use interactive mode.")
105+
return 1
106+
}
107+
108+
tags, err := c.getTags()
109+
if err != nil {
110+
c.UI.Error(err.Error())
111+
return 1
112+
}
113+
114+
selectedTags, err := c.selectedTagsFromFzf(tags)
115+
if err != nil {
116+
c.UI.Error(err.Error())
117+
return 1
118+
}
119+
120+
playbook.AddArg("--tags", strings.Join(selectedTags, ","))
121+
}
122+
85123
if environment == "development" {
86-
os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false")
87-
playbook.SetName("dev.yml")
88124
playbook.SetInventory(findDevInventory(c.Trellis, c.UI))
89125
}
90126

127+
galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis}
128+
galaxyInstallCommand.Run([]string{})
129+
91130
provision := command.WithOptions(
92131
command.WithUiOutput(c.UI),
93132
command.WithLogging(c.UI),
@@ -130,11 +169,16 @@ Provision and provide extra vars to Ansible:
130169
131170
$ trellis provision --extra-vars key=value production
132171
172+
Provision using interactive mode to select tags:
173+
174+
$ trellis provision -i production
175+
133176
Arguments:
134177
ENVIRONMENT Name of environment (ie: production)
135178
136179
Options:
137180
--extra-vars (multiple) Set additional variables as key=value or YAML/JSON, if filename prepend with @
181+
-i, --interactive Enter interactive mode to select tags to provision (requires fzf)
138182
--tags (multiple) Only run roles and tasks tagged with these values
139183
--verbose Enable Ansible's verbose mode
140184
-h, --help Show this help
@@ -149,8 +193,65 @@ func (c *ProvisionCommand) AutocompleteArgs() complete.Predictor {
149193

150194
func (c *ProvisionCommand) AutocompleteFlags() complete.Flags {
151195
return complete.Flags{
152-
"--extra-vars": complete.PredictNothing,
153-
"--tags": complete.PredictNothing,
154-
"--verbose": complete.PredictNothing,
196+
"-i": complete.PredictNothing,
197+
"--interactive": complete.PredictNothing,
198+
"--extra-vars": complete.PredictNothing,
199+
"--tags": complete.PredictNothing,
200+
"--verbose": complete.PredictNothing,
155201
}
156202
}
203+
204+
func (c *ProvisionCommand) getTags() ([]string, error) {
205+
tagsPlaybook := ansible.Playbook{
206+
Name: c.playbookName,
207+
Env: c.flags.Arg(0),
208+
Args: []string{"--list-tags"},
209+
}
210+
211+
tagsProvision := command.WithOptions(
212+
command.WithUiOutput(c.UI),
213+
).Cmd("ansible-playbook", tagsPlaybook.CmdArgs())
214+
215+
output := &bytes.Buffer{}
216+
tagsProvision.Stdout = output
217+
218+
if err := tagsProvision.Run(); err != nil {
219+
return nil, err
220+
}
221+
222+
tags := ansible.ParseTags(output.String())
223+
224+
return tags, nil
225+
}
226+
227+
func (c *ProvisionCommand) selectedTagsFromFzf(tags []string) ([]string, error) {
228+
output := &bytes.Buffer{}
229+
input := strings.NewReader(strings.Join(tags, "\n"))
230+
231+
previewCmd := fmt.Sprintf("trellis exec ansible-playbook %s --list-tasks --tags {}", c.playbookName)
232+
233+
fzf := command.WithOptions(command.WithTermOutput()).Cmd(
234+
"fzf",
235+
[]string{
236+
"-m",
237+
"--height", "50%",
238+
"--reverse",
239+
"--border",
240+
"--border-label", "Select tags to provision (use TAB to select multiple tags)",
241+
"--border-label-pos", "5",
242+
"--preview", previewCmd,
243+
"--preview-label", "Tasks for tag",
244+
},
245+
)
246+
fzf.Stdin = input
247+
fzf.Stdout = output
248+
249+
err := fzf.Run()
250+
if err != nil {
251+
return nil, err
252+
}
253+
254+
selectedTags := strings.Split(strings.TrimSpace(output.String()), "\n")
255+
256+
return selectedTags, nil
257+
}

cmd/provision_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ func TestProvisionRunValidations(t *testing.T) {
4646
"Error: foo is not a valid environment",
4747
1,
4848
},
49+
{
50+
"invalid_env",
51+
true,
52+
[]string{"--tags", "users", "-i", "development"},
53+
"Error: --interactive and --tags cannot be used together. Please use one or the other.",
54+
1,
55+
},
4956
}
5057

5158
for _, tc := range cases {
@@ -69,6 +76,33 @@ func TestProvisionRunValidations(t *testing.T) {
6976
}
7077
}
7178

79+
func TestProvisionInteractiveWithoutFzf(t *testing.T) {
80+
defer trellis.LoadFixtureProject(t)()
81+
trellis := trellis.NewTrellis()
82+
83+
ui := cli.NewMockUi()
84+
defer MockUiExec(t, ui)()
85+
86+
// Clear PATH to ensure fzf is not found
87+
t.Setenv("PATH", "")
88+
89+
provisionCommand := NewProvisionCommand(ui, trellis)
90+
91+
code := provisionCommand.Run([]string{"--interactive", "development"})
92+
93+
expectedCode := 1
94+
95+
if code != expectedCode {
96+
t.Errorf("expected code %d to be %d", code, expectedCode)
97+
}
98+
99+
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
100+
expectedOut := "Error: `fzf` command found. fzf is required to use interactive mode."
101+
if !strings.Contains(combined, expectedOut) {
102+
t.Errorf("expected output %q to contain %q", combined, expectedOut)
103+
}
104+
}
105+
72106
func TestProvisionRun(t *testing.T) {
73107
defer trellis.LoadFixtureProject(t)()
74108
trellis := trellis.NewTrellis()

pkg/ansible/playbook.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ type Playbook struct {
1010
Env string
1111
Verbose bool
1212
ExtraVars map[string]string
13-
args []string
13+
Args []string
1414
}
1515

1616
func (p *Playbook) AddArg(name string, value string) *Playbook {
17-
p.args = append(p.args, name+"="+value)
17+
p.Args = append(p.Args, name+"="+value)
1818
return p
1919
}
2020

@@ -28,7 +28,7 @@ func (p *Playbook) AddExtraVar(name string, value string) *Playbook {
2828
}
2929

3030
func (p *Playbook) AddExtraVars(extraVars string) *Playbook {
31-
p.args = append(p.args, fmt.Sprintf("-e %s", extraVars))
31+
p.Args = append(p.Args, fmt.Sprintf("-e %s", extraVars))
3232
return p
3333
}
3434

@@ -52,7 +52,7 @@ func (p *Playbook) CmdArgs() []string {
5252
args = append(args, "-vvvv")
5353
}
5454

55-
args = append(args, p.args...)
55+
args = append(args, p.Args...)
5656

5757
if p.Env != "" {
5858
p.AddExtraVar("env", p.Env)

pkg/ansible/tags.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package ansible
2+
3+
import (
4+
"regexp"
5+
"sort"
6+
"strings"
7+
)
8+
9+
/*
10+
Parse output from ansible-playbook --list-tags
11+
12+
Example output:
13+
14+
```
15+
playbook: dev.yml
16+
17+
play #1 (web:&development): WordPress Server: Install LEMP Stack with PHP and MariaDB MySQL TAGS: []
18+
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]
19+
20+
```
21+
*/
22+
func ParseTags(output string) []string {
23+
re := regexp.MustCompile(`TASK TAGS:\s*\[([^\]]*)\]`)
24+
match := re.FindStringSubmatch(output)
25+
26+
if len(match) < 2 {
27+
return []string{}
28+
}
29+
30+
// Split by comma and trim each tag
31+
rawTags := strings.Split(match[1], ",")
32+
var tags []string
33+
34+
for _, tag := range rawTags {
35+
trimmed := strings.TrimSpace(tag)
36+
37+
if trimmed != "" {
38+
tags = append(tags, trimmed)
39+
}
40+
}
41+
42+
sort.Strings(tags)
43+
return tags
44+
}

pkg/ansible/tags_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ansible
2+
3+
import (
4+
"slices"
5+
"testing"
6+
)
7+
8+
func TestParseTags(t *testing.T) {
9+
tagsOutput := `
10+
playbook: dev.yml
11+
12+
play #1 (web:&development): WordPress Server: Install LEMP Stack with PHP and MariaDB MySQL TAGS: []
13+
TASK TAGS: [common, composer]
14+
`
15+
16+
expectedTags := []string{
17+
"common",
18+
"composer",
19+
}
20+
21+
tags := ParseTags(tagsOutput)
22+
23+
if !slices.Equal(tags, expectedTags) {
24+
t.Errorf("ParseTags() = %v, want %v", tags, expectedTags)
25+
}
26+
}

0 commit comments

Comments
 (0)