Answer an agent's Pings from one connector only by jeremy · Pull Request #936 · basecamp/basecamp-cli · GitHub
Skip to content
Merged
22 changes: 21 additions & 1 deletion README.md
40 changes: 40 additions & 0 deletions docs/agent-connections-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,46 @@ the approval page; do not reconnect merely to upgrade the CLI.
A second run of the same profile is refused. Deliberate project overlap is
allowed; requests admitted by both can be answered twice. There is no lease
or cross-profile claim.
6. Decide which one computer answers the agent's Pings (see below), and start
every other connector for the agent with `--no-pings` once.

## Only one computer answers Pings

A Ping is in no project, so serving projects can't divide Pings between
connections: every connector for the agent sees all of them, and each one
that heard them would answer. Only one computer should answer Pings, and the
person running the agent arranges it; the CLI picks a default, keeps the
choice, and warns about a second it can see on the same computer.

- `--pings` hears Pings and anything else outside a project. `--no-pings`
hears only the connector's projects.
- With neither, the agent's first connection (its credential is the agent
client's own secret) hears them, and a connection added after it (its
secret starts `bcac_`) doesn't. A profile that holds a person's login, a
bot user's, hears them as every connector did before this build. A run
given `--project` hears none, as before, and `--pings` with `--project` is
refused.
- The first start on this build without `--project` saves the answer in the
profile's state directory (`pings.json`, beside the ledger), as `--pings`
and `--no-pings` save theirs, and every restart keeps it until one of them
changes it. A run given `--project` saves nothing, so a narrowed first run
leaves the choice to the first one that isn't. A profile that heard Pings keeps
hearing them after a reconnect turns its credential into an added
connection. An unreadable `pings.json` refuses the start: remove it, then
choose again with a flag.
- `basecamp connect status` and `basecamp connect doctor` say whether the
profile's connector answers Pings and why. A connector that hears them
warns when another for the same agent on this computer does too, an older
build's included (it hears them unless started with `--project`).
Connectors on other computers can't be seen.
- Upgrading changes nothing for one connection per agent: it is the first
connection, or a bot user's login, and it still hears Pings. Where an agent
already runs several connectors that hear Pings (a bot user on three
computers, say), choose one, and restart the others with `--no-pings`.

Downgrading drops the choice: older builds ignore `pings.json` and every
connector they run hears Pings unless given `--project`. The file stays, and
the next start on this build reads it again.

State is under `$XDG_STATE_HOME/basecamp/connect` and `connect-shadow` (default
`~/.local/state/basecamp`). New profile directories canonicalize numeric account
Expand Down
22 changes: 19 additions & 3 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ the agent.
Connect the agent to a profile first (basecamp auth agent connect -P <profile>),
then run setup on that profile: it records who may drive the agent, maps
which Basecamp projects it serves, and checks the connector is
ready. Pings need no setup: an operator's ping is handed off too. Show prints what setup recorded. Then run the connector on it:
ready. Show prints what setup recorded. Then run the connector on it:

basecamp connect -P <profile> [--project <id>]... [--shadow]
basecamp connect -P <profile> [--project <id>]... [--pings | --no-pings] [--shadow]

It runs in the foreground until interrupted. Stdout is one JSON object per
line: events seen, admission's verdicts, and a "type":"request" line for each
Expand All @@ -69,6 +69,19 @@ a new approval replaces the existing connection. Each profile
serves its own projects and has its own hold. When two connections serve the
same project, a request both admit can be answered twice.

Pings. Only one computer should answer an agent's Pings: every connector for
the agent sees all of them, since a Ping is in no project. A connector started
with --pings hears Pings, and anything else outside a project; one started
with --no-pings hears only projects. Without either, the agent's first
connection hears them, a connection added after it doesn't, and a person's
login (a bot user's) does, as before. A run given --project hears none, and
saves nothing. The first start without --project saves the choice for the
profile, as --pings and --no-pings save theirs, and every restart keeps it
until one of them changes it. A connector that hears Pings warns when
another one on this computer hears the same agent's; ones on other computers
can't be seen, so starting just one is yours to arrange. basecamp connect
status and doctor say which this profile's does.

basecamp connect status what it heard and holds
basecamp connect doctor what it needs to run
basecamp connect discard <id> close a record without running it
Expand All @@ -78,6 +91,7 @@ same project, a request both admit can be answered twice.
basecamp connect import <file> apply a cutover reconciliation file`,
Example: ` basecamp connect setup -P agent --operator-profile me --serve 12345
basecamp connect -P agent
basecamp connect -P laptop --no-pings
basecamp connect -P agent --project 12345 --shadow`,
Args: cobra.NoArgs,
Annotations: map[string]string{
Expand Down Expand Up @@ -401,7 +415,9 @@ The list is this profile's own. When two connections of one agent serve the
same project, a request both admit there can be answered twice, so serve
each project from one of them.

Pings. A Ping is in no project, so it is never served and needs no setup.
Pings. A Ping is in no project, so it is never served and needs no setup,
and only the connector started to hear Pings answers them (see basecamp
connect --help: only one computer should answer Pings).
An operator (the operator, or a person passed with --allow) gives the agent
work by Ping in every trust mode: every line of theirs in a direct Ping with
the agent is a request, and in a group Ping a line that @mentions it.
Expand Down
88 changes: 85 additions & 3 deletions internal/commands/connect_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"time"

Expand All @@ -25,9 +26,10 @@ func newConnectDoctorCmd() *cobra.Command {
Use: "doctor",
Short: "Check what the connector needs to run",
Long: `Check the connector for a set-up profile: connect.json, the token, the agent's
identity, the stream ticket mint, the account feed, and the ledger (its gaps,
open losses, hold, and lifecycle messages an older connector left waiting for
a person).
identity, the stream ticket mint, the account feed, whether it hears the
agent's Pings (and whether another connector on this computer does too), and
the ledger (its gaps, open losses, hold, and lifecycle messages an older
connector left waiting for a person).

It writes nothing to the connector's ledger and posts nothing to Basecamp.
Renewing the profile's own credential, which every command does when its token
Expand Down Expand Up @@ -65,6 +67,7 @@ func runConnectDoctor(cmd *cobra.Command, _ []string) error {
feedCheck(ctx, agent.client.ForAccount(agent.account)),
)
}
checks = append(checks, pingsCheck(ctx, p))
checks = append(checks, ledgerChecks(ctx, p)...)

