Skip to content

Commit f94bc3f

Browse files
committed
feat: Parse and prompt to re-use previous configuration
1 parent d7838b5 commit f94bc3f

File tree

1 file changed

+132
-40
lines changed

1 file changed

+132
-40
lines changed

cli/configssh.go

Lines changed: 132 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"bufio"
45
"bytes"
56
"errors"
67
"fmt"
@@ -33,9 +34,9 @@ const (
3334
sshCoderConfigDocsHeader = `
3435
#
3536
# You should not hand-edit this file, all changes will be lost upon workspace
36-
# creation, deletion or when running "coder config-ssh".
37-
`
38-
sshCoderConfigOptionsHeader = `#
37+
# creation, deletion or when running "coder config-ssh".`
38+
sshCoderConfigOptionsHeader = `
39+
#
3940
# Last config-ssh options:
4041
`
4142
// Relative paths are assumed to be in ~/.ssh, except when
@@ -53,11 +54,50 @@ var (
5354
sshCoderIncludedRe = regexp.MustCompile(`^\s*((?i)Include) coder(\s|$)`)
5455
)
5556

57+
// sshCoderConfigOptions represents options that can be stored and read
58+
// from the coder config in ~/.ssh/coder.
59+
type sshCoderConfigOptions struct {
60+
sshConfigFile string
61+
sshOptions []string
62+
}
63+
64+
func (o sshCoderConfigOptions) isZero() bool {
65+
return o.sshConfigFile == sshDefaultConfigFileName && len(o.sshOptions) == 0
66+
}
67+
68+
func (o sshCoderConfigOptions) equal(other sshCoderConfigOptions) bool {
69+
// Compare without side-effects or regard to order.
70+
opt1 := slices.Clone(o.sshOptions)
71+
sort.Strings(opt1)
72+
opt2 := slices.Clone(other.sshOptions)
73+
sort.Strings(opt2)
74+
return o.sshConfigFile == other.sshConfigFile && slices.Equal(opt1, opt2)
75+
}
76+
77+
func (o sshCoderConfigOptions) asArgs() (args []string) {
78+
if o.sshConfigFile != sshDefaultConfigFileName {
79+
args = append(args, "--ssh-config-file", o.sshConfigFile)
80+
}
81+
for _, opt := range o.sshOptions {
82+
args = append(args, "--ssh-option", fmt.Sprintf("%q", opt))
83+
}
84+
return args
85+
}
86+
87+
func (o sshCoderConfigOptions) asList() (list []string) {
88+
if o.sshConfigFile != sshDefaultConfigFileName {
89+
list = append(list, fmt.Sprintf("ssh-config-file: %s", o.sshConfigFile))
90+
}
91+
for _, opt := range o.sshOptions {
92+
list = append(list, fmt.Sprintf("ssh-option: %s", opt))
93+
}
94+
return list
95+
}
96+
5697
func configSSH() *cobra.Command {
5798
var (
58-
sshConfigFile string
99+
coderConfig sshCoderConfigOptions
59100
coderConfigFile string
60-
sshOptions []string
61101
showDiff bool
62102
skipProxyCommand bool
63103

@@ -97,30 +137,26 @@ func configSSH() *cobra.Command {
97137
return err
98138
}
99139

140+
out := cmd.OutOrStdout()
141+
if showDiff {
142+
out = cmd.OutOrStderr()
143+
}
144+
binaryFile, err := currentBinPath(out)
145+
if err != nil {
146+
return err
147+
}
148+
100149
dirname, err := os.UserHomeDir()
101150
if err != nil {
102151
return xerrors.Errorf("user home dir failed: %w", err)
103152
}
104153

105-
sshConfigFileOrig := sshConfigFile // Store the pre ~/ replacement name for serializing options.
154+
sshConfigFile := coderConfig.sshConfigFile // Store the pre ~/ replacement name for serializing options.
106155
if strings.HasPrefix(sshConfigFile, "~/") {
107156
sshConfigFile = filepath.Join(dirname, sshConfigFile[2:])
108157
}
109-
coderConfigFileOrig := coderConfigFile
110158
coderConfigFile = filepath.Join(dirname, coderConfigFile[2:]) // Replace ~/ with home dir.
111159

112-
// TODO(mafredri): Check coderConfigFile for previous options
113-
// coderConfigFile.
114-
115-
out := cmd.OutOrStdout()
116-
if showDiff {
117-
out = cmd.OutOrStderr()
118-
}
119-
binaryFile, err := currentBinPath(out)
120-
if err != nil {
121-
return err
122-
}
123-
124160
// Only allow not-exist errors to avoid trashing
125161
// the users SSH config.
126162
configRaw, err := os.ReadFile(sshConfigFile)
@@ -137,6 +173,25 @@ func configSSH() *cobra.Command {
137173
return xerrors.Errorf("unexpected content in %s: remove the file and rerun the command to continue", coderConfigFile)
138174
}
139175
}
176+
lastCoderConfig := sshCoderConfigParseLastOptions(bytes.NewReader(coderConfigRaw))
177+
178+
// Only prompt when no arguments are provided and avoid
179+
// prompting in diff mode (unexpected behavior).
180+
if !showDiff && coderConfig.isZero() && !lastCoderConfig.isZero() {
181+
line, err := cliui.Prompt(cmd, cliui.PromptOptions{
182+
Text: fmt.Sprintf("Found previous configuration option(s):\n\n - %s\n\n Use previous option(s)?", strings.Join(lastCoderConfig.asList(), "\n - ")),
183+
IsConfirm: true,
184+
})
185+
if err != nil {
186+
// TODO(mafredri): Better way to differ between "no" and Ctrl+C?
187+
if line == "" && xerrors.Is(err, cliui.Canceled) {
188+
return nil
189+
}
190+
} else {
191+
coderConfig = lastCoderConfig
192+
}
193+
_, _ = fmt.Fprint(out, "\n")
194+
}
140195

141196
// Keep track of changes we are making.
142197
var changes []string
@@ -145,14 +200,14 @@ func configSSH() *cobra.Command {
145200
// remove if present.
146201
configModified, ok := stripOldConfigBlock(configRaw)
147202
if ok {
148-
changes = append(changes, fmt.Sprintf("Remove old config block (START-CODER/END-CODER) from %s", sshConfigFileOrig))
203+
changes = append(changes, fmt.Sprintf("Remove old config block (START-CODER/END-CODER) from %s", sshConfigFile))
149204
}
150205

151206
// Check for the presence of the coder Include
152207
// statement is present and add if missing.
153208
configModified, ok = sshConfigAddCoderInclude(configModified)
154209
if ok {
155-
changes = append(changes, fmt.Sprintf("Add %q to %s", "Include coder", sshConfigFileOrig))
210+
changes = append(changes, fmt.Sprintf("Add %q to %s", "Include coder", sshConfigFile))
156211
}
157212

158213
root := createConfig(cmd)
@@ -195,19 +250,13 @@ func configSSH() *cobra.Command {
195250
}
196251

197252
buf := &bytes.Buffer{}
198-
_, _ = buf.WriteString(sshCoderConfigHeader)
199-
_, _ = buf.WriteString(sshCoderConfigDocsHeader)
200-
201-
// Store the provided flags as part of the
202-
// config for future (re)use.
203-
_, _ = buf.WriteString(sshCoderConfigOptionsHeader)
204-
if sshConfigFileOrig != sshDefaultConfigFileName {
205-
_, _ = fmt.Fprintf(buf, "# :%s=%s\n", "ssh-config-file", sshConfigFileOrig)
206-
}
207-
for _, opt := range sshOptions {
208-
_, _ = fmt.Fprintf(buf, "# :%s=%s\n", "ssh-option", opt)
253+
254+
// Write header and store the provided options as part
255+
// of the config for future (re)use.
256+
err = sshCoderConfigWriteHeader(buf, coderConfig)
257+
if err != nil {
258+
return xerrors.Errorf("write coder config header failed: %w", err)
209259
}
210-
_, _ = buf.WriteString("#\n")
211260

212261
// Ensure stable sorting of output.
213262
slices.SortFunc(workspaceConfigs, func(a, b workspaceConfig) bool {
@@ -220,7 +269,7 @@ func configSSH() *cobra.Command {
220269
configOptions := []string{
221270
"Host coder." + hostname,
222271
}
223-
for _, option := range sshOptions {
272+
for _, option := range coderConfig.sshOptions {
224273
configOptions = append(configOptions, "\t"+option)
225274
}
226275
configOptions = append(configOptions,
@@ -246,9 +295,9 @@ func configSSH() *cobra.Command {
246295
modifyCoderConfig := !bytes.Equal(coderConfigRaw, buf.Bytes())
247296
if modifyCoderConfig {
248297
if len(coderConfigRaw) == 0 {
249-
changes = append(changes, fmt.Sprintf("Write auto-generated coder config file to %s", coderConfigFileOrig))
298+
changes = append(changes, fmt.Sprintf("Write auto-generated coder config file to %s", coderConfigFile))
250299
} else {
251-
changes = append(changes, fmt.Sprintf("Update auto-generated coder config file in %s", coderConfigFileOrig))
300+
changes = append(changes, fmt.Sprintf("Update auto-generated coder config file in %s", coderConfigFile))
252301
}
253302
}
254303

@@ -257,7 +306,7 @@ func configSSH() *cobra.Command {
257306
// Write to stderr to avoid dirtying the diff output.
258307
_, _ = fmt.Fprint(out, "Changes:\n\n")
259308
for _, change := range changes {
260-
_, _ = fmt.Fprintf(out, "* %s\n", change)
309+
_, _ = fmt.Fprintf(out, " * %s\n", change)
261310
}
262311
}
263312

@@ -281,8 +330,11 @@ func configSSH() *cobra.Command {
281330
}
282331

283332
if len(changes) > 0 {
333+
// In diff mode we don't prompt re-using the previous
334+
// configuration, so we output the entire command.
335+
diffCommand := fmt.Sprintf("$ %s %s", cmd.CommandPath(), strings.Join(append(coderConfig.asArgs(), "--diff"), " "))
284336
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
285-
Text: fmt.Sprintf("The following changes will be made to your SSH configuration (use --diff to see changes):\n\n * %s\n\n Continue?", strings.Join(changes, "\n * ")),
337+
Text: fmt.Sprintf("The following changes will be made to your SSH configuration:\n\n * %s\n\n To see changes, run with --diff:\n\n %s\n\n Continue?", strings.Join(changes, "\n * "), diffCommand),
286338
IsConfirm: true,
287339
})
288340
if err != nil {
@@ -313,10 +365,10 @@ func configSSH() *cobra.Command {
313365
return nil
314366
},
315367
}
316-
cliflag.StringVarP(cmd.Flags(), &sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
368+
cliflag.StringVarP(cmd.Flags(), &coderConfig.sshConfigFile, "ssh-config-file", "", "CODER_SSH_CONFIG_FILE", sshDefaultConfigFileName, "Specifies the path to an SSH config.")
317369
cmd.Flags().StringVar(&coderConfigFile, "ssh-coder-config-file", sshDefaultCoderConfigFileName, "Specifies the path to an Coder SSH config file. Useful for testing.")
318370
_ = cmd.Flags().MarkHidden("ssh-coder-config-file")
319-
cmd.Flags().StringArrayVarP(&sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
371+
cmd.Flags().StringArrayVarP(&coderConfig.sshOptions, "ssh-option", "o", []string{}, "Specifies additional SSH options to embed in each host stanza.")
320372
cmd.Flags().BoolVarP(&showDiff, "diff", "D", false, "Show diff of changes that will be made.")
321373
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
322374
_ = cmd.Flags().MarkHidden("skip-proxy-command")
@@ -354,6 +406,46 @@ func sshConfigAddCoderInclude(data []byte) (modifiedData []byte, modified bool)
354406
return data, true
355407
}
356408

409+
func sshCoderConfigWriteHeader(w io.Writer, o sshCoderConfigOptions) error {
410+
_, _ = fmt.Fprint(w, sshCoderConfigHeader)
411+
_, _ = fmt.Fprint(w, sshCoderConfigDocsHeader)
412+
_, _ = fmt.Fprint(w, sshCoderConfigOptionsHeader)
413+
if o.sshConfigFile != sshDefaultConfigFileName {
414+
_, _ = fmt.Fprintf(w, "# :%s=%s\n", "ssh-config-file", o.sshConfigFile)
415+
}
416+
for _, opt := range o.sshOptions {
417+
_, _ = fmt.Fprintf(w, "# :%s=%s\n", "ssh-option", opt)
418+
}
419+
_, _ = fmt.Fprint(w, "#\n")
420+
return nil
421+
}
422+
423+
func sshCoderConfigParseLastOptions(r io.Reader) (o sshCoderConfigOptions) {
424+
o.sshConfigFile = sshDefaultConfigFileName // Default value is not written.
425+
426+
s := bufio.NewScanner(r)
427+
for s.Scan() {
428+
line := s.Text()
429+
if strings.HasPrefix(line, "# :") {
430+
line = strings.TrimPrefix(line, "# :")
431+
parts := strings.SplitN(line, "=", 2)
432+
switch parts[0] {
433+
case "ssh-config-file":
434+
o.sshConfigFile = parts[1]
435+
case "ssh-option":
436+
o.sshOptions = append(o.sshOptions, parts[1])
437+
default:
438+
// Unknown option, ignore.
439+
}
440+
}
441+
}
442+
if err := s.Err(); err != nil {
443+
panic(err)
444+
}
445+
446+
return o
447+
}
448+
357449
// writeWithTempFileAndMove writes to a temporary file in the same
358450
// directory as path and renames the temp file to the file provided in
359451
// path. This ensure we avoid trashing the file we are writing due to

0 commit comments

Comments
 (0)