Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
623 changes: 623 additions & 0 deletions commands/init.go

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions commands/init_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package commands

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// stubAsker drives the init flow from a pre recorded script for
// deterministic tests.
type stubAsker struct {
t *testing.T
selects []string
inputs []string
secrets []string
multilines []string
confirm []bool
}

func (stub *stubAsker) Select(_, _ string, options []string, _ string) (string, error) {
if len(stub.selects) == 0 {
stub.t.Fatalf("unexpected Select (options %v)", options)
}
value := stub.selects[0]
stub.selects = stub.selects[1:]
return value, nil
}

func (stub *stubAsker) Input(_, _, defaultValue string) (string, error) {
if len(stub.inputs) == 0 {
return defaultValue, nil
}
value := stub.inputs[0]
stub.inputs = stub.inputs[1:]
if value == "" {
return defaultValue, nil
}
return value, nil
}

func (stub *stubAsker) Secret(_, _ string) (string, error) {
if len(stub.secrets) == 0 {
stub.t.Fatalf("unexpected Secret call")
}
value := stub.secrets[0]
stub.secrets = stub.secrets[1:]
return value, nil
}

func (stub *stubAsker) Multiline(_, _ string) (string, error) {
if len(stub.multilines) == 0 {
stub.t.Fatalf("unexpected Multiline call")
}
value := stub.multilines[0]
stub.multilines = stub.multilines[1:]
return value, nil
}

func (stub *stubAsker) Confirm(_ string, _ bool) (bool, error) {
if len(stub.confirm) == 0 {
stub.t.Fatalf("unexpected Confirm call")
}
value := stub.confirm[0]
stub.confirm = stub.confirm[1:]
return value, nil
}

// TestRunInit_NoneBindFlow walks the full init flow using only the built
// in providers (NONE registrar + BIND DNS). It asserts the generated
// creds.json and dnsconfig.js parse cleanly.
func TestRunInit_NoneBindFlow(t *testing.T) {
dir := t.TempDir()

stub := &stubAsker{
t: t,
selects: []string{
"BIND", // DNS provider (asked first)
"NONE", // registrar
},
inputs: []string{
"", // BIND: directory (accept default)
"", // BIND: filenameformat (accept default)
"bind", // BIND: entry name
"", // NONE: entry name (accept default "none")
"example.com", // first domain
},
confirm: []bool{
false, // "Add another domain?"
true, // Write these files?
false, // Compare domains with zones at provider?
false, // Run preview now?
},
}

// Replace the subprocess seam so the test does not actually exec the
// dnscontrol binary for `preview` or `get-zones`.
origRun := runSubcommand
runSubcommand = func(*exec.Cmd) error { return nil }
t.Cleanup(func() { runSubcommand = origRun })

args := InitArgs{
CredsFile: filepath.Join(dir, "creds.json"),
ConfigFile: filepath.Join(dir, "dnsconfig.js"),
}
if err := runInit(args, stub); err != nil {
t.Fatalf("runInit: %v", err)
}

credsBytes, err := os.ReadFile(args.CredsFile)
if err != nil {
t.Fatalf("read creds: %v", err)
}
if !strings.Contains(string(credsBytes), `"bind"`) {
t.Errorf("creds.json missing bind entry: %s", credsBytes)
}
if !strings.Contains(string(credsBytes), `"TYPE": "BIND"`) {
t.Errorf("creds.json missing BIND TYPE: %s", credsBytes)
}

configBytes, err := os.ReadFile(args.ConfigFile)
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(configBytes), `NewDnsProvider("bind")`) {
t.Errorf("config missing bind provider: %s", configBytes)
}
if !strings.Contains(string(configBytes), `D("example.com"`) {
t.Errorf("config missing example.com domain: %s", configBytes)
}
}
223 changes: 223 additions & 0 deletions commands/init_prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package commands

import (
"errors"
"fmt"
"os"
"slices"
"strings"

"github.com/AlecAivazis/survey/v2"
"github.com/DNSControl/dnscontrol/v4/pkg/providers"
)

// Asker is the minimal set of interactive prompts used by `dnscontrol
// init`. Tests provide a stub implementation to drive the flow
// deterministically.
type Asker interface {
// Select asks the user to pick one value from a list. The default is
// suggested and returned when the user accepts without typing.
Select(message, help string, options []string, defaultOption string) (string, error)
// Input asks for a free form string.
Input(message, help, defaultValue string) (string, error)
// Secret asks for a string and masks the input.
Secret(message, help string) (string, error)
// Multiline opens an external editor so the user can enter a value
// that contains newlines (for example a PEM encoded private key).
Multiline(message, help string) (string, error)
// Confirm asks a yes/no question.
Confirm(message string, defaultValue bool) (bool, error)
}

