Skip to content

Commit 7aee241

Browse files
committed
feat: agent service improvements, Docker publishing, platform-aware CLI
Service fixes: - Fix macOS status detection: use launchctl print instead of launchctl list which can't see system daemons without root - Fix empty server URL in plist during launchd throttle recovery - Service data lives in /var/lib/plow-agent/ instead of user home dir - Credentials copied from user config to service dir during install - Always re-register on startup (idempotent) to handle server rebuilds - Log to stderr so daemon logs appear in /var/log/plow-agent.err.log CLI improvements: - --service logs: tails the right log source per platform - --service status: shows platform, data dir, status, and all commands - Wizard and help messages are platform-aware (macOS/Linux/Windows) - sudo auto-elevation for service install/uninstall/start/stop Docker: - CI workflow now builds and pushes to ghcr.io/jackharrhy/plow-agent - Added compose.yml for easy volunteer setup - Dockerfile accepts VERSION build arg for proper version stamping - README rewritten with Docker Compose as primary container path
1 parent 0cc2375 commit 7aee241

7 files changed

Lines changed: 439 additions & 110 deletions

File tree

.github/workflows/release-agent.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99

1010
permissions:
1111
contents: write
12+
packages: write
1213

1314
jobs:
1415
release:
@@ -51,3 +52,23 @@ jobs:
5152
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5253
VERSION: ${{ steps.version.outputs.version }}
5354
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.tag }}
55+
56+
- name: Log in to ghcr.io
57+
if: steps.version.outputs.skip != 'true'
58+
uses: docker/login-action@v3
59+
with:
60+
registry: ghcr.io
61+
username: ${{ github.actor }}
62+
password: ${{ secrets.GITHUB_TOKEN }}
63+
64+
- name: Build and push Docker image
65+
if: steps.version.outputs.skip != 'true'
66+
uses: docker/build-push-action@v6
67+
with:
68+
context: agent
69+
push: true
70+
tags: |
71+
ghcr.io/jackharrhy/plow-agent:latest
72+
ghcr.io/jackharrhy/plow-agent:${{ steps.version.outputs.version }}
73+
build-args: |
74+
VERSION=${{ steps.version.outputs.version }}

agent/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
FROM golang:1.25-alpine AS builder
2+
ARG VERSION=dev
23
WORKDIR /build
34
COPY go.mod go.sum ./
45
RUN go mod download
56
COPY *.go ./
6-
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /plow-agent .
7+
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o /plow-agent .
78

89
FROM scratch
910
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

agent/README.md

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ This launches an interactive setup wizard that:
3333

3434
After setup, the agent runs in the background and survives reboots. You don't need to keep a terminal open.
3535

36-
**Note:** Installing a system service usually requires administrator/root privileges. On Linux, run with `sudo`. On macOS, you may get a permissions prompt. On Windows, run as Administrator.
36+
**Note:** Installing a system service requires administrator/root privileges. The wizard will prompt for your sudo password automatically. On Windows, run as Administrator.
37+
38+
**Once you see "Registered" in the output, let Jack know** (message him or open an issue) so he can approve your agent. It won't start fetching data until approved.
3739

3840
## Running interactively
3941

@@ -50,35 +52,86 @@ This runs the agent in the foreground. Press Ctrl+C to stop.
5052
Once installed, you can control the service with:
5153

5254
```
53-
plow-agent --service status # Check if running
55+
plow-agent --service status # Check if running + platform info
56+
plow-agent --service logs # View live logs (auto-detects platform)
5457
plow-agent --service stop # Stop the service
5558
plow-agent --service start # Start the service
5659
plow-agent --service restart # Restart the service
5760
plow-agent --service uninstall # Remove the service
5861
```
5962

