Skip to content
Merged
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
4 changes: 2 additions & 2 deletions commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func (c *ApplyCommand) AutocompleteFlags() complete.Flags {
c.Meta.AutocompleteFlags(command.FlagSetClient),
complete.Flags{
"--tasks": taskFileAutocomplete(),
"--tasks-format": tasksFormatAutocomplete(),
"--tasks-format": recipeFormatAutocomplete(),
"--verbose": complete.PredictNothing,
"--json": complete.PredictNothing,
"--host": complete.PredictAnything,
Expand Down Expand Up @@ -173,7 +173,7 @@ func (c *ApplyCommand) Run(args []string) int {

resolvedHost := resolveSshFlags(c.host, c.sudo, c.acceptNewHostKeys)

formatOverride, err := parseTasksFormatFlag(c.tasksFormatFlag)
formatOverride, err := parseRecipeFormatFlag("--tasks-format", c.tasksFormatFlag)
if err != nil {
c.Ui.Error(err.Error())
return 1
Expand Down
46 changes: 38 additions & 8 deletions commands/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type ExportCommand struct {

output string
varsOutput string
// formatFlag is the raw --format value; it is normalised by
// parseRecipeFormatFlag in Run and then governs both the recipe and
// the companion vars-file, overriding either output's extension.
formatFlag string
overwrite bool
redact bool
apps []string
Expand Down Expand Up @@ -52,6 +56,7 @@ func (c *ExportCommand) Examples() map[string]string {
"Export the local server to tasks.yml + tasks.vars.yml": fmt.Sprintf("%s %s", appName, c.Name()),
"Export a remote server over SSH": fmt.Sprintf("%s %s --host deploy@dokku.example.com", appName, c.Name()),
"Stream a self-contained recipe to stdout": fmt.Sprintf("%s %s --output -", appName, c.Name()),
"Stream a JSON5 recipe to stdout": fmt.Sprintf("%s %s --output - --format json5", appName, c.Name()),
"Redact secrets into a fill-in-the-blanks vars-file": fmt.Sprintf("%s %s --redact", appName, c.Name()),
"Export only a single app": fmt.Sprintf("%s %s --app my-app", appName, c.Name()),
}
Expand All @@ -71,8 +76,9 @@ func (c *ExportCommand) ParsedArguments(args []string) (map[string]command.Argum

func (c *ExportCommand) FlagSet() *flag.FlagSet {
f := c.Meta.FlagSet(c.Name(), command.FlagSetClient)
f.StringVar(&c.output, "output", "tasks.yml", "path to write the recipe to; pass - to stream a self-contained recipe to stdout")
f.StringVar(&c.varsOutput, "vars-output", "", "path to write the companion vars-file to (defaults to <output-base>.vars.<ext>)")
f.StringVar(&c.output, "output", defaultRecipeOutput, "path to write the recipe to; pass - to stream a self-contained recipe to stdout")
f.StringVar(&c.formatFlag, "format", "", "write the recipe and vars-file as this format (yaml or json5) instead of inferring it from the --output extension. Without an explicit --output, json5 writes "+defaultRecipeOutputJSON5+"; this is also the only way to get JSON5 on stdout.")
f.StringVar(&c.varsOutput, "vars-output", "", "path to write the companion vars-file to (defaults to <output-base>.vars.<ext>; --format overrides its format)")
f.BoolVar(&c.overwrite, "overwrite", false, "overwrite existing output files without prompting")
f.BoolVar(&c.redact, "redact", false, "write placeholder values into the vars-file instead of real secrets")
f.StringArrayVar(&c.apps, "app", nil, "restrict the export to the named app (repeatable)")
Expand All @@ -87,6 +93,7 @@ func (c *ExportCommand) AutocompleteFlags() complete.Flags {
c.Meta.AutocompleteFlags(command.FlagSetClient),
complete.Flags{
"--output": taskFileAutocomplete(),
"--format": recipeFormatAutocomplete(),
"--vars-output": taskFileAutocomplete(),
"--overwrite": complete.PredictNothing,
"--redact": complete.PredictNothing,
Expand Down Expand Up @@ -114,12 +121,31 @@ func (c *ExportCommand) Run(args []string) int {
return 1
}

formatOverride, err := parseRecipeFormatFlag("--format", c.formatFlag)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

// Resolve the write target up front. --format json5 with no explicit
// --output moves the default to tasks.json (and, through
// deriveVarsOutput, tasks.vars.json), and every later use of c.output
// - the overwrite prompt, the write, the summary, the Next steps line
// - has to agree on one path. flags.Changed is only meaningful after
// flags.Parse. Validating here also means a typo'd --format fails
// before an SSH control master is opened or the server is read.
var recipeFormat string
c.output, recipeFormat = resolveRecipeOutput(c.output, formatOverride, flags.Changed("output"))
if msg := recipeOutputFormatMismatch(c.output, formatOverride); msg != "" {
c.Ui.Warn(msg)
}

resolvedHost := resolveSshFlags(c.host, c.sudo, c.acceptNewHostKeys)
if resolvedHost != "" {
defer subprocess.CloseSshControlMaster(resolvedHost)
}

toStdout := c.output == "-"
toStdout := c.output == taskFileStdin

res, err := tasks.ExportRecipe(tasks.ExportOptions{
Apps: c.apps,
Expand Down Expand Up @@ -147,10 +173,6 @@ func (c *ExportCommand) Run(args []string) int {
return 1
}

recipeFormat := taskFileFormatYAML
if !toStdout {
recipeFormat = detectTaskFileFormat(c.output)
}
recipeBytes, err := res.MarshalRecipe(recipeFormat)
if err != nil {
c.Ui.Error(fmt.Sprintf("marshal recipe: %v", err))
Expand All @@ -169,6 +191,14 @@ func (c *ExportCommand) Run(args []string) int {
if varsOutput == "" {
varsOutput = deriveVarsOutput(c.output)
}
// --format governs the pair: when it is given, the vars-file matches
// the recipe even if --vars-output names another extension. Without
// it the vars-file keeps following its own extension, so
// `--output tasks.yml --vars-output vars.json` still writes JSON.
varsFormat := formatOverride
if varsFormat == "" {
varsFormat = detectTaskFileFormat(varsOutput)
}
writeVars := res.HasVars()

// Overwrite check: both files are checked before either is written, so a
Expand Down Expand Up @@ -204,7 +234,7 @@ func (c *ExportCommand) Run(args []string) int {
return 1
}
if writeVars {
varsBytes, err := res.MarshalVars(detectTaskFileFormat(varsOutput))
varsBytes, err := res.MarshalVars(varsFormat)
if err != nil {
c.Ui.Error(fmt.Sprintf("marshal vars: %v", err))
return 1
Expand Down
251 changes: 251 additions & 0 deletions commands/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,257 @@ func TestExportOutputValidates(t *testing.T) {
}
}

// TestExportOutputValidatesJSON5 is the JSON5 twin of
// TestExportOutputValidates: the pair --format json5 emits must round-trip
// through docket's own offline validation just as the YAML pair does.
func TestExportOutputValidatesJSON5(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
t.Chdir(dir)
recipe := filepath.Join(dir, "tasks.json")
vars := filepath.Join(dir, "tasks.vars.json")

c, ui := newExportCommand()
if code := c.Run([]string{"--format", "json5"}); code != 0 {
t.Fatalf("export exit = %d: %s", code, ui.ErrorWriter.String())
}

valArgs := []string{"--tasks", recipe, "--vars-file", vars, "--strict"}
oldArgs := os.Args
os.Args = append([]string{"docket", "validate"}, valArgs...)
defer func() { os.Args = oldArgs }()

vui := cli.NewMockUi()
v := &ValidateCommand{Meta: command.Meta{Ui: vui}}
if code := v.Run(valArgs); code != 0 {
rb, _ := os.ReadFile(recipe)
vb, _ := os.ReadFile(vars)
t.Fatalf("docket validate --strict exit = %d, want 0\n--- validate stderr ---\n%s\n--- recipe ---\n%s\n--- vars ---\n%s",
code, vui.ErrorWriter.String(), rb, vb)
}
}

// TestExportCommandFormatJSON5WritesJSONPair pins the default-path swap
// for the pair of files export writes: tasks.json plus tasks.vars.json,
// and no .yml left behind.
func TestExportCommandFormatJSON5WritesJSONPair(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
t.Chdir(dir)

c, ui := newExportCommand()
if code := c.Run([]string{"--format", "json5"}); code != 0 {
t.Fatalf("Run exit = %d, want 0: %s", code, ui.ErrorWriter.String())
}

for _, name := range []string{"tasks.yml", "tasks.vars.yml"} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
t.Errorf("--format json5 should not write %s", name)
}
}

recipe, err := os.ReadFile(filepath.Join(dir, "tasks.json"))
if err != nil {
t.Fatalf("tasks.json not written: %v", err)
}
if !strings.HasPrefix(string(recipe), "[") {
t.Errorf("recipe should open with a JSON5 array:\n%s", recipe)
}
if !strings.Contains(string(recipe), "{{ .web_API_KEY }}") {
t.Errorf("recipe should reference the input:\n%s", recipe)
}

vars, err := os.ReadFile(filepath.Join(dir, "tasks.vars.json"))
if err != nil {
t.Fatalf("tasks.vars.json not written: %v", err)
}
if !strings.HasPrefix(string(vars), "{") {
t.Errorf("vars-file should be a JSON object:\n%s", vars)
}
if !strings.Contains(string(vars), `"web_API_KEY"`) || !strings.Contains(string(vars), "abc123") {
t.Errorf("vars-file should hold the real value under the input name:\n%s", vars)
}
// export always emits per-task "cannot read this back" warnings, so
// assert on the absence of the mismatch one specifically.
if warn := ui.ErrorWriter.String(); strings.Contains(warn, "does not match") {
t.Errorf("matching extensions should not warn about a mismatch:\n%s", warn)
}
}

// TestExportCommandFormatOverridesOutputExtension pins that --format wins
// over an explicit --output extension, and that the resulting file - whose
// name now lies about its contents - is warned about.
func TestExportCommandFormatOverridesOutputExtension(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
recipe := filepath.Join(dir, "tasks.yml")

c, ui := newExportCommand()
if code := c.Run([]string{"--output", recipe, "--format", "json5"}); code != 0 {
t.Fatalf("Run exit = %d, want 0: %s", code, ui.ErrorWriter.String())
}

body, err := os.ReadFile(recipe)
if err != nil {
t.Fatalf("recipe not written: %v", err)
}
if !strings.HasPrefix(string(body), "[") {
t.Errorf("--format json5 should have won over the .yml extension:\n%s", body)
}
if warn := ui.ErrorWriter.String(); !strings.Contains(warn, "--tasks-format json5") {
t.Errorf("a lying extension should warn how to read it back:\n%s", warn)
}
}

// TestExportCommandFormatGovernsVarsFile pins decision 3 of #410: an
// explicit --format sets the vars-file format too, even when
// --vars-output names a different extension.
func TestExportCommandFormatGovernsVarsFile(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
recipe := filepath.Join(dir, "tasks.json5")
vars := filepath.Join(dir, "tasks.vars.yml")

c, ui := newExportCommand()
if code := c.Run([]string{"--output", recipe, "--vars-output", vars, "--format", "json5"}); code != 0 {
t.Fatalf("Run exit = %d, want 0: %s", code, ui.ErrorWriter.String())
}

body, err := os.ReadFile(vars)
if err != nil {
t.Fatalf("vars-file not written: %v", err)
}
// A quoted key is the JSON tell; the YAML encoder emits web_API_KEY:
// unquoted. JSON is valid YAML, so this file still loads under its
// .yml name - which is why only the recipe earns a warning.
if !strings.HasPrefix(string(body), "{") || !strings.Contains(string(body), `"web_API_KEY":`) {
t.Errorf("--format json5 should have won over the .yml vars extension:\n%s", body)
}
}

// TestExportCommandVarsFileKeepsItsOwnExtensionWithoutFormat is the
// backwards-compatibility half: with no --format, the vars-file still
// follows its own --vars-output extension rather than the recipe's.
func TestExportCommandVarsFileKeepsItsOwnExtensionWithoutFormat(t *testing.T) {
tests := []struct {
name string
varsName string
wantPrefix string
}{
{name: "json vars beside a yaml recipe", varsName: "vars.json", wantPrefix: "{"},
{name: "yaml vars beside a yaml recipe", varsName: "vars.yml", wantPrefix: "web_API_KEY:"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
vars := filepath.Join(dir, tt.varsName)

c, ui := newExportCommand()
code := c.Run([]string{"--output", filepath.Join(dir, "tasks.yml"), "--vars-output", vars})
if code != 0 {
t.Fatalf("Run exit = %d, want 0: %s", code, ui.ErrorWriter.String())
}

body, err := os.ReadFile(vars)
if err != nil {
t.Fatalf("vars-file not written: %v", err)
}
if !strings.HasPrefix(string(body), tt.wantPrefix) {
t.Errorf("vars-file should follow its own extension, want prefix %q:\n%s", tt.wantPrefix, body)
}
})
}
}

// TestExportCommandFormatJSON5ToStdout is the round trip #410 was filed
// for. Streaming still inlines values, so there is no vars-file and
// nothing lands on disk.
func TestExportCommandFormatJSON5ToStdout(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
t.Chdir(dir)

var ui *cli.MockUi
captured, exit := captureStdout(t, func() int {
var c *ExportCommand
c, ui = newExportCommand()
return c.Run([]string{"--output", "-", "--format", "json5"})
})
if exit != 0 {
t.Fatalf("exit = %d, want 0: %s", exit, ui.ErrorWriter.String())
}
if !strings.HasPrefix(captured, "[") {
t.Errorf("stdout should open with a JSON5 array:\n%s", captured)
}
if !strings.Contains(captured, "abc123") {
t.Errorf("a streamed recipe inlines its values:\n%s", captured)
}

entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
if len(entries) != 0 {
t.Errorf("--output - should not write any file, found %d", len(entries))
}
}

// TestExportCommandFormatJSON5PromptsOnAdjustedPath is the export twin of
// init's --force sequencing test: the overwrite prompt has to name the
// path the swap produced.
func TestExportCommandFormatJSON5PromptsOnAdjustedPath(t *testing.T) {
defer subprocess.SetExecRunner(fakeExecRunner(exportCommandFixture()))()

dir := t.TempDir()
t.Chdir(dir)
recipe := filepath.Join(dir, "tasks.json")
if err := os.WriteFile(recipe, []byte("OLD\n"), 0o644); err != nil {
t.Fatalf("seed tasks.json: %v", err)
}

c, ui := newExportCommand()
ui.InputReader = strings.NewReader("n\n")
if code := c.Run([]string{"--format", "json5"}); code != 1 {
t.Fatalf("declined overwrite exit = %d, want 1", code)
}
if body, _ := os.ReadFile(recipe); string(body) != "OLD\n" {
t.Errorf("declined overwrite should leave the file alone: %q", body)
}
if out := ui.OutputWriter.String(); !strings.Contains(out, "tasks.json already exists") {
t.Errorf("prompt should name the adjusted path:\n%s", out)
}
}

// TestExportCommandRejectsUnknownFormatBeforeReadingServer deliberately
// installs no fake exec runner: passing proves the --format value is
// rejected before tasks.ExportRecipe would have shelled out to dokku.
func TestExportCommandRejectsUnknownFormatBeforeReadingServer(t *testing.T) {
dir := t.TempDir()
recipe := filepath.Join(dir, "tasks.yml")

c, ui := newExportCommand()
if code := c.Run([]string{"--format", "toml", "--output", recipe}); code != 1 {
t.Fatalf("exit = %d, want 1", code)
}
errOut := ui.ErrorWriter.String()
if !strings.Contains(errOut, "--format") {
t.Errorf("error should name --format:\n%s", errOut)
}
if !strings.Contains(errOut, "yaml, json5") {
t.Errorf("error should name the valid values:\n%s", errOut)
}
if _, err := os.Stat(recipe); err == nil {
t.Error("a rejected --format should not write the recipe")
}
}

func TestExportCommandSummaryExcludesGlobalPlay(t *testing.T) {
// #345: one app plus a global play must report "(1 app)", not "(2 apps)".
responses := exportCommandFixture()
Expand Down
Loading