Skip to content

Commit bd24059

Browse files
committed
fix: address review on #221
Four things the review caught. The MCP conf resource handed out the live config with no redaction. It is the one surface whose full text leaves the machine by design — the client, and from there a model provider — and a witness node's config carries its signing key. Redact at the resource, not in the shared helper: the drift tool reads the same helper and needs the raw text, since a redacted side would report every witness node as drifted. RedactWitnessLines decided array membership from line shapes, and two shapes defeated it. A `]` inside the comment on the opening line ended the array before it began and let the key through; an element that closed the array left the scanner inside it, so every following line came out as <REDACTED> and the diff said nothing. Find the values by parsing instead — the config parser is already a dependency for dbfork — and replace those literal strings wherever they appear, which no formatting can dodge. The scan stays as the fallback for text that does not parse, hardened to strip comments before the bracket checks. Every nodeContext command was holding the exclusive state lock until Close, including ones that never write: logs, wait, exec, files, health, diagnose, verify-config. A `trond logs -f` left running would block every other trond process on the host, and Acquire had no deadline, so the wait was indefinite and unexplained. Split the resolve path — readers take nothing, writers keep the lock across load-modify-save — and bound the wait at 30s with an error that says which process to look for. Enabling provisioning mode made a discarded error live: ensureServiceUser ignored useradd's result, which was invisible while the whitelist refused the call. Check it, tolerating only "already exists" so bootstrap stays re-runnable.
1 parent d8bc344 commit bd24059

15 files changed

Lines changed: 359 additions & 24 deletions

cmd/bootstrap.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,18 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
7777
if len(parsed.Nodes) > 0 && parsed.Nodes[0].SystemUser != "" {
7878
user = parsed.Nodes[0].SystemUser
7979
}
80-
tgt.Exec(ctx, "useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", user)
80+
// Until provisioning mode existed this call was refused by the
81+
// SSH whitelist and failed on every remote target, so discarding
82+
// the error was invisible. Now that it runs, a real failure —
83+
// no permission, a conflicting uid, no /usr/sbin/nologin — would
84+
// otherwise be reported as a created user.
85+
if out, err := tgt.Exec(ctx, "useradd", "--system", "--no-create-home",
86+
"--shell", "/usr/sbin/nologin", user); err != nil {
87+
if !userAlreadyExists(out) {
88+
return exitWithError("BOOTSTRAP_ERROR", output.ExitGeneralError,
89+
fmt.Sprintf("Failed to create system user %q: %v: %s", user, err, strings.TrimSpace(string(out))))
90+
}
91+
}
8192
installed = append(installed, "user:"+user)
8293
}
8394

@@ -138,3 +149,16 @@ func installJDK(ctx context.Context, tgt target.Target) error {
138149

139150
return fmt.Errorf("unsupported package manager; install JDK 17 manually")
140151
}
152+
153+
// userAlreadyExists reports whether a useradd failure was only the user
154+
// being there already, which bootstrap has to tolerate: it is expected
155+
// to be re-runnable, and the second run finds the user from the first.
156+
//
157+
// useradd exits 9 for "name already in use", but the exit status does
158+
// not survive target.Exec's error, so the message is what is left to
159+
// match on. Both util-linux and busybox wording are covered.
160+
func userAlreadyExists(out []byte) bool {
161+
msg := strings.ToLower(string(out))
162+
return strings.Contains(msg, "already exists") ||
163+
strings.Contains(msg, "already in use")
164+
}

cmd/bootstrap_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
// bootstrap has to be re-runnable, so a useradd that fails only because
6+
// the user is already there must not abort the run — but anything else
7+
// must, now that provisioning mode lets the call actually execute.
8+
func TestUserAlreadyExists(t *testing.T) {
9+
tolerated := []string{
10+
"useradd: user 'tron' already exists",
11+
"useradd: UID 999 is not unique\nuseradd: name tron already in use",
12+
"ALREADY EXISTS",
13+
}
14+
for _, out := range tolerated {
15+
if !userAlreadyExists([]byte(out)) {
16+
t.Errorf("should tolerate: %q", out)
17+
}
18+
}
19+
20+
fatal := []string{
21+
"useradd: Permission denied.",
22+
"useradd: cannot open /etc/passwd",
23+
"useradd: invalid shell '/usr/sbin/nologin'",
24+
"",
25+
}
26+
for _, out := range fatal {
27+
if userAlreadyExists([]byte(out)) {
28+
t.Errorf("should not tolerate: %q", out)
29+
}
30+
}
31+
}

cmd/heal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func runAutoHeal(cmd *cobra.Command, args []string) error {
102102
}
103103
}
104104

105-
nc, err := resolveNodeContext(name)
105+
nc, err := resolveNodeContextForWrite(name)
106106
if err != nil {
107107
return err
108108
}

cmd/remove.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func runRemove(cmd *cobra.Command, args []string) error {
4949
}
5050

5151
start := time.Now()
52-
nc, err := resolveNodeContext(name)
52+
nc, err := resolveNodeContextForWrite(name)
5353
if err != nil {
5454
return err
5555
}

cmd/resolve.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,23 +115,43 @@ func requirePrivateForNodes(names ...string) error {
115115
return guard.EnforceNodes(refs)
116116
}
117117

