diff --git a/apps/cinc/cmd/node.go b/apps/cinc/cmd/node.go index bba4bc0..92e285b 100644 --- a/apps/cinc/cmd/node.go +++ b/apps/cinc/cmd/node.go @@ -271,6 +271,13 @@ Bootstrap a host managed by a Policyfile policy group. cinc node bootstrap web01.example.com --ssh-user ubuntu --policy-name base --policy-group prod`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Resolved up front, before anything with a side effect: bootstrap + // creates a client on the server and installs software on the + // target, and a rejected --format should not cost the user either. + format, err := resolveFormat(cmd) + if err != nil { + return err + } target := argAt(args, 0) if err := promptNodeBootstrap(cmd, &target, &flags); err != nil { return err @@ -334,10 +341,6 @@ cinc node bootstrap web01.example.com --ssh-user ubuntu --policy-name base --pol if result.ExitCode != 0 { return fmt.Errorf("bootstrap failed on %s: %s; client %q was created and may need cleanup before retry", target, firstNonEmpty(result.Error, result.Stderr), flags.nodeName) } - format, err := resolveFormat(cmd) - if err != nil { - return err - } if format == printer.FormatJSON { return printer.New(cmd.OutOrStdout(), format).Value(result) } diff --git a/apps/cinc/cmd/node_test.go b/apps/cinc/cmd/node_test.go index 75b5090..64d1e77 100644 --- a/apps/cinc/cmd/node_test.go +++ b/apps/cinc/cmd/node_test.go @@ -472,6 +472,50 @@ client_key = %q } } +// TestNodeBootstrapRejectsBadFormatBeforeDoingWork pins flag validation ahead +// of side effects. Bootstrap creates a client on the server and installs +// software on the target, so a rejected --format must cost the user neither. +// Every other command resolves the format first; this one used to resolve it +// after the SSH run. +func TestNodeBootstrapRejectsBadFormatBeforeDoingWork(t *testing.T) { + runner := &recordingRunner{result: remote.CommandResult{Stdout: "bootstrap ok\n"}} + prev := nodeRemoteRunner + nodeRemoteRunner = runner + t.Cleanup(func() { nodeRemoteRunner = prev }) + + clientCreated := false + mux := http.NewServeMux() + mux.HandleFunc("/organizations/acme/clients", func(w http.ResponseWriter, r *http.Request) { + clientCreated = true + _, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"name":"web01","validator":false}`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + cfgPath := writeCommandConfig(t, srv.URL) + + root := newRootCmd() + root.SetOut(new(bytes.Buffer)) + root.SetErr(new(bytes.Buffer)) + root.SetArgs([]string{ + "node", "bootstrap", "web01.example.test", + "--node-name", "web01", "--ssh-user", "ubuntu", + "--config", cfgPath, "--no-host-key-verify", + "--format", "yaml", + }) + + if err := root.Execute(); err == nil { + t.Fatal("expected an error for an unknown --format") + } + if clientCreated { + t.Error("a client was created on the server before --format was validated") + } + if len(runner.calls) != 0 { + t.Errorf("bootstrap ran over SSH before --format was validated: %+v", runner.calls) + } +} + func TestNodeBootstrapDryRunCommand(t *testing.T) { cfgPath := writeCommandConfig(t, "https://cinc.example.test") root := newRootCmd()