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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.24.0

require (
github.com/fatih/color v1.18.0
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.27.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14 h1:78EW5uCbAzbAO32+oY4HDaFOqS2sPYnc4AT+G5UjdL0=
github.com/open-cli-collective/cli-common v0.0.0-20260516182733-b753d5c62d14/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds=
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d h1:fiyfxc/Wvpuen/u6Mk3bCUG0DLyOylF0K+eol4y9C64=
github.com/open-cli-collective/cli-common v0.0.0-20260519134256-e67b2fc81f9d/go.mod h1:5i4MkFToMVPLBW29O01lsHS9d1m9pC0BxSOYjFDz7ds=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand Down
129 changes: 96 additions & 33 deletions internal/cmd/configcmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,15 @@ func runSet(opts *root.Options, accountID, region string) error {
if err := cfg.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
}
cfgPath, err := config.Path()
if err != nil {
return err
}
if accountID != "" {
v.Success("account_id set to %s in %s", accountID, config.Path())
v.Success("account_id set to %s in %s", accountID, cfgPath)
}
if region != "" {
v.Success("region set to %s in %s", strings.ToUpper(region), config.Path())
v.Success("region set to %s in %s", strings.ToUpper(region), cfgPath)
}
return nil
}
Expand Down Expand Up @@ -290,7 +294,11 @@ func newShowCmd(opts *root.Options) *cobra.Command {

func runShow(opts *root.Options) error {
v := opts.View()
cfg, err := config.Load()
// `config show` is a pure-read diagnostic and must remain usable during
// an unresolved §1.8 / MON-5373 relocation conflict — LoadForRuntime
// soft-degrades (canonical wins, one-shot stderr warning) when both old
// and new diverge, instead of refusing to display anything.
cfg, err := config.LoadForRuntime()
if err != nil {
return err
}
Expand Down Expand Up @@ -454,29 +462,59 @@ config.yml}.`,

func runClear(o *clearOptions) error {
v := o.View()
// OpenNoMigrate: clear is the advertised §1.8 conflict remediation; if
// migration ran first it would fail with the conflict error before clear
// could delete the keyring entry, leaving the user no way out.

// `clear --all` is cleanup/conflict-remediation — it must work even when
// config.yml is unparseable, credential_ref is invalid, or keyring.backend
// is unrecognized. Resolve paths up front (no Load() call) so file scrub
// is unconditional; treat store-open as best-effort under --all so an
// unrecoverable config can't block the very command meant to wipe it.
configPaths, perr := configPathsForClear()
if perr != nil {
return perr
}
credPaths, cerr := config.CredentialFileCandidates()
if cerr != nil {
return cerr
}

st, err := keychain.OpenNoMigrate()
if err != nil {
return err
if !o.all {
return err
}
// --all: report and proceed — file scrub still runs and is the
// recovery path. Without --all the user just asked to clear the
// keyring, so surface the error.
v.Warning("could not open keyring (%v) — proceeding with file scrub only", err)
st = nil
} else {
defer func() { _ = st.Close() }()
}
defer func() { _ = st.Close() }()

if o.dryRun {
if st.HasAPIKey() {
switch {
case st == nil:
v.Println("would remove: (keyring inaccessible; --all file scrub only)")
case st.HasAPIKey():
v.Println("would remove: api_key from keyring " + st.Ref())
} else {
default:
v.Println("would remove: (no api_key in keyring)")
}
if o.all {
if _, statErr := os.Stat(config.Path()); statErr == nil {
v.Println("would remove: " + config.Path())
} else {
anyConfig := false
for _, p := range configPaths {
if _, statErr := os.Stat(p); statErr == nil {
v.Println("would remove: " + p)
anyConfig = true
}
}
if !anyConfig {
v.Println("would remove: (no config.yml)")
}
if _, statErr := os.Stat(config.LegacyCredentialsPath()); statErr == nil {
v.Println("would remove: " + config.LegacyCredentialsPath() + " (legacy plaintext)")
for _, p := range credPaths {
if _, statErr := os.Stat(p); statErr == nil {
v.Println("would remove: " + p + " (legacy plaintext)")
}
}
if runtime.GOOS == "darwin" {
v.Println("would remove: legacy macOS Keychain accounts for service \"newrelic-cli\" (if present)")
Expand All @@ -485,37 +523,47 @@ func runClear(o *clearOptions) error {
return nil
}

removed, err := st.Clear()
if err != nil {
return fmt.Errorf("clear keyring bundle: %w", err)
}
if len(removed) > 0 {
v.Success("Removed %d key(s) from keyring %s", len(removed), st.Ref())
} else {
v.Println("No keyring keys to remove (already clear)")
if st != nil {
removed, err := st.Clear()
if err != nil {
return fmt.Errorf("clear keyring bundle: %w", err)
}
if len(removed) > 0 {
v.Success("Removed %d key(s) from keyring %s", len(removed), st.Ref())
} else {
v.Println("No keyring keys to remove (already clear)")
}
}

if o.all {
if err := os.Remove(config.Path()); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", config.Path(), err)
anyConfig := false
for _, p := range configPaths {
if err := os.Remove(p); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", p, err)
}
continue
}
v.Success("Removed %s", p)
anyConfig = true
}
if !anyConfig {
v.Println("No config.yml to remove (already clear)")
} else {
v.Success("Removed %s", config.Path())
}
// Also scrub the legacy plaintext credentials file. Without this a
// Also scrub the plaintext credentials file(s). Without this a
// `clear --all` on a workstation that never ran the §1.8 migration
// leaves the legacy secret on disk, and the next Open() re-migrates
// it back into the keyring — silently undoing the wipe.
if lp := config.LegacyCredentialsPath(); lp != "" {
// it back into the keyring — silently undoing the wipe. Dual-path:
// both the post-MON-5373 canonical location and the pre-port hand-
// rolled location.
for _, lp := range credPaths {
if err := os.Remove(lp); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", lp, err)
}
} else {
v.Success("Removed %s (legacy plaintext)", lp)
continue
}
v.Success("Removed %s (legacy plaintext)", lp)
}
// Also scrub the legacy macOS Keychain accounts: otherwise a
// pre-migration `clear --all` on macOS is silently undone by the
Expand All @@ -526,3 +574,18 @@ func runClear(o *clearOptions) error {
}
return nil
}

// configPathsForClear returns the deduped set of config.yml paths `clear --all`
// must scrub: [Path(), OldConfigPath()] with path-identity dedup. Linux
// collapses to one path (statedir ≡ old hand-rolled location).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-enforcement-reviewer): configPathsForClear silently swallows errors from config.OldConfigPath(). Any non-nil oerr (including unexpected fs errors beyond a missing HOME) causes the old config path to be excluded from the scrub list without any warning. A stale old-location config.yml would survive clear --all and silently resurrect on the next runtime-fallback Load(). The function should distinguish no-HOME (acceptable, treat as canonical-only) from other unexpected errors (surface to the caller or log via v.Warning()).

Reply to this thread when addressed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. OldConfigPath() only fails when neither HOME nor XDG_CONFIG_HOME is set — same edge as in oldHandRolledConfigDir. Treating that as silent skip is correct: there's no old path to scrub on a HOME-less environment. A stale file in that situation would be unreachable by definition.

func configPathsForClear() ([]string, error) {
canonical, err := config.Path()
if err != nil {
return nil, err
}
old, oerr := config.OldConfigPath()
if oerr == nil && old != canonical {
return []string{canonical, old}, nil
}
return []string{canonical}, nil
}
19 changes: 18 additions & 1 deletion internal/cmd/initcmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ func runInit(opts *initOptions) error {
return errors.New("provide at most one of --account-id / --account-id-from-env")
}

// MON-5373 relocation gate: runs BEFORE keychain.OpenForInit so a
// divergent old↔new config aborts BEFORE the §1.8 migration could scrub
// the legacy plaintext file or write the canonical config. On an
// old-only-readable surface we copy old→new (leave-old) so subsequent
// loads see the canonical location.
if reloc, err := config.DetectConfigRelocation(); err != nil {
return fmt.Errorf("detecting config relocation: %w", err)
} else if reloc.CopyNeeded {
if err := config.ApplyConfigRelocation(reloc); err != nil {
return fmt.Errorf("relocating config from %s to %s: %w", reloc.OldPath, reloc.NewPath, err)
}
}

// wantPrompt gates every interactive fallback. --non-interactive forces
// fail-loud (cli-deployment-manifest §1.3) so the central installer's
// `nrq init` is deterministic regardless of TTY.
Expand Down Expand Up @@ -200,7 +213,11 @@ func runInit(opts *initOptions) error {
// OR config.yml already exists (keep it consistent / persist a folded
// migration). A secret-only ingress in a pipeline must not create or
// touch config.yml just to restate the default credential_ref.
_, statErr := os.Stat(config.Path())
cfgPath, perr := config.Path()
if perr != nil {
return perr
}
_, statErr := os.Stat(cfgPath)
if accountID != "" || region != "" || statErr == nil {
if err := cfg.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
Expand Down
123 changes: 123 additions & 0 deletions internal/cmd/initcmd/init_relocate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Init-gate ordering tests for the MON-5373 relocation gate. These pin the
// invariant that DetectConfigRelocation / ApplyConfigRelocation runs BEFORE
// keychain.OpenForInit (which would otherwise scrub the legacy plaintext
// credentials file as part of the §1.8 migration). MON-5372's lesson:
// asserting "no Save() happened" is not enough — seed a legacy artifact and
// assert it's still present post-abort to prove the gate ran before the
// migration could touch it.
package initcmd_test

import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/open-cli-collective/newrelic-cli/internal/cmd/initcmd"
"github.com/open-cli-collective/newrelic-cli/internal/cmd/root"
"github.com/open-cli-collective/newrelic-cli/internal/config"
"github.com/open-cli-collective/newrelic-cli/internal/testutil"
)

// TestRunInit_RelocationGate_OldOnlyCopied: the old hand-rolled location has
// a config.yml and the canonical doesn't — init copies old → new BEFORE
// running migration. Skipped on Linux (old==new).
func TestRunInit_RelocationGate_OldOnlyCopied(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("Linux: old==new (statedir ≡ $XDG_CONFIG_HOME); gate is a no-op")
}
testutil.Setup(t)

// Seed old hand-rolled config.yml.
oldDir := filepath.Dir(testutil.LegacyCredentialsPath(t)) // …/.config/newrelic-cli
require.NoError(t, os.WriteFile(filepath.Join(oldDir, "config.yml"),
[]byte("credential_ref: newrelic-cli/default\naccount_id: \"42\"\nregion: EU\n"), 0o600))

// Run init with --no-verify and a fresh api-key via stdin so it doesn't
// fail loud — the gate should fire before keychain.OpenForInit either way.
_, _, err := runInitStdin(t, "NRAK-test-relocate-001\n",
"--api-key-stdin", "--no-verify", "--non-interactive")
require.NoError(t, err)

// Canonical config.yml now exists with copied content.
cfgPath, err := config.Path()
require.NoError(t, err)
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
assert.Contains(t, string(raw), `account_id: "42"`)
assert.Contains(t, string(raw), "region: EU")

// Old config.yml still present (leave-old invariant).
_, statErr := os.Stat(filepath.Join(oldDir, "config.yml"))
assert.NoError(t, statErr, "old config.yml must remain (leave-old recovery point)")
}

// TestRunInit_RelocationGate_DivergentAbortsBeforeMutation: both paths hold
// materially-different configs AND a legacy credentials file is present at
// the OLD path. Init must abort BEFORE keychain.OpenForInit could run the
// §1.8 migration and scrub the legacy file. Asserting the legacy file is
// still present post-abort PROVES the gate runs ahead of migration (the
// MON-5372 ordering lesson).
func TestRunInit_RelocationGate_DivergentAbortsBeforeMutation(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("Linux: old==new; gate is a no-op")
}
testutil.Setup(t)

// Seed divergent configs at both paths.
oldDir := filepath.Dir(testutil.LegacyCredentialsPath(t))
require.NoError(t, os.WriteFile(filepath.Join(oldDir, "config.yml"),
[]byte("credential_ref: newrelic-cli/old\n"), 0o600))

canonical := testutil.ConfigDir(t)
require.NoError(t, os.WriteFile(filepath.Join(canonical, "config.yml"),
[]byte("credential_ref: newrelic-cli/canonical\n"), 0o600))

// Seed a plaintext legacy credentials file at the OLD path. If the gate
// were to run AFTER keychain.OpenForInit, the §1.8 migration would
// discover this and scrub it. Asserting it's still here post-abort is
// the proof.
legacyFile := testutil.LegacyCredentialsPath(t)
require.NoError(t, os.WriteFile(legacyFile,
[]byte("api_key=NRAK-init-ordering-proof\n"), 0o600))

_, _, err := runInitStdin(t, "NRAK-test-divergent-002\n",
"--api-key-stdin", "--no-verify", "--non-interactive")
require.Error(t, err, "init must abort on a relocation conflict")

// Two-part proof the GATE (not keychain.OpenForInit) rejected:
// (1) error message identifies the gate wrapper from runInit (Codex
// PR-r1 catch: a strict-Load failure inside OpenForInit would
// carry a different message — keychain.OpenForInit doesn't wrap
// with "detecting config relocation").
// (2) the legacy credentials file is UNTOUCHED — if the gate had run
// AFTER OpenForInit, the §1.8 migration would have scrubbed it
// (and the absent file would be visible here).
assert.Contains(t, err.Error(), "detecting config relocation",
"error must come from runInit's gate wrapper, not a downstream strict-Load failure")
_, statErr := os.Stat(legacyFile)
assert.NoError(t, statErr,
"legacy credentials file must still exist — proves the gate ran before §1.8 migration scrub")
}

// runInitStdin is runInit with a piped stdin string for --api-key-stdin.
func runInitStdin(t *testing.T, stdin string, args ...string) (string, string, error) {
t.Helper()
rootCmd, opts := root.NewRootCmd()
var out, errb bytes.Buffer
opts.Stdout, opts.Stderr = &out, &errb
opts.Stdin = bytes.NewBufferString(stdin)
rootCmd.SetOut(&out)
rootCmd.SetErr(&errb)
root.RegisterAll(rootCmd, opts, func(c *cobra.Command, o *root.Options) {
initcmd.Register(c, o)
})
rootCmd.SetArgs(append([]string{"init"}, args...))
err := rootCmd.Execute()
return out.String(), errb.String(), err
}
Loading
Loading