60-
### Viewing logs
63+
The `status` and `logs` commands automatically detect your platform and show the right information -- no need to remember platform-specific log commands.
64+
65+
### Complete removal
66+
67+
To fully uninstall the agent and clean up all its data:
68+
69+
```bash
70+
# 1. Stop and remove the system service
71+
plow-agent --service uninstall
72+
73+
# 2. Remove service data (credentials used by the daemon)
74+
sudo rm -rf /var/lib/plow-agent
75+
76+
# 3. Remove your local config (keypair and name)
77+
rm -rf ~/.config/plow-agent
78+
79+
# 4. Remove the binary itself
80+
rm plow-agent
81+
```
82+
83+
On macOS, if the service is stuck, you can force-remove it:
84+
85+
```bash
86+
sudo launchctl bootout system/plow-agent
87+
sudo rm /Library/LaunchDaemons/plow-agent.plist
88+
```
89+
90+
## Docker / Docker Compose
91+
92+
The agent Docker image is published to `ghcr.io/jackharrhy/plow-agent` automatically on every release.
93+
94+
The easiest way to run it is with the included [`compose.yml`](compose.yml):
95+
96+
1. Download it: `curl -O https://raw.githubusercontent.com/jackharrhy/where-the-plow/main/agent/compose.yml`
97+
2. Edit `PLOW_NAME` to something that identifies you (e.g. "alice-homelab")
98+
3. Run it:
99+
100+
```bash
101+
docker compose up -d
102+
```
103+
104+
That's it. The volume keeps your keypair across restarts. Let Jack know once it's running so he can approve your agent.
105+
106+
To view logs:
61107

62-
- **Linux (systemd):** `journalctl -u plow-agent -f`
63-
- **macOS (launchd):** `log show --predicate 'process == "plow-agent"' --last 1h`
64-
- **Windows:** Event Viewer > Application logs
108+
```bash
109+
docker compose logs -f
110+
```
65111

66-
## Docker
112+
To stop and remove:
67113

114+
```bash
115+
docker compose down
116+
docker volume rm plow-agent_plow-agent-data # removes credentials
68117
```
118+
119+
### Docker run (without Compose)
120+
121+
```bash
69122
docker run -d \
70123
--name plow-agent \
124+
--restart unless-stopped \
71125
-e PLOW_SERVER=https://plow.jackharrhy.dev \
72126
-e PLOW_NAME=your-name-here \
127+
-e PLOW_DATA_DIR=/data \
73128
-v plow-agent-data:/data \
74129
ghcr.io/jackharrhy/plow-agent:latest
75130
```
76131

77-
The volume keeps your keypair across container restarts. Set `PLOW_NAME` to something that identifies you -- the server operator sees this when approving agents.
78-
79-
To build the image yourself:
132+
### Building locally
80133

81-
```
134+
```bash
82135
docker build -t plow-agent agent/
83136
```
84137

@@ -98,31 +151,34 @@ This creates a small PVC for key persistence and a Deployment running the agent.
98151
|---|---|---|
99152
| `--server` / `PLOW_SERVER` | Yes (for `--run`) | Plow server URL |
100153
| `--run` | No | Run in foreground instead of installing as service |
101-
| `--service <action>` | No | Control installed service: install, uninstall, start, stop, restart, status |
154+
| `--service <action>` | No | Control installed service: install, uninstall, start, stop, restart, status, logs |
102155
| `PLOW_NAME` | Docker/K8s only | Agent name (binary prompts interactively) |
103156
| `PLOW_DATA_DIR` | No | Override config directory (default: `~/.config/plow-agent/`, or `/data` when set) |
104157

105-
## What gets stored locally
158+
## What gets stored
106159

107-
The agent saves two files in its config directory:
160+
The agent stores two files:
108161

109162
- `key.pem` -- your ECDSA private key (never sent to the server, only used to sign requests)
110163
- `name` -- the name you chose for this agent
111164

112-
On a binary install these live in `~/.config/plow-agent/`. In Docker/K8s they live in the `/data` volume.
165+
**Interactive / `--run` mode:** stored in `~/.config/plow-agent/`
166+
167+
**System service:** stored in `/var/lib/plow-agent/` (copied there during install so the daemon can access them as root)
168+
169+
**Docker/K8s:** stored in the `/data` volume
113170

114171
## Checking your status
115172

116173
The agent logs its current status on startup and during operation:
117174

118175
```
119176
2026/02/25 14:30:00 Agent ID: a1b2c3d4e5f67890
120-
2026/02/25 14:30:00 Server: https://plow.jackharrhy.dev
121-
2026/02/25 14:30:01 Registered! Waiting for approval...
177+
2026/02/25 14:30:01 Registered: agent_id=a1b2c3d4e5f67890 status=pending
122178
2026/02/25 14:30:01 Status: pending — waiting for approval (checking every 30s)
123179
```
124180

125-
Once approved:
181+
Once Jack approves your agent:
126182

127183
```
128184
2026/02/25 14:35:01 Approved! Fetching every 18s (offset 6s)