result := summarizeChecks(asDoctorChecks(checks))
Expand Down Expand Up @@ -171,3 +174,82 @@ func connectUnsupportedOSCheck(goos string) setup.Check {
Message: fmt.Sprintf("The connector does not run on %s: %s", goos, connectSupportedOSReason),
Hint: "Run the connector on Linux or macOS; the rest of the CLI runs here."}
}

// pingsCheck says whether the profile's connector hears the agent's Pings —
// the one running now, as its lock says, else a start with neither --pings,
// --no-pings nor --project — and warns when two connectors on this computer
// answer them. Only one should; the ones on other computers can't be seen
// from here. The saved choice is read either way: a running connector
// doesn't make an unreadable one the next start's to discover.
func pingsCheck(ctx context.Context, p connectProfile) setup.Check {
c := setup.Check{Name: "Pings"}
v := readConnectPingsView(ctx, p.app, p.name, p.file)
if v.SavedErr != nil {
c.Status, c.Message = setup.StatusFail, "Whether it hears the agent's Pings was saved, and can't be read, so the next start without a flag refuses: "+errorMessage(v.SavedErr)
c.Hint = v.Remedy(p.name)
return c
}
hears, known := v.Hears()
if !known {
c.Status, c.Message = setup.StatusSkip, "Whether it hears the agent's Pings can't be told without the profile's credential"
return c
}
// A running connector is described by its lock; Next may be unknown
// then, with no saved choice and no credential to read a default from.
who, why := "The connector running now", v.Running.Describe()
if !v.Running.Running {
who, why = "It", v.Next.Describe()
}
switch {
case !hears:
c.Message = who + " doesn't hear the agent's Pings: " + why
case v.Running.Running && v.Running.Pings == nil:
c.Message = who + " may hear the agent's Pings: " + why
default:
c.Message = who + " hears the agent's Pings: " + why
}
// What a restart without flags or --project would do, when it differs.
if v.Running.Running && v.Next != nil && v.Next.Hears != hears {
if v.Next.Hears {
c.Message += "; restarted without --project, it would hear them (" + v.Next.Describe() + ")"
} else {
c.Message += "; restarted, it would hear none (" + v.Next.Describe() + ")"
}
}
switch {
case v.Twice() && hears && len(v.Others) == 1:
c.Status = setup.StatusWarn
c.Message += ", and so does " + peerList(v.Others) + " on this computer: each Ping is answered twice"
case v.Twice() && hears:
c.Status = setup.StatusWarn
c.Message += ", and so do " + peerList(v.Others) + " on this computer: each Ping is answered more than once"
case v.Twice():
c.Status = setup.StatusWarn
c.Message += ", and " + peerList(v.Others) + " on this computer all do: each Ping is answered more than once"
case len(v.Others) == 1:
c.Status = setup.StatusPass
c.Message += "; " + peerList(v.Others) + " answers them on this computer"
return c
case !hears:
c.Status = setup.StatusPass
c.Message += ". Only one connector should answer them; start this one with --pings if it's that one"
return c
default:
c.Status = setup.StatusPass
return c
}
// A profile that hears none can only point at the others: restarting it
// with --no-pings changes nothing.
older := slices.ContainsFunc(v.Others, func(o connectPingsPeer) bool { return o.Older })
switch {
case !hears && older:
c.Hint = "Only one connector should answer Pings, and this one doesn't: upgrade any older one, then restart all but one of the others with --no-pings: basecamp connect -P <profile> --no-pings"
case !hears:
c.Hint = "Only one connector should answer Pings, and this one doesn't: restart all but one of the others with --no-pings: basecamp connect -P <profile> --no-pings"
case older:
c.Hint = "Only one connector should answer Pings, and an older build can't be told: upgrade it, or start this one with --no-pings: basecamp connect -P " + richtext.ShellQuote(p.name) + " --no-pings"
default:
c.Hint = "Only one connector should answer Pings. Start all but one of them with --no-pings: basecamp connect -P " + richtext.ShellQuote(p.name) + " --no-pings"
}
return c
}
97 changes: 95 additions & 2 deletions internal/commands/connect_operator.go
Loading
Loading