// surveyAsker is the default Asker backed by github.com/AlecAivazis/survey/v2.
type surveyAsker struct{}

// Select implements Asker.
func (surveyAsker) Select(message, help string, options []string, defaultOption string) (string, error) {
var answer string
prompt := &survey.Select{
Message: message,
Options: options,
Help: help,
PageSize: 15,
}
// Survey rejects a Default that is not one of the options. Only set
// it when it matches, otherwise fall back to the first option.
if slices.Contains(options, defaultOption) {
prompt.Default = defaultOption
}
if err := survey.AskOne(prompt, &answer); err != nil {
return "", err
}
return answer, nil
}

// Input implements Asker.
func (surveyAsker) Input(message, help, defaultValue string) (string, error) {
var answer string
prompt := &survey.Input{
Message: message,
Default: defaultValue,
Help: help,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return "", err
}
return answer, nil
}

// Secret implements Asker.
func (surveyAsker) Secret(message, help string) (string, error) {
var answer string
prompt := &survey.Password{Message: message, Help: help}
if err := survey.AskOne(prompt, &answer); err != nil {
return "", err
}
return answer, nil
}

// Multiline implements Asker.
func (surveyAsker) Multiline(message, help string) (string, error) {
var answer string
prompt := &survey.Editor{
Message: message,
Help: help,
HideDefault: true,
AppendDefault: true,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return "", err
}
return answer, nil
}

// Confirm implements Asker.
func (surveyAsker) Confirm(message string, defaultValue bool) (bool, error) {
var answer bool
prompt := &survey.Confirm{Message: message, Default: defaultValue}
if err := survey.AskOne(prompt, &answer); err != nil {
return false, err
}
return answer, nil
}

// askField prompts for a single CredsField and returns the value the user
// entered, respecting Required, Secret, Default and Choices.
func askField(asker Asker, field providers.CredsField) (string, error) {
defaultValue := field.Default
if field.EnvVar != "" {
if envValue := os.Getenv(field.EnvVar); envValue != "" {
defaultValue = envValue
}
}

label := field.Label
if label == "" {
label = field.Key
}
if field.Required {
label += " (required)"
} else {
label += " (optional)"
}

for {
var (
value string
err error
)
switch {
case len(field.Choices) > 0:
value, err = asker.Select(label, field.Help, field.Choices, defaultValue)
case field.Multiline:
value, err = asker.Multiline(label, field.Help)
case field.Secret:
value, err = asker.Secret(label, field.Help)
default:
value, err = asker.Input(label, field.Help, defaultValue)
}
if err != nil {
return "", err
}
value = strings.TrimSpace(value)
if value == "" && field.Required {
fmt.Fprintln(os.Stderr, "A value is required.")
continue
}
if field.Validator != nil && value != "" {
if err := field.Validator(value); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
continue
}
}
return value, nil
}
}

// openPortalHint prints the portal URL plus any provider notes so the
// user can open the link themselves before answering the credential
// prompts.
func openPortalHint(_ Asker, meta providers.CredsMetadata) error {
if meta.PortalURL == "" && meta.Notes == "" {
return nil
}
fmt.Println()
if meta.PortalURL != "" {
fmt.Printf("API settings for %s: %s\n", displayName(meta.TypeName), meta.PortalURL)
}
if meta.Notes != "" {
fmt.Println(meta.Notes)
}
return nil
}

// collectFields runs askField for every field defined in meta and returns
// the resulting key/value map. Fields whose ShowIf condition does not
// match are skipped. Internal fields are not written to the output.
// Empty optional answers are dropped.
func collectFields(asker Asker, meta providers.CredsMetadata) (map[string]string, error) {
answers := map[string]string{}
output := map[string]string{}
for _, field := range meta.Fields {
if !showField(field, answers) {
continue
}
value, err := askField(asker, field)
if err != nil {
return nil, err
}
answers[field.Key] = value
if field.Internal {
continue
}
if value == "" && !field.Required {
continue
}
output[field.Key] = value
}
return output, nil
}

// showField evaluates the ShowIf map against the already collected
// answers.
func showField(field providers.CredsField, answers map[string]string) bool {
for key, want := range field.ShowIf {
if answers[key] != want {
return false
}
}
return true
}

// displayName returns the human friendly DisplayName registered for the
// given provider type, falling back to the type name itself when no
// metadata or DisplayName is registered.
func displayName(typeName string) string {
if meta, ok := providers.GetCredsMetadata(typeName); ok && meta.DisplayName != "" {
return meta.DisplayName
}
return typeName
}

// errInitAborted is returned when the user aborts the init flow.
var errInitAborted = errors.New("init aborted by user")
Loading
Loading