agent/client.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ type Schedule struct {
1919
Headers map[string]string `json:"headers"`
2020
}
2121

22-
// register sends a POST /agents/register request to the server.
23-
func register(cfg *Config) {
22+
// tryRegister sends a POST /agents/register request to the server.
23+
// Registration is idempotent — if the agent is already known the server
24+
// returns the current status. Returns nil on success.
25+
func tryRegister(cfg *Config) error {
2426
hostname, _ := os.Hostname()
2527
systemInfo := fmt.Sprintf("%s/%s %s", runtime.GOOS, runtime.GOARCH, hostname)
2628

@@ -31,32 +33,38 @@ func register(cfg *Config) {
3133
}
3234
body, err := json.Marshal(payload)
3335
if err != nil {
34-
log.Fatalf("Failed to marshal register payload: %v", err)
36+
return fmt.Errorf("marshal register payload: %w", err)
3537
}
3638

3739
url := cfg.server + "/agents/register"
3840
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
3941
if err != nil {
40-
log.Fatalf("Registration failed: %v", err)
42+
return fmt.Errorf("register request: %w", err)
4143
}
4244
defer resp.Body.Close()
4345

4446
respBody, _ := io.ReadAll(resp.Body)
4547
if resp.StatusCode != 200 {
46-
log.Fatalf("Registration failed (HTTP %d): %s", resp.StatusCode, respBody)
48+
return fmt.Errorf("register HTTP %d: %s", resp.StatusCode, respBody)
4749
}
4850

4951
var result struct {
5052
AgentID string `json:"agent_id"`
5153
Status string `json:"status"`
5254
}
5355
if err := json.Unmarshal(respBody, &result); err != nil {
54-
log.Fatalf("Failed to parse register response: %v", err)
56+
return fmt.Errorf("parse register response: %w", err)
5557
}
5658

57-
log.Printf("Registered! Agent ID: %s, Status: %s", result.AgentID, result.Status)
58-
if result.Status == "pending" {
59-
log.Printf("Waiting for approval...")
59+
log.Printf("Registered: agent_id=%s status=%s", result.AgentID, result.Status)
60+
return nil
61+
}
62+
63+
// register calls tryRegister and fatals on error. Used by the interactive
64+
// wizard where failure should be immediately visible.
65+
func register(cfg *Config) {
66+
if err := tryRegister(cfg); err != nil {
67+
log.Fatalf("Registration failed: %v", err)
6068
}
6169
}
6270

agent/compose.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
services:
2+
plow-agent:
3+
image: ghcr.io/jackharrhy/plow-agent:latest
4+
restart: unless-stopped
5+
environment:
6+
- PLOW_SERVER=https://plow.jackharrhy.dev
7+
- PLOW_NAME=your-name-here # Change this to identify yourself
8+
- PLOW_DATA_DIR=/data
9+
volumes:
10+
- plow-agent-data:/data
11+
12+
volumes:
13+
plow-agent-data:

0 commit comments

Comments
 (0)