118-
// resolveNodeContext loads a node from state and constructs its target and runtime.
118+
// resolveNodeContext loads a node from state without keeping the state
119+
// lock. Use it for commands that only read — logs, wait, exec, files,
120+
// health, diagnose, verify-config. Holding the exclusive lock across
121+
// those buys nothing, and `logs -f` or a long `wait` would keep every
122+
// other trond process on the host blocked for as long as it runs.
119123
func resolveNodeContext(name string) (*nodeContext, error) {
124+
return resolveNode(name, false)
125+
}
126+
127+
// resolveNodeContextForWrite loads a node and keeps the state lock until
128+
// Close. Commands that write the node list back — start, stop, restart,
129+
// upgrade, rollback, heal, remove — need the read and the write inside
130+
// one lock, or a concurrent trond drops one of the two updates.
131+
func resolveNodeContextForWrite(name string) (*nodeContext, error) {
132+
return resolveNode(name, true)
133+
}
134+
135+
func resolveNode(name string, forWrite bool) (*nodeContext, error) {
120136
store, err := state.NewStore(statePath())
121137
if err != nil {
122138
return nil, err
123139
}
124140

125-
// Take the lock before the read: the caller writes the same state back
126-
// through SaveState once its operation finishes.
127-
lock := state.NewLock(stateDir())
128-
if err := lock.Acquire(); err != nil {
129-
return nil, exitWithError("LOCK_ERROR", output.ExitGeneralError,
130-
"Failed to acquire state lock: "+err.Error(),
131-
"Check if another trond process is running")
141+
// A writer takes the lock before the read and holds it until Close,
142+
// so the load-modify-save cycle is atomic. A reader takes nothing:
143+
// the load below is a single Load() and nothing is written back.
144+
var lock *state.Lock
145+
if forWrite {
146+
lock = state.NewLock(stateDir())
147+
if err := acquireStateLock(lock); err != nil {
148+
return nil, err
149+
}
132150
}
133151
release := func() {
134-
lock.Release()
152+
if lock != nil {
153+
lock.Release()
154+
}
135155
}
136156

137157
deployState, err := store.Load()
@@ -241,3 +261,36 @@ func writeAudit(ev auditEvent) {
241261
Log().Warn("audit log write failed", "error", writeErr)
242262
}
243263
}
264+
265+
// stateLockTimeout bounds how long a command waits for the state lock.
266+
// Long enough that a normal deploy finishing up is simply waited out,
267+
// short enough that a stuck or forgotten process is reported rather
268+
// than leaving the caller staring at a hung terminal.
269+
const stateLockTimeout = 30 * time.Second
270+
271+
// acquireStateLock waits for the lock, but not forever. syscall.Flock
272+
// with LOCK_EX blocks with no deadline, so the wait happens on a
273+
// goroutine and the caller gives up after stateLockTimeout with an
274+
// error that says what to do about it.
275+
func acquireStateLock(lock *state.Lock) error {
276+
done := make(chan error, 1)
277+
go func() { done <- lock.Acquire() }()
278+
279+
select {
280+
case err := <-done:
281+
if err != nil {
282+
return exitWithError("LOCK_ERROR", output.ExitGeneralError,
283+
"Failed to acquire state lock: "+err.Error(),
284+
"Check if another trond process is running")
285+
}
286+
return nil
287+
case <-time.After(stateLockTimeout):
288+
// The goroutine keeps waiting and will release on process exit;
289+
// the lock file is a shared resource, so abandoning the attempt
290+
// is safe.
291+
return exitWithError("LOCK_TIMEOUT", output.ExitGeneralError,
292+
fmt.Sprintf("Another trond process has held the state lock for %s", stateLockTimeout),
293+
"Find it with: ps aux | grep trond",
294+
"A stuck process can be ended; the lock is released when it exits")
295+
}
296+
}

cmd/restart.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func runRestart(cmd *cobra.Command, args []string) error {
2828
return err
2929
}
3030

31-
nc, err := resolveNodeContext(name)
31+
nc, err := resolveNodeContextForWrite(name)
3232
if err != nil {
3333
return err
3434
}

cmd/rollback.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func runRollback(cmd *cobra.Command, args []string) error {
2929
return err
3030
}
3131

32-
nc, err := resolveNodeContext(name)
32+
nc, err := resolveNodeContextForWrite(name)
3333
if err != nil {
3434
return err
3535
}

cmd/start.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func runStart(cmd *cobra.Command, args []string) error {
2828
return err
2929
}
3030

31-
nc, err := resolveNodeContext(name)
31+
nc, err := resolveNodeContextForWrite(name)
3232
if err != nil {
3333
return err
3434
}

cmd/stop.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func runStop(cmd *cobra.Command, args []string) error {
2828
return err
2929
}
3030

31-
nc, err := resolveNodeContext(name)
31+
nc, err := resolveNodeContextForWrite(name)
3232
if err != nil {
3333
return err
3434
}

cmd/upgrade.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
3434
return err
3535
}
3636

37-
nc, err := resolveNodeContext(name)
37+
nc, err := resolveNodeContextForWrite(name)
3838
if err != nil {
3939
return err
4040
}

0 commit comments

Comments
 (0)