Resolve the config path; print help on a bare invocation
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Fall back to $XDG_CONFIG_HOME and $HOME/.config before the baked-in
/opt/certmanager/config.yaml, and stop failing when called with no args.
This commit is contained in:
2026-09-22 22:34:51 +10:00
parent 72337bf0bb
commit 6042499e25
6 changed files with 266 additions and 18 deletions
+16 -6
View File
@@ -52,11 +52,20 @@ func rootCmd() *cobra.Command {
Use: "certmanager <common_name>",
Short: "Issue a PKI certificate from HashiCorp Vault",
Version: version,
Args: cobra.ExactArgs(1),
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgFile)
if len(args) == 0 {
// A bare invocation is someone asking what this is; flags
// without a common name is a malformed call and must fail.
if cmd.Flags().NFlag() == 0 {
return cmd.Help()
}
return fmt.Errorf("a common name is required")
}
cfg, err := config.Load(config.Resolve(cfgFile))
if err != nil {
return err
}
@@ -79,9 +88,10 @@ func rootCmd() *cobra.Command {
},
}
// Persistent so the ca subcommand shares it; the positional form keeps
// parsing --config exactly as before.
cmd.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
// Persistent so the ca subcommand shares it; empty means "resolve", which
// keeps the resolution order out of the usage text and out of --help.
cmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"path to config.yaml (default: $XDG_CONFIG_HOME or ~/.config, else "+config.FallbackPath()+")")
cmd.Flags().StringVarP(&altNames, "alt-names", "a", "", "comma-separated DNS subject alternative names")
cmd.Flags().StringVarP(&ipSans, "ip-sans", "i", "", "comma-separated IP subject alternative names")
cmd.Flags().IntVarP(&expiryDays, "expiry-days", "e", 365, "certificate validity in days")
@@ -106,7 +116,7 @@ func caCmd(cfgFile *string) *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.LoadCA(*cfgFile)
cfg, err := config.LoadCA(config.Resolve(*cfgFile))
if err != nil {
return err
}
+88
View File
@@ -21,6 +21,8 @@ import (
"strings"
"testing"
"time"
"git.unkin.net/unkin/certmanager/internal/config"
)
const (
@@ -769,3 +771,89 @@ func TestRunCAFailures(t *testing.T) {
})
}
}
// A bare invocation is a human at a terminal, not Puppet: print help and exit
// zero rather than failing on the missing positional.
func TestRunNoArgsPrintsHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := run(nil, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Usage:") {
t.Errorf("stdout = %q, want the help text", stdout.String())
}
if !strings.Contains(stdout.String(), "certmanager <common_name>") {
t.Errorf("help does not document the positional form: %q", stdout.String())
}
if stderr.Len() != 0 {
t.Errorf("stderr = %q, want empty", stderr.String())
}
}
// seedXDGConfig writes a config where DefaultPath() looks first.
func seedXDGConfig(t *testing.T, addr string) {
t.Helper()
xdg := t.TempDir()
dir := filepath.Join(xdg, "certmanager")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "vault:\n addr: " + addr + "\n auth_method: approle\n" +
" approle_path: approle\n role_id: test-role-id\n" +
" mount_point: pki_int\n role_name: servers_default\n"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("HOME", t.TempDir())
}
// Puppet passes no --config, so the resolved path has to be used.
func TestRunResolvesConfigWithoutFlag(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
code := run([]string{"host.example.com", "--alt-names", "host", "--expiry-days", "90", "--json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if got.issueBody["common_name"] != "host.example.com" {
t.Errorf("common_name = %v", got.issueBody["common_name"])
}
if stderr.Len() != 0 {
t.Errorf("stderr = %q, want empty", stderr.String())
}
}
// The ca subcommand shares the same resolution.
func TestRunCAResolvesConfigWithoutFlag(t *testing.T) {
srv, got := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
var stdout, stderr bytes.Buffer
if code := run([]string{"ca"}, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !got.caRequested {
t.Error("the CA chain endpoint was never called")
}
}
// With no config anywhere the run fails naming the baked-in path, not a
// home-relative one that could never exist under generate().
func TestRunWithoutAnyConfigNamesTheBakedPath(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
var stdout, stderr bytes.Buffer
if code := run([]string{"host.example.com", "--json"}, &stdout, &stderr); code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if stdout.Len() != 0 {
t.Errorf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), config.FallbackPath()) {
t.Errorf("stderr = %q, want it to name %q", stderr.String(), config.FallbackPath())
}
}