feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94)#107
Merged
Conversation
First slice of the fjbagent foundation. Lands the proto package, the binary skeleton, and the Health RPC — no deploy story yet (cloud-init, systemd, cache base-image come in subsequent slices), no consumer code in the orchestrator yet (agent.Dialer is its own slice). - proto/fjbellows/agent/v1/agent.proto: AgentService.Health - cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz shim. -listen and -token-file flags (operator sets these from the systemd unit when deployment lands). - internal/agent: Handler captures build version + start time + hostname at construction; bearer middleware matches the internal/control auth shape (constant-time compare, /healthz exempt). - Unit tests: Health response shape, bearer accept/reject, /healthz open without token, graceful shutdown, LoadToken validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…B-94) gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(), so the signal-context goroutine leaks on the error path. Moving the work into run() that returns error lets the defer fire before main maps the error to an exit code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
hstern
added a commit
that referenced
this pull request
May 28, 2026
* feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) (#107) * feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) First slice of the fjbagent foundation. Lands the proto package, the binary skeleton, and the Health RPC — no deploy story yet (cloud-init, systemd, cache base-image come in subsequent slices), no consumer code in the orchestrator yet (agent.Dialer is its own slice). - proto/fjbellows/agent/v1/agent.proto: AgentService.Health - cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz shim. -listen and -token-file flags (operator sets these from the systemd unit when deployment lands). - internal/agent: Handler captures build version + start time + hostname at construction; bearer middleware matches the internal/control auth shape (constant-time compare, /healthz exempt). - Unit tests: Health response shape, bearer accept/reject, /healthz open without token, graceful shutdown, LoadToken validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): split main into run() so defer cancel() runs on error (FJB-94) gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(), so the signal-context goroutine leaks on the error path. Moving the work into run() that returns error lets the defer fire before main maps the error to an exit code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): Exec bidi RPC, pipe-mode handler (FJB-93 Phase A) (#108) First slice of FJB-93. Lands the AgentService.Exec bidi-streaming RPC on the agent and a pipe-mode handler (no PTY yet — that's Phase C). The orchestrator-side ShellSession proxy and the fjbctl ssh CLI come in Phase B; the wire envelope shape (ShellMsg / ShellEvent) is already designed to support both phases without a re-do. Wire shape: - ShellMsg: oneof Open | Stdin | Resize | Signal | CloseStdin - ShellEvent: oneof Opened | Stdout | Stderr | Exit - ExecOpen: target (set by fjbctl, stripped by orchestrator), argv, env, tty (rejected with CodeUnimplemented this phase), term, winsize - ExecExit: code on normal exit, signal name on signal kill Handler behavior: - First frame must be Open or returns CodeInvalidArgument. - tty=true returns CodeUnimplemented. - argv exec'd directly (no shell interpolation); env is exact (no agent-env passthrough, matches the design doc). - exec.Cmd.Stdout/Stderr go through a streamWriter that feeds a buffered sendBuf channel; a single sender goroutine owns the bidi stream's Send side so we never need to mutex Connect's BidiStream. - cmd.Wait blocks until both child exit AND exec's internal copy goroutines have drained, so all output frames precede Exit by construction (avoids the StdoutPipe-vs-Wait race). - Child runs in a fresh process group (Setpgid); signals are delivered to the whole group via kill(-pgid). - Command-not-found surfaces as on-stream Exit{127} (sh convention); signal kills surface as Exit{code: 128+signum, signal: <name>}. Tests: 11 cases covering echo, stdin pump + CloseStdin, non-zero exit, stderr capture, env passthrough, tty/empty-argv rejection, command-not-found, signal kill, first-frame-must-be-Open, bearer auth gating. `go test -race -count=10` clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99) Foundation for the per-deployment ephemeral cache flow that FJB-91 called out as missing: each cache provisioned by fj-bellows now comes up with its own WG keypair, brings up wg-quick@wg0 referencing the orchestrator's pubkey, and publishes its own pubkey to S3 so the orchestrator can read it back in Phase B. What lands (Phase A): - main.go loads or generates the orchestrator's WG keypair BEFORE prov.Configure runs (was deferred until wgboot.Boot). Pubkey is pushed into the Linode provider via SetOrchestratorWGPubkey for the cache cloud-init injector. - managedCache carries orchestratorWGPubkey and renders four new fields into cacheCloudInitParams (OrchestratorWGPubkey, Orchestrator- WGAddr, CacheWGAddr, WGListenPort) when transport.mode is cache-gateway. - cache-cloud-init.yaml.tmpl conditionally installs wireguard + awscli, drops /usr/local/sbin/fjb-wg-bootstrap.sh, and runs it from runcmd. The script: - generates a Curve25519 keypair on first boot (idempotent — re- runs reuse the persisted private key); - writes /etc/wireguard/wg0.conf with the orchestrator pubkey baked in as [Peer].PublicKey and the cache's address pinned at 100.64.0.2/32; - brings up wg-quick@wg0; - publishes /etc/wireguard/publickey to s3://<bucket>/wg-pubkey.txt using the same Object Storage credentials already shared with zot — no new identity to provision. - ssh-mode renders byte-identical to before (the WG bootstrap block is gated on OrchestratorWGPubkey). Out of Phase A (lands in Phase B/C): - Orchestrator-side WaitForWGPubkey polling against the bucket + feeding the discovered pubkey into wgboot.Boot as the peer config. - bootWGStack ordering reorder (today still wgboot before cache.Ensure; Phase B moves it after). - e2e harness reactivation: drop the persistent-cache preflight, use the ephemeral cache + bootstrap, and reactivate the worker readiness + job-complete checks. - Drop the static transport.wg.peer.{public_key,endpoint} config knobs. Tests cover the cache-gateway and ssh-mode render branches; verified end-to-end against live Linode via test/e2e-linode/run-local.sh --transport=cache-gateway (stack-up scope from FJB-91 still passes — the new code path is exercised but worker validation is the Phase C deliverable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hstern
added a commit
that referenced
this pull request
May 28, 2026
…FJB-98) (#109) * feat(linode): bake fjb-iptables FORWARD chain into cache cloud-init (FJB-98) Cache nanodes provisioned by fj-bellows in cache-gateway mode now come up with the FJB-FORWARD chain installed at first boot, persisted via iptables-persistent. Previously the chain was only rendered into a log line (`wgboot: cache iptables re-rendered (TODO: push via SSH)`) and had to be hand-installed — the persistent test nanode for FJB-91 was the only place this happened, and a rebuild would lose the FORWARD policy entirely. What lands: - managedCache learns the worker VPC CIDR via setHardwareContext, then renders cachegateway.RenderCacheIPTables at cache-create time and embeds the script in cloud-init under /usr/local/sbin/fjb-iptables.sh. - cache-cloud-init.yaml.tmpl conditionally installs iptables-persistent, writes the script, runs it from runcmd, and saves via netfilter-persistent. ssh-mode renders byte-identical to before. - cachegateway Inputs.CacheVPCIP is now optional: the renderer never used it in the script body, and the bake-in happens before any VPC IP has been assigned. wgboot still passes it (eager Status lookup). ACL-derived per-prefix ACCEPT rules are intentionally omitted from the bake-in: the ACL registry isn't wired until wgboot starts (post-Configure), so the script that lands at create time is the skeleton (sysctl + ESTABLISHED,RELATED + FJB-92 reverse-direction + DROP). Runtime push of ACL-updated rules is the wgboot TODO already noted in code; it lands with FJB-99's ephemeral-cache bootstrap loop. Tests cover the cache-gateway and ssh-mode render branches and the renderIPTablesForCacheGateway transport-mode guard. Verified end-to-end against live Linode via test/e2e-linode/run-local.sh --transport=cache-gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99) (#110) * feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) (#107) * feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) First slice of the fjbagent foundation. Lands the proto package, the binary skeleton, and the Health RPC — no deploy story yet (cloud-init, systemd, cache base-image come in subsequent slices), no consumer code in the orchestrator yet (agent.Dialer is its own slice). - proto/fjbellows/agent/v1/agent.proto: AgentService.Health - cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz shim. -listen and -token-file flags (operator sets these from the systemd unit when deployment lands). - internal/agent: Handler captures build version + start time + hostname at construction; bearer middleware matches the internal/control auth shape (constant-time compare, /healthz exempt). - Unit tests: Health response shape, bearer accept/reject, /healthz open without token, graceful shutdown, LoadToken validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(agent): split main into run() so defer cancel() runs on error (FJB-94) gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(), so the signal-context goroutine leaks on the error path. Moving the work into run() that returns error lets the defer fire before main maps the error to an exit code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(agent): Exec bidi RPC, pipe-mode handler (FJB-93 Phase A) (#108) First slice of FJB-93. Lands the AgentService.Exec bidi-streaming RPC on the agent and a pipe-mode handler (no PTY yet — that's Phase C). The orchestrator-side ShellSession proxy and the fjbctl ssh CLI come in Phase B; the wire envelope shape (ShellMsg / ShellEvent) is already designed to support both phases without a re-do. Wire shape: - ShellMsg: oneof Open | Stdin | Resize | Signal | CloseStdin - ShellEvent: oneof Opened | Stdout | Stderr | Exit - ExecOpen: target (set by fjbctl, stripped by orchestrator), argv, env, tty (rejected with CodeUnimplemented this phase), term, winsize - ExecExit: code on normal exit, signal name on signal kill Handler behavior: - First frame must be Open or returns CodeInvalidArgument. - tty=true returns CodeUnimplemented. - argv exec'd directly (no shell interpolation); env is exact (no agent-env passthrough, matches the design doc). - exec.Cmd.Stdout/Stderr go through a streamWriter that feeds a buffered sendBuf channel; a single sender goroutine owns the bidi stream's Send side so we never need to mutex Connect's BidiStream. - cmd.Wait blocks until both child exit AND exec's internal copy goroutines have drained, so all output frames precede Exit by construction (avoids the StdoutPipe-vs-Wait race). - Child runs in a fresh process group (Setpgid); signals are delivered to the whole group via kill(-pgid). - Command-not-found surfaces as on-stream Exit{127} (sh convention); signal kills surface as Exit{code: 128+signum, signal: <name>}. Tests: 11 cases covering echo, stdin pump + CloseStdin, non-zero exit, stderr capture, env passthrough, tty/empty-argv rejection, command-not-found, signal kill, first-frame-must-be-Open, bearer auth gating. `go test -race -count=10` clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99) Foundation for the per-deployment ephemeral cache flow that FJB-91 called out as missing: each cache provisioned by fj-bellows now comes up with its own WG keypair, brings up wg-quick@wg0 referencing the orchestrator's pubkey, and publishes its own pubkey to S3 so the orchestrator can read it back in Phase B. What lands (Phase A): - main.go loads or generates the orchestrator's WG keypair BEFORE prov.Configure runs (was deferred until wgboot.Boot). Pubkey is pushed into the Linode provider via SetOrchestratorWGPubkey for the cache cloud-init injector. - managedCache carries orchestratorWGPubkey and renders four new fields into cacheCloudInitParams (OrchestratorWGPubkey, Orchestrator- WGAddr, CacheWGAddr, WGListenPort) when transport.mode is cache-gateway. - cache-cloud-init.yaml.tmpl conditionally installs wireguard + awscli, drops /usr/local/sbin/fjb-wg-bootstrap.sh, and runs it from runcmd. The script: - generates a Curve25519 keypair on first boot (idempotent — re- runs reuse the persisted private key); - writes /etc/wireguard/wg0.conf with the orchestrator pubkey baked in as [Peer].PublicKey and the cache's address pinned at 100.64.0.2/32; - brings up wg-quick@wg0; - publishes /etc/wireguard/publickey to s3://<bucket>/wg-pubkey.txt using the same Object Storage credentials already shared with zot — no new identity to provision. - ssh-mode renders byte-identical to before (the WG bootstrap block is gated on OrchestratorWGPubkey). Out of Phase A (lands in Phase B/C): - Orchestrator-side WaitForWGPubkey polling against the bucket + feeding the discovered pubkey into wgboot.Boot as the peer config. - bootWGStack ordering reorder (today still wgboot before cache.Ensure; Phase B moves it after). - e2e harness reactivation: drop the persistent-cache preflight, use the ephemeral cache + bootstrap, and reactivate the worker readiness + job-complete checks. - Drop the static transport.wg.peer.{public_key,endpoint} config knobs. Tests cover the cache-gateway and ssh-mode render branches; verified end-to-end against live Linode via test/e2e-linode/run-local.sh --transport=cache-gateway (stack-up scope from FJB-91 still passes — the new code path is exercised but worker validation is the Phase C deliverable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First slice of the fjbagent foundation (FJB-94). Lands the proto package + binary skeleton + Health RPC. No deploy story yet (cloud-init, systemd, cache base-image come in Phase B/C), no consumer code yet in the orchestrator (
agent.Dialeris Phase C). Self-contained, local-only.proto/fjbellows/agent/v1/agent.proto—AgentService.Healthreturning build version + start time + hostname. Forward-compatible shape; FJB-93 addsExec, FJB-97 reuses Health for the gRPC reachability probe.cmd/fjbagent— static Go binary, ConnectRPC server, bearer auth, plain/healthzshim. Flags:-listen,-token-file,-log-level.internal/agent—Handlercaptures process-scope health facts once at construction; bearer middleware matches theinternal/controlshape (constant-time compare,/healthzexempt, empty token disables for tests).LoadTokenrejects empty / whitespace-only files.Test plan
go test ./internal/agent/— 8 cases (Health, bearer accept/reject/wrong-token, /healthz exempt, graceful shutdown on ctx cancel, LoadToken happy/empty/whitespace/missing)go test -race ./internal/agent/go test ./...— full repo, no regressionsgo vet ./...,gofmt,make proto-checkcleango build ./cmd/fjbagentproduces a working binary with-houtputNo Linode e2e required for this slice — no infra code path is touched yet. e2e gate kicks in for Phase B (worker cloud-init) and Phase C (cache deploy + orchestrator dialer).
Design
Per
docs/designs/remote-shell.md(FJB-93's design doc, parent of the agent shape). Bearer auth pattern lifted frominternal/control/auth.goso workers and the cache have a familiar auth posture even though token sources differ.🤖 Generated with Claude Code