50 Fleet Management Agent: .deb Packaging, CLI Tool & CI/CD — Spec Template
What this document is: A reusable spec template for any system that follows the centralized cloud console + distributed local agent pattern. Replace the
{PLACEHOLDER}tokens with your project’s actual names, then hand the spec to an implementer (human or AI).
50.1 Placeholder Reference
Fill in this table first. Every {TOKEN} in the document resolves from here.
| Token | Description | Example |
|---|---|---|
{PRODUCT_NAME} |
Human-readable product name | Fleet Manager |
{SOLUTION_NAME} |
.NET solution file name (no extension) | FleetManager |
{AGENT_PKG} |
Linux package name (lowercase, hyphens) | fleet-agent |
{AGENT_PROJECT} |
.NET project name for the agent | Agent.Worker |
{CLI_NAME} |
CLI binary name (lowercase, hyphens) | fleet-ctl |
{CLI_PROJECT} |
.NET project name for the CLI | Fleet.Cli |
{CONSOLE_PROJECT} |
.NET project name for the cloud console | CloudConsole.Api |
{SHARED_PROJECT} |
.NET project name for shared contracts | Shared.Contracts |
{SERVICE_USER} |
Linux system user the agent runs as | fleet-agent |
{HUB_ENDPOINT} |
SignalR hub path | /hub/fleet |
{GITHUB_ORG} |
GitHub organization or username | your-org |
{GITHUB_REPO} |
GitHub repository name | FleetManager |
{MAINTAINER_EMAIL} |
Package maintainer email | infra@example.com |
50.2 Objective
Set up the {AGENT_PROJECT} as a .deb-installable system daemon distributed via GitHub Releases, and provide a {CLI_NAME} command-line tool for admins to interact with the cloud console. A GitHub Actions workflow orchestrates test, build, package, and release on every version tag.
50.3 Target Outcome
Developer pushes tag GitHub Actions Target Server
───────────────────── ─────────────────────── ─────────────────────
git tag v1.2.0 ───► test → build .deb ───► curl -LO ...deb
git push --tags test → build CLI (matrix) sudo dpkg -i {{AGENT_PKG}}_1.2.0_amd64.deb
create GitHub Release ✓ Agent running as systemd service
attach all artifacts
Admin workstation (macOS / Linux)
─────────────────────────────────
{{CLI_NAME}} login --url https://console.example.com
{{CLI_NAME}} agents list
{{CLI_NAME}} agents status <agent-id>
{{CLI_NAME}} apps restart <agent-id> <app-id>
{{CLI_NAME}} audit --last 20
50.4 Part A: Architecture Context
This spec assumes a system with three components and a shared contract library:
┌────────────────────────────────────────────────────────┐
│ {{CONSOLE_PROJECT}} │
│ (Cloud Console — ASP.NET Core) │
│ │
│ REST API SignalR Hub ({{HUB_ENDPOINT}}) │
│ /api/* bidirectional real-time │
└───────┬──────────────────────┬─────────────────────────┘
│ │
│ HTTP │ WebSocket
│ │
┌───────▼──────────┐ ┌──────▼──────────────────┐
│ {{CLI_NAME}} │ │ {{AGENT_PROJECT}} │
│ (Admin CLI) │ │ (Worker Service daemon) │
│ per-user tool │ │ per-server daemon │
└──────────────────┘ └─────────────────────────┘
Both reference:
{{SHARED_PROJECT}} — DTOs, hub interfaces, enums, command types
50.4.1 Dependency Rule
{{CONSOLE_PROJECT}} ──► {{SHARED_PROJECT}} ◄── {{AGENT_PROJECT}}
▲
│
{{CLI_PROJECT}}
No horizontal references between executable projects.
50.5 Part B: Repository Structure (New Files Only)
Do not modify existing source code unless explicitly stated.
{{SOLUTION_NAME}}/
├── (existing src/, docs/, {{SOLUTION_NAME}}.sln, README.md, CLAUDE.md)
│
├── src/
│ ├── (existing {{SHARED_PROJECT}}/, {{CONSOLE_PROJECT}}/, {{AGENT_PROJECT}}/)
│ │
│ └── {{CLI_PROJECT}}/ # NEW
│ ├── {{CLI_PROJECT}}.csproj
│ ├── Program.cs
│ ├── Commands/
│ │ ├── LoginCommand.cs
│ │ ├── AgentsCommand.cs
│ │ ├── AppsCommand.cs
│ │ └── AuditCommand.cs
│ ├── Services/
│ │ ├── CloudApiClient.cs
│ │ └── CliConfigManager.cs
│ └── Models/
│ └── CliConfig.cs
│
├── packaging/ # NEW
│ ├── build-deb.sh
│ ├── DEBIAN/
│ │ ├── control.template
│ │ ├── conffiles
│ │ ├── postinst
│ │ ├── prerm
│ │ └── postrm
│ └── etc/
│ └── {{AGENT_PKG}}/
│ └── agent.env.default
│
├── .github/
│ └── workflows/
│ └── release.yml # NEW
│
└── scripts/
└── install-from-github.sh # NEW
Update {SOLUTION_NAME}.sln to include {CLI_PROJECT}.csproj.
50.6 Part C: CLI Tool (src/{{CLI_PROJECT}}/)
50.6.1 C.1 Overview
{CLI_NAME} is a cross-platform command-line tool for admins to interact with the cloud console REST API. It follows standard CLI conventions: per-user config in ~/.config/, human-readable table output by default, JSON output with --json.
{{CLI_NAME}} (CLI, per-user) {{AGENT_PKG}} (daemon, system-wide)
──────────────────────────── ──────────────────────────────────
Config: ~/.config/{{CLI_NAME}}/ Config: /etc/{{AGENT_PKG}}/
Runs as: the human admin Runs as: {{SERVICE_USER}} (system user)
Lifecycle: invoked, runs, exits Lifecycle: always running (systemd)
Talks to: Cloud Console REST API Talks to: Cloud Console SignalR hub
Installed: manual download / extract Installed: .deb package
50.6.2 C.2 Project File
Target framework: net8.0
Output type: Exe
Assembly name: {{CLI_NAME}}
NuGet dependencies:
- System.CommandLine (2.0.0-beta4.*)
Project reference:
- {{SHARED_PROJECT}} (reuse DTOs, enums, command types)
Do NOT reference {{CONSOLE_PROJECT}} or {{AGENT_PROJECT}}.
50.6.3 C.3 Config Location & Format
Path: ~/.config/{{CLI_NAME}}/config.json
Follows XDG Base Directory Specification. On Linux, use $XDG_CONFIG_HOME if set, otherwise ~/.config. On macOS, use ~/.config as well (not ~/Library — CLI tools conventionally use XDG even on macOS).
{
"consoleUrl": "https://console.example.com",
"apiKey": "sk-...",
"defaultOutput": "table"
}CliConfigManager service responsibilities: - First-run: create ~/.config/{{CLI_NAME}}/ directory and default config - Read: deserialize from JSON - Write: serialize to JSON, set file permissions to 600 on Unix (protect API key) - Validate: check that consoleUrl is set before any API command; if not, print clear error and exit
50.6.4 C.4 Command Tree
{{CLI_NAME}}
│
├── login Set console URL and credentials
│ Options:
│ --url <url> Console URL (required)
│ --api-key <key> API key (optional; prompt interactively if omitted)
│
├── agents Agent fleet management
│ ├── list List all registered agents
│ │ Options:
│ │ --json Machine-readable JSON output
│ │ --online-only Show only connected agents
│ │
│ └── status <agentId> Detailed status of one agent
│ Options:
│ --json Machine-readable JSON output
│
├── apps Managed application commands
│ ├── restart <agentId> <appId> Restart a managed app
│ │
│ ├── config <agentId> <appId> Push configuration
│ │ Options:
│ │ --set <key=value> Repeatable; one or more key=value pairs
│ │
│ └── update <agentId> <appId> Trigger app update
│ Options:
│ --version <ver> Target version (required)
│ --artifact <url> Download URL for the new package (required)
│
└── audit View audit log
Options:
--last <n> Number of entries (default: 20)
--agent <agentId> Filter by agent
--json Machine-readable JSON output
50.6.5 C.5 Command Implementations
Each command class constructs a System.CommandLine.Command with arguments, options, and an async handler. All commands that call the API should check config validity first and exit with code 1 + message if not configured.
50.6.5.1 login
- Accept
--url(required) and--api-key(optional). - If
--api-keyomitted → prompt interactively with masked input (*per keystroke). - Validate URL format.
- Test connectivity:
GET {url}/api/agents. On success → save config. On failure → show error, do NOT save. - Print:
Logged in to {url}. Config saved to ~/.config/{{CLI_NAME}}/config.json
50.6.5.2 agents list
GET /api/agentsTable output (default):
AGENT ID HOSTNAME STATUS CPU MEM (MB) APPS agent-001 srv-alpha online 23.4% 128 2 agent-002 srv-beta offline - - 3--json→ raw JSON array from API.--online-only→ client-side filter.
50.6.5.3 agents status <agentId>
GET /api/agents/{agentId}On 404 →
Agent '{agentId}' not found.+ exit code 1.Detailed output:
Agent: agent-001 Hostname: srv-alpha OS: Ubuntu 24.04 LTS Status: online Last Seen: 2026-03-07 10:30:15 UTC Metrics: CPU: 23.4% MEM: 128.3 MB Disk: 52.1% Managed Apps: NAME VERSION STATUS app-alpha 1.2.0 Running app-beta 3.0.1 Running
50.6.5.4 apps restart <agentId> <appId>
POST /api/agents/{agentId}/restart-appwith{"appId": "<appId>"}202→Restart command sent to {appId} on {agentId}.404→Agent '{agentId}' not connected.
50.6.5.5 apps config <agentId> <appId>
- Parse
--set key=valueinto dictionary. Error if none provided. POST /api/agents/{agentId}/push-configwith{"appId": "...", "settings": {...}}202→Config pushed to {appId} on {agentId}: key1, key2, ...
50.6.5.6 apps update <agentId> <appId>
--versionand--artifactboth required.POST /api/agents/{agentId}/update-appwith{"appId":"...","targetVersion":"...","artifactUrl":"..."}202→Update command sent: {appId} → v{version} on {agentId}.
50.6.5.7 audit
GET /api/auditClient-side: take last N, optionally filter by agent.
Table output:
TIMESTAMP AGENT CATEGORY MESSAGE 2026-03-07 10:30:15 agent-001 AppLifecycle App app-alpha restarted 2026-03-07 10:28:03 agent-001 Config Config pushed to app-beta--json→ raw JSON array.
50.6.6 C.6 CloudApiClient Service
Thin wrapper around HttpClient: - Base URL from CliConfigManager - Sets Authorization: Bearer {apiKey} if apiKey is non-empty - Sets Accept: application/json - One method per API endpoint (mirrors REST API surface) - Non-success status codes → descriptive error or result type for the command to format
50.6.7 C.7 Program.cs
Use System.CommandLine to build the command tree. Manual construction — do NOT use Microsoft.Extensions.DependencyInjection. Instantiate CliConfigManager and CloudApiClient in Main, pass to command builders.
50.7 Part D: .deb Packaging (packaging/)
50.7.1 D.1 DEBIAN/control.template
Package: {{AGENT_PKG}}
Version: {{VERSION}}
Section: admin
Priority: optional
Architecture: amd64
Depends: libicu74 | libicu72 | libicu70, libssl3 | libssl1.1
Maintainer: {{PRODUCT_NAME}} Team <{{MAINTAINER_EMAIL}}>
Homepage: https://github.com/{{GITHUB_ORG}}/{{GITHUB_REPO}}
Description: {{PRODUCT_NAME}} Agent
<one-line description of what the agent does>
{VERSION} is substituted by build-deb.sh at build time.
50.7.2 D.2 DEBIAN/conffiles
/etc/{{AGENT_PKG}}/agent.env
Tells dpkg to preserve admin’s config on upgrade.
50.7.3 D.3 DEBIAN/postinst
#!/bin/bash, set -e. Must handle both fresh install and upgrade.
1. Create {{SERVICE_USER}} system user if not exists
useradd --system --no-create-home --shell /usr/sbin/nologin {{SERVICE_USER}}
2. mkdir -p /var/lib/{{AGENT_PKG}}
3. chown -R {{SERVICE_USER}}:{{SERVICE_USER}} /opt/{{AGENT_PKG}}
4. chown -R {{SERVICE_USER}}:{{SERVICE_USER}} /var/lib/{{AGENT_PKG}}
5. chown {{SERVICE_USER}}:{{SERVICE_USER}} /etc/{{AGENT_PKG}}/agent.env
6. chmod 600 /etc/{{AGENT_PKG}}/agent.env
7. chmod +x /opt/{{AGENT_PKG}}/{{AGENT_BINARY}}
8. systemctl daemon-reload
9. systemctl enable {{AGENT_PKG}}
10. If already active → systemctl restart. Else → systemctl start.
({AGENT_BINARY} = the published executable name, e.g., Agent.Worker)
50.7.4 D.4 DEBIAN/prerm
1. systemctl stop {{AGENT_PKG}} (if active)
2. systemctl disable {{AGENT_PKG}} (ignore errors)
50.7.5 D.5 DEBIAN/postrm
1. If "$1" = "purge":
userdel {{SERVICE_USER}}
rm -rf /var/lib/{{AGENT_PKG}}
rm -rf /etc/{{AGENT_PKG}}
2. Always: systemctl daemon-reload
50.7.6 D.6 etc/{{AGENT_PKG}}/agent.env.default
Template shipped in package. Copied to agent.env at build time. Example:
DOTNET_ENVIRONMENT=Production
# {{CONSOLE_PROJECT}}__HubUrl=https://console.example.com{{HUB_ENDPOINT}}
# Agent__Id=agent-CHANGEME
# Agent__HeartbeatIntervalSec=30Adapt the environment variable names to match your project’s IConfiguration binding.
50.8 Part E: Build Script (packaging/build-deb.sh)
50.8.1 Interface
./packaging/build-deb.sh <version>
# Output: ./dist/{{AGENT_PKG}}_<version>_amd64.deb50.8.2 Steps
1. Validate version argument matches N.N.N pattern.
2. dotnet publish src/{{AGENT_PROJECT}} -c Release \
--self-contained true \
-r linux-x64 \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-o ./tmp-publish
3. Create staging directory mirroring filesystem:
./tmp-deb/{{AGENT_PKG}}_<version>_amd64/
├── DEBIAN/
├── opt/{{AGENT_PKG}}/
├── etc/{{AGENT_PKG}}/
├── etc/systemd/system/
└── var/lib/{{AGENT_PKG}}/
4. Copy files:
Published binary + appsettings.json → opt/{{AGENT_PKG}}/
systemd unit file → etc/systemd/system/
agent.env.default → etc/{{AGENT_PKG}}/agent.env
DEBIAN/* → DEBIAN/
5. Substitute {{VERSION}} in DEBIAN/control.
6. Set permissions: scripts 755, metadata 644.
7. dpkg-deb --build --root-owner-group <staging> ./dist/
8. dpkg-deb --info on the output for verification.
9. Clean up temp directories.
Requirements: set -euo pipefail, runnable from repo root, output to ./dist/.
50.9 Part F: systemd Service File
Place at docs/{{AGENT_PKG}}.service. This is the canonical version included in the .deb.
[Unit]
Description={{PRODUCT_NAME}} Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
WorkingDirectory=/opt/{{AGENT_PKG}}
ExecStart=/opt/{{AGENT_PKG}}/{{AGENT_BINARY}}
User={{SERVICE_USER}}
Group={{SERVICE_USER}}
EnvironmentFile=/etc/{{AGENT_PKG}}/agent.env
Restart=always
RestartSec=10
StartLimitIntervalSec=60
StartLimitBurst=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/{{AGENT_PKG}}
ReadOnlyPaths=/opt/{{AGENT_PKG}}
# Resource limits (adjust to your workload)
MemoryMax=512M
CPUQuota=50%
SyslogIdentifier={{AGENT_PKG}}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target50.10 Part G: GitHub Actions Workflow (.github/workflows/release.yml)
50.10.1 Trigger
on:
push:
tags:
- 'v*.*.*'50.10.2 Job Graph
push tag v1.2.0
│
▼
┌──────────┐ ┌──────────────┐
│ test │────►│ build-deb │────┐
│ │ │ │ │
│ restore │ │ publish │ │
│ build │ │ build .deb │ │ ┌──────────────┐
│ test │ │ upload │ ├────►│ release │
│ │ └──────────────┘ │ │ │
│ │ │ │ download all │
│ │ ┌──────────────┐ │ │ create GH │
│ │────►│ build-cli │────┘ │ release │
│ │ │ │ │ attach: │
└──────────┘ │ matrix: │ │ .deb │
│ linux-x64 │ │ cli tarballs│
│ macos-x64 │ └──────────────┘
│ macos-arm64 │
└──────────────┘
50.10.3 Job 1: test
Ubuntu-latest. Steps: 1. Checkout 2. Setup .NET SDK (actions/setup-dotnet@v4) 3. dotnet restore 4. dotnet build --no-restore 5. dotnet test --no-build --verbosity normal
50.10.4 Job 2: build-deb (needs: test)
Ubuntu-latest. Steps: 1. Checkout 2. Setup .NET SDK 3. Extract version: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT 4. Run: ./packaging/build-deb.sh $VERSION 5. Upload artifact: ./dist/{{AGENT_PKG}}_*.deb
50.10.5 Job 3: build-cli (needs: test)
Ubuntu-latest. Strategy matrix:
runtime |
os-label |
|---|---|
linux-x64 |
linux-x64 |
osx-x64 |
macos-x64 |
osx-arm64 |
macos-arm64 |
Steps per matrix entry: 1. Checkout 2. Setup .NET SDK 3. dotnet publish src/{{CLI_PROJECT}} -c Release --self-contained -r $RUNTIME -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ./dist/cli/$OS_LABEL 4. tar -czf {{CLI_NAME}}-$OS_LABEL.tar.gz -C ./dist/cli/$OS_LABEL . 5. Upload artifact
50.10.6 Job 4: release (needs: [build-deb, build-cli])
Ubuntu-latest. Steps: 1. Download all artifacts 2. softprops/action-gh-release@v2: - files: .deb + all CLI tarballs - generate_release_notes: true - env: GITHUB_TOKEN
Permissions: contents: write
50.11 Part H: Convenience Installer (scripts/install-from-github.sh)
50.11.1 Usage
# Latest
curl -fsSL https://raw.githubusercontent.com/{{GITHUB_ORG}}/{{GITHUB_REPO}}/main/scripts/install-from-github.sh | sudo bash
# Specific version
curl -fsSL ... | sudo bash -s -- 1.2.050.11.2 Logic
1. Accept optional version arg. If omitted → query GitHub API for latest release tag.
2. Download: https://github.com/{{GITHUB_ORG}}/{{GITHUB_REPO}}/releases/download/v${VERSION}/{{AGENT_PKG}}_${VERSION}_amd64.deb
3. dpkg -i /tmp/{{AGENT_PKG}}.deb
4. Print systemctl status.
5. Remind admin to edit /etc/{{AGENT_PKG}}/agent.env.
50.12 Part I: File Placement Summary
50.12.1 Server (after dpkg -i):
/opt/{{AGENT_PKG}}/ # Binaries (read-only at runtime)
├── {{AGENT_BINARY}} # Self-contained executable
└── appsettings.json # Default app config
/etc/{{AGENT_PKG}}/ # Admin config (conffile, preserved on upgrade)
└── agent.env # Secrets + overrides (mode 600)
/etc/systemd/system/ # Service definition
└── {{AGENT_PKG}}.service
/var/lib/{{AGENT_PKG}}/ # Writable data (service-owned)
systemd: {{AGENT_PKG}}.service → enabled + active
User: {{SERVICE_USER}} (system, no login, no home)
50.12.2 Admin workstation (after CLI extract):
~/bin/{{CLI_NAME}} # Or /usr/local/bin/
~/.config/{{CLI_NAME}}/ # Created on first login
└── config.json # Console URL + API key (mode 600)
50.12.3 Comparison
Agent (daemon) CLI (interactive)
────────────────── ──────────────────
Installed by dpkg / apt Manual extract
Runs as {{SERVICE_USER}} (system) Human admin
Lifecycle Always running (systemd) On-demand, exits
Config path /etc/{{AGENT_PKG}}/ ~/.config/{{CLI_NAME}}/
Data path /var/lib/{{AGENT_PKG}}/ (stateless)
Talks to Console (SignalR/WebSocket) Console (REST API)
Installed on Target servers Admin's workstation
50.13 Part J: Acceptance Criteria
50.13.1 Agent (.deb)
./packaging/build-deb.sh 1.0.0produces./dist/{{AGENT_PKG}}_1.0.0_amd64.debdpkg-deb --infoshows correct metadatadpkg-deb --contentsshows files at correct FHS paths- Fresh install on Ubuntu 22.04/24.04 →
systemctl status {{AGENT_PKG}}shows active - Upgrade with new
.deb→ service restarts,agent.envpreserved dpkg -P {{AGENT_PKG}}→ service stopped, user removed, all files cleaned
50.13.2 CLI
dotnet run --project src/{{CLI_PROJECT}} -- --helpshows full command tree{CLI_NAME} login --url http://localhost:5000creates config file{CLI_NAME} agents listreturns formatted table (console running){CLI_NAME} agents list --jsonreturns valid JSON{CLI_NAME} apps restart <agentId> <appId>sends command, prints confirmation{CLI_NAME} audit --last 5shows recent entries- Any command without prior
login→ clear error:Not configured. Run '{{CLI_NAME}} login' first.
50.13.3 CI/CD
- Pushing
v1.0.0tag triggers: test → build-deb + build-cli → release - GitHub Release shows:
.deb+ CLI tarballs (linux-x64, macos-x64, macos-arm64) install-from-github.shinstalls latest release
50.14 Part K: Out of Scope (Future Work)
These items are explicitly NOT part of this spec. List them to set clear boundaries for the implementer.
- Private APT repository (Aptly, S3, Cloudsmith)
- ARM64
.debbuilds - GPG signing of
.deb - Windows CLI build (
win-x64) {CLI_NAME}as adotnet tool(NuGet global tool)- Homebrew formula for CLI
- Authentication middleware on the Console API
- Interactive TUI mode (e.g., Spectre.Console live dashboard)
- Automated rollback on failed health check
- Console-initiated updates (SignalR push path)
- Integration tests (install
.debin VM/container)
50.15 Appendix: How to Use This Template
50.15.1 Step 1: Fill in the Placeholder Table (Part A header)
50.15.2 Step 2: Search-and-replace all {TOKENS}
sed -i \
-e 's/{{PRODUCT_NAME}}/Fleet Manager/g' \
-e 's/{{SOLUTION_NAME}}/FleetManager/g' \
-e 's/{{AGENT_PKG}}/fleet-agent/g' \
-e 's/{{AGENT_PROJECT}}/Agent.Worker/g' \
-e 's/{{AGENT_BINARY}}/Agent.Worker/g' \
-e 's/{{CLI_NAME}}/fleet-ctl/g' \
-e 's/{{CLI_PROJECT}}/Fleet.Cli/g' \
-e 's/{{CONSOLE_PROJECT}}/CloudConsole.Api/g' \
-e 's/{{SHARED_PROJECT}}/Shared.Contracts/g' \
-e 's/{{SERVICE_USER}}/fleet-agent/g' \
-e 's|{{HUB_ENDPOINT}}|/hub/fleet|g' \
-e 's/{{GITHUB_ORG}}/your-org/g' \
-e 's/{{GITHUB_REPO}}/FleetManager/g' \
-e 's/{{MAINTAINER_EMAIL}}/infra@example.com/g' \
spec.md50.15.3 Step 3: Hand to implementer
The resulting document is a concrete, unambiguous spec ready for implementation — whether by a human developer or by Claude Code:
claude "Read the spec at ./spec.md and implement all files described in it."