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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ trond preflight --intent my-net.yaml -o json

# 4. Create the whole network in one shot. trond auto-wires
# node.active between siblings so peering works under auto_ports.
SR_KEY=da146374a75310b9666e834ee4ad0866d6f4035967bfc76217c5a495fff9f0d0 \
SR_KEY=a31d54825aea2fc5127e3bd435fc2346021313005e5f304ab33372432784acae \
trond network create --intent my-net.yaml --wait -o json
# Output: {"network":"pn", "nodes":[{"name":"pn-node0", "endpoints":{...}}, ...]}

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ LDFLAGS := -s -w -X $(MODULE)/cmd.version=$(VERSION) -X $(MODULE)/cmd.commit=
# resolves on PATH (useful in CI runners that already pinned Go via
# actions/setup-go and want to skip the download step).

GO_VERSION ?= 1.25.9
GO_VERSION ?= 1.25.13

ifeq ($(USE_SYSTEM_GO),1)
GO := go
Expand Down
33 changes: 32 additions & 1 deletion cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if closer, ok := tgt.(interface{ Close() error }); ok {
defer closer.Close()
}
// Host preparation installs packages, which the ordinary SSH whitelist
// does not allow — deliberately, so that no lifecycle path or `trond
// exec` can. bootstrap is the one command that may, and only for the
// lifetime of this target.
if p, ok := tgt.(interface{ SetProvisioning(bool) }); ok {
p.SetProvisioning(true)
Comment thread
Sunny6889 marked this conversation as resolved.
}

runtimeType := parsed.Target.Runtime
if runtimeType == "" {
Expand Down Expand Up @@ -70,7 +77,18 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
if len(parsed.Nodes) > 0 && parsed.Nodes[0].SystemUser != "" {
user = parsed.Nodes[0].SystemUser
}
tgt.Exec(ctx, "useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", user)
// Until provisioning mode existed this call was refused by the
// SSH whitelist and failed on every remote target, so discarding
// the error was invisible. Now that it runs, a real failure —
// no permission, a conflicting uid, no /usr/sbin/nologin — would
// otherwise be reported as a created user.
if out, err := tgt.Exec(ctx, "useradd", "--system", "--no-create-home",
"--shell", "/usr/sbin/nologin", user); err != nil {
if !userAlreadyExists(out) {
return exitWithError("BOOTSTRAP_ERROR", output.ExitGeneralError,
fmt.Sprintf("Failed to create system user %q: %v: %s", user, err, strings.TrimSpace(string(out))))
}
}
installed = append(installed, "user:"+user)
}

Expand Down Expand Up @@ -131,3 +149,16 @@ func installJDK(ctx context.Context, tgt target.Target) error {

return fmt.Errorf("unsupported package manager; install JDK 17 manually")
}

// userAlreadyExists reports whether a useradd failure was only the user
// being there already, which bootstrap has to tolerate: it is expected
// to be re-runnable, and the second run finds the user from the first.
//
// useradd exits 9 for "name already in use", but the exit status does
// not survive target.Exec's error, so the message is what is left to
// match on. Both util-linux and busybox wording are covered.
func userAlreadyExists(out []byte) bool {
msg := strings.ToLower(string(out))
return strings.Contains(msg, "already exists") ||
strings.Contains(msg, "already in use")
}
31 changes: 31 additions & 0 deletions cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cmd

import "testing"

// bootstrap has to be re-runnable, so a useradd that fails only because
// the user is already there must not abort the run — but anything else
// must, now that provisioning mode lets the call actually execute.
func TestUserAlreadyExists(t *testing.T) {
tolerated := []string{
"useradd: user 'tron' already exists",
"useradd: UID 999 is not unique\nuseradd: name tron already in use",
"ALREADY EXISTS",
}
for _, out := range tolerated {
if !userAlreadyExists([]byte(out)) {
t.Errorf("should tolerate: %q", out)
}
}

fatal := []string{
"useradd: Permission denied.",
"useradd: cannot open /etc/passwd",
"useradd: invalid shell '/usr/sbin/nologin'",
"",
}
for _, out := range fatal {
if userAlreadyExists([]byte(out)) {
t.Errorf("should not tolerate: %q", out)
}
}
}
8 changes: 6 additions & 2 deletions cmd/config/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ func runDiff(cmd *cobra.Command, args []string) error {
// assignment misaligns the tail and would otherwise print the SR
// private key into `diffs[]`.
func simpleDiff(old, new []string) []string {
// Redact whole-slice: a multi-line `localwitness = [` array keeps its
// key on a line that does not itself start with the key name.
oldR := render.RedactWitnessLines(old)
newR := render.RedactWitnessLines(new)
var diffs []string

maxLen := len(old)
Expand All @@ -139,10 +143,10 @@ func simpleDiff(old, new []string) []string {
}
if oldLine != newLine {
if oldLine != "" {
diffs = append(diffs, fmt.Sprintf("- %s", render.RedactWitnessLine(oldLine)))
diffs = append(diffs, fmt.Sprintf("- %s", oldR[i]))
}
if newLine != "" {
diffs = append(diffs, fmt.Sprintf("+ %s", render.RedactWitnessLine(newLine)))
diffs = append(diffs, fmt.Sprintf("+ %s", newR[i]))
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/heal.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func runAutoHeal(cmd *cobra.Command, args []string) error {
}
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
9 changes: 9 additions & 0 deletions cmd/network/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ func runAdd(cmd *cobra.Command, args []string) error {
// Pick the next free index. Existing entries are "<network>-node<N>"; we
// rescan state instead of trusting any in-memory counter so the operation
// is safe to retry.
// Hold the state lock across the whole load-modify-save cycle: this
// command reads the node list here and writes it back much later, and
// a concurrent trond would otherwise drop one of the two updates.
lock := state.NewLock(paths.BaseDir())
if err := lock.Acquire(); err != nil {
return output.NewError("LOCK_ERROR", output.ExitGeneralError, "acquire state lock: "+err.Error())
}
defer lock.Release()

store, err := state.NewStore(paths.State())
if err != nil {
return err
Expand Down
9 changes: 9 additions & 0 deletions cmd/network/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ func runCreate(cmd *cobra.Command, args []string) error {
templateDir := findTemplatesDir()
workDir := paths.Deployments()

// Hold the state lock across the whole load-modify-save cycle: this
// command reads the node list here and writes it back much later, and
// a concurrent trond would otherwise drop one of the two updates.
lock := state.NewLock(paths.BaseDir())
if err := lock.Acquire(); err != nil {
return output.NewError("LOCK_ERROR", output.ExitGeneralError, "acquire state lock: "+err.Error())
}
defer lock.Release()

store, err := state.NewStore(paths.State())
if err != nil {
return output.NewError("STATE_ERROR", output.ExitGeneralError, err.Error())
Expand Down
9 changes: 9 additions & 0 deletions cmd/network/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ func runDestroy(cmd *cobra.Command, args []string) error {
WithSuggestions("Add --confirm <network-name> to proceed")
}

// Hold the state lock across the whole load-modify-save cycle: this
// command reads the node list here and writes it back much later, and
// a concurrent trond would otherwise drop one of the two updates.
lock := state.NewLock(paths.BaseDir())
if err := lock.Acquire(); err != nil {
return output.NewError("LOCK_ERROR", output.ExitGeneralError, "acquire state lock: "+err.Error())
}
defer lock.Release()

store, err := state.NewStore(paths.State())
if err != nil {
return err
Expand Down
8 changes: 6 additions & 2 deletions cmd/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ func runPlan(cmd *cobra.Command, args []string) error {
// tail and would otherwise push the SR private key straight into
// stdout and into result["config_diff"].
func simpleHOCONDiff(old, new []string) []string {
// Redact whole-slice: a multi-line `localwitness = [` array keeps its
// key on a line that does not itself start with the key name.
oldR := render.RedactWitnessLines(old)
newR := render.RedactWitnessLines(new)
var diffs []string
maxLen := len(old)
if len(new) > maxLen {
Expand All @@ -215,10 +219,10 @@ func simpleHOCONDiff(old, new []string) []string {
}
if oldLine != newLine {
if oldLine != "" {
diffs = append(diffs, "- "+render.RedactWitnessLine(oldLine))
diffs = append(diffs, "- "+oldR[i])
}
if newLine != "" {
diffs = append(diffs, "+ "+render.RedactWitnessLine(newLine))
diffs = append(diffs, "+ "+newR[i])
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func runRemove(cmd *cobra.Command, args []string) error {
}

start := time.Now()
nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
84 changes: 82 additions & 2 deletions cmd/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,24 @@ type nodeContext struct {
Node *state.ManagedNode
Target target.Target
Runtime runtime.Runtime

// lock is held for the whole load-modify-save cycle. Every command
// built on a nodeContext reads state here and writes it back through
// SaveState, sometimes many seconds later, so the read and the write
// have to sit inside one lock or a concurrent trond drops one of the
// two updates.
lock *state.Lock
}

// Close releases resources (e.g., SSH connections).
// Close releases the state lock and any resources (e.g., SSH connections).
func (nc *nodeContext) Close() {
if closer, ok := nc.Target.(interface{ Close() error }); ok {
closer.Close()
}
if nc.lock != nil {
nc.lock.Release()
nc.lock = nil
}
}

// SaveState persists the current deployment state.
Expand Down Expand Up @@ -104,20 +115,54 @@ func requirePrivateForNodes(names ...string) error {
return guard.EnforceNodes(refs)
}

// resolveNodeContext loads a node from state and constructs its target and runtime.
// resolveNodeContext loads a node from state without keeping the state
// lock. Use it for commands that only read — logs, wait, exec, files,
// health, diagnose, verify-config. Holding the exclusive lock across
// those buys nothing, and `logs -f` or a long `wait` would keep every
// other trond process on the host blocked for as long as it runs.
func resolveNodeContext(name string) (*nodeContext, error) {
return resolveNode(name, false)
}

// resolveNodeContextForWrite loads a node and keeps the state lock until
// Close. Commands that write the node list back — start, stop, restart,
// upgrade, rollback, heal, remove — need the read and the write inside
// one lock, or a concurrent trond drops one of the two updates.
func resolveNodeContextForWrite(name string) (*nodeContext, error) {
return resolveNode(name, true)
}

func resolveNode(name string, forWrite bool) (*nodeContext, error) {
store, err := state.NewStore(statePath())
if err != nil {
return nil, err
}

// A writer takes the lock before the read and holds it until Close,
// so the load-modify-save cycle is atomic. A reader takes nothing:
// the load below is a single Load() and nothing is written back.
var lock *state.Lock
if forWrite {
lock = state.NewLock(stateDir())
if err := acquireStateLock(lock); err != nil {
return nil, err
}
}
release := func() {
if lock != nil {
lock.Release()
}
}

deployState, err := store.Load()
if err != nil {
release()
return nil, err
}

node := store.GetNode(deployState, name)
if node == nil {
release()
return nil, exitWithError("NODE_NOT_FOUND", output.ExitGeneralError,
fmt.Sprintf("Node %q not found in state", name),
"Run: trond list",
Expand All @@ -126,6 +171,7 @@ func resolveNodeContext(name string) (*nodeContext, error) {

tgt, err := resolveTargetFromNode(node)
if err != nil {
release()
return nil, exitWithError("TARGET_UNREACHABLE", output.ExitTargetUnreachable, err.Error())
}

Expand All @@ -137,6 +183,7 @@ func resolveNodeContext(name string) (*nodeContext, error) {
Node: node,
Target: tgt,
Runtime: rt,
lock: lock,
}, nil
}

Expand Down Expand Up @@ -214,3 +261,36 @@ func writeAudit(ev auditEvent) {
Log().Warn("audit log write failed", "error", writeErr)
}
}

// stateLockTimeout bounds how long a command waits for the state lock.
// Long enough that a normal deploy finishing up is simply waited out,
// short enough that a stuck or forgotten process is reported rather
// than leaving the caller staring at a hung terminal.
const stateLockTimeout = 30 * time.Second

// acquireStateLock waits for the lock, but not forever. syscall.Flock
// with LOCK_EX blocks with no deadline, so the wait happens on a
// goroutine and the caller gives up after stateLockTimeout with an
// error that says what to do about it.
func acquireStateLock(lock *state.Lock) error {
done := make(chan error, 1)
go func() { done <- lock.Acquire() }()

select {
case err := <-done:
if err != nil {
return exitWithError("LOCK_ERROR", output.ExitGeneralError,
"Failed to acquire state lock: "+err.Error(),
"Check if another trond process is running")
}
return nil
case <-time.After(stateLockTimeout):
// The goroutine keeps waiting and will release on process exit;
// the lock file is a shared resource, so abandoning the attempt
// is safe.
return exitWithError("LOCK_TIMEOUT", output.ExitGeneralError,
fmt.Sprintf("Another trond process has held the state lock for %s", stateLockTimeout),
"Find it with: ps aux | grep trond",
"A stuck process can be ended; the lock is released when it exits")
}
}
2 changes: 1 addition & 1 deletion cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func runRestart(cmd *cobra.Command, args []string) error {
return err
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func runRollback(cmd *cobra.Command, args []string) error {
return err
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func runStart(cmd *cobra.Command, args []string) error {
return err
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func runStop(cmd *cobra.Command, args []string) error {
return err
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error {
return err
}

nc, err := resolveNodeContext(name)
nc, err := resolveNodeContextForWrite(name)
if err != nil {
return err
}
Expand Down
Loading
Loading