Architecture
Project Structure
JenkinsAsService/
├── src/
│ └── JenkinsAsService/
│ ├── JenkinsAsService.csproj # .NET 10 Worker Service
│ ├── Program.cs # Host builder, Serilog config, DI, OTel
│ ├── Properties/AssemblyInfo.cs # Explicit assembly attributes (for Codacy)
│ ├── ServiceSettings.cs # Jenkins config POCO
│ ├── TelemetrySettings.cs # OpenTelemetry config POCO
│ ├── SecretMode.cs # Enum: Unprotected, Dpapi, EnvironmentVariable, CredentialManager
│ ├── ISecretResolver.cs # Secret resolution abstraction
│ ├── SecretResolver.cs # DPAPI / CredMgr / EnvVar / plaintext resolver
│ ├── SecretWriter.cs # Writes appsettings.json; stores secret per mode
│ ├── UpdateSecretCommand.cs # CLI subcommand: update-secret
│ ├── JenkinsAgentWorker.cs # BackgroundService — validation, process, watchdog, OTel
│ ├── IJarDownloader.cs # Jar download abstraction
│ ├── HttpJarDownloader.cs # HTTP jar downloader with ETag caching
│ ├── IConnectivityChecker.cs # Connectivity check abstraction
│ ├── TcpConnectivityChecker.cs # TCP connectivity implementation
│ ├── appsettings.json # Configuration file
│ └── packages.lock.json # Locked NuGet restore
├── src/
│ └── JenkinsAsService.Installer/ # WiX v5 MSI installer project
├── tests/
│ └── JenkinsAsService.Tests/ # 59 xUnit unit tests
├── .github/
│ └── workflows/ # CI/CD: build, test, 6 security scans, release
├── ReadMe.md
├── Security.md
└── LICENSE # BSD 3-Clause
Runtime File Layout (Installed)
C:\Program Files\Jenkins\
├── JenkinsAsService.exe # Self-contained .NET 10 executable
├── appsettings.json # Configuration
├── agent.jar # Downloaded from Jenkins (ETag cached)
├── agent.jar.etag # Stored ETag for conditional GET
├── agent.log # Serilog rolling log file
├── agent_001.log # Rolled log (size-based)
├── agent_002.log # Older rolled log
├── agent_003.log # Oldest kept log
└── remoting/ # Jenkins remoting work directory
Service Lifecycle
ExecuteAsync (Start)
JenkinsAgentWorker.ExecuteAsync() is the BackgroundService entry point:
- Log session banner —
=== SERVICE STARTING (PID: ...) === ValidateSettings()— Checks mandatory fields, rejects default-port URLsResolveJavaPath()— ChecksJavaPathconfig →JAVA_HOME→JAVA_HOME\binTestConnectivityAsync()— Delegates toIConnectivityChecker(TCP with 2s timeout)IJarDownloader.DownloadAsync()— Conditional GET with Polly resilienceStartAgentProcess()— Launches Java viaProcesswith async output parsingRunWatchdogAsync()— Enters monitoring loop
StopAsync
- Logs the stop request
- Kills the Java process tree (
Process.Kill(entireProcessTree: true)) - Logs confirmation
Execution Model
The Java process is managed directly via System.Diagnostics.Process:
ProcessStartInfo.ArgumentListfor safe argument passing (no shell interpretation)RedirectStandardOutput+RedirectStandardErrorwith async event handlersOutputDataReceived/ErrorDataReceived→ParseAgentOutput():INFO: ...→LogInformationWARNING: ...→LogWarningSEVERE: ...→LogError- Everything else →
LogInformation(orLogDebugin debug mode)
Watchdog & Auto-Recovery
RunWatchdogAsync() is event-driven — it awaits Task.WhenAny(exitTask, Task.Delay(StabilityMs)) where exitTask is a TaskCompletionSource completed by the process.Exited event. No polling.
flowchart TD
A[Watchdog Loop] -->|Await exit signal OR 60s stability timer| B{exitTask completed?}
B -->|No — 60s elapsed, agent still alive| C{retryCount > 0?}
C -->|Yes| D[Reset retry counter, log stability]
C -->|No| A
D --> A
B -->|Yes — process exited| E[Log SEVERE count, capture exit code, KillAgent]
E --> F{MaxRetries exceeded?}
F -->|Yes| G[Log error and stop]
F -->|No| H["Wait with exponential backoff (10s to 300s)"]
H --> I[Test TCP connectivity]
I -->|Unreachable| J[Log warning, reset TCS, loop]
J --> A
I -->|OK| K["ETag conditional GET agent.jar"]
K --> L[Start new process, subscribe Exited before Start]
L --> M["_restartCounter.Add(1)"]
M --> A
- Exit detection: instant via
process.Exited→TCS.TrySetResult(), subscribed beforeprocess.Start() - Fast-crash guard: After
Start(), ifprocess.HasExitedis already true,TCS.TrySetResult()is called defensively - Failed recovery: If connectivity check or download fails, a fresh uncompleted TCS is assigned so the loop waits properly instead of spinning
- Backoff: 10s → 20s → 40s → 80s → ... capped at 300s
- Retry reset: After 60s of stability, retry counter resets to 0
- MaxRetries:
0= infinite (default) - SEVERE counter:
_severeCountisInterlocked.Increment-ed per SEVERE line; read and reset viaInterlocked.Exchangeon each exit
Logging (Serilog)
| Sink | What Gets Written | Configuration |
|---|---|---|
Rolling file (agent.log or agent.clef) |
All log entries | 10MB size limit, configurable retention (RetainedLogs, default 3). CLEF JSON when CompactLog: true |
Windows Event Log (Application/JenkinsAsService) |
Warnings and Errors only | Source auto-created |
Enrichers: ProcessId, MachineName, EnvironmentName.
Output format: <PID> | <datetime> | <Level> | <message>
Set CompactLog: true for CLEF JSON output (agent.clef). Useful for Seq, Datadog, or any log aggregator.
HTTP Resilience
The HttpClient used by HttpJarDownloader is registered as a named client ("JarDownloader") with AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience. This provides automatic retry, circuit breaker, and timeout. Separate from the watchdog's recovery loop.
Constants
| Constant | Value | Purpose |
|---|---|---|
BackoffBaseSec |
10 |
Initial backoff delay (seconds) |
BackoffMaxSec |
300 |
Maximum backoff delay (seconds) |
StabilityMs |
60000 |
Stability window — reset retry counter after this many ms of uptime |
JarFilename |
agent.jar |
Jenkins agent jar file name |
InfoPrefix / WarningPrefix / SeverePrefix |
"INFO: " etc. |
Agent output log-level prefixes |
SecretRedaction |
"*****" |
Replacement value for redacted secrets |
Dependency Injection
| Service | Implementation |
|---|---|
IOptions<ServiceSettings> |
Bound from appsettings.json section Jenkins |
IOptions<TelemetrySettings> |
Bound from appsettings.json section Telemetry |
IHttpClientFactory |
Named client "JarDownloader" with Polly resilience |
IJarDownloader |
HttpJarDownloader (ETag caching) |
IConnectivityChecker |
TcpConnectivityChecker |
ISecretResolver |
SecretResolver (DPAPI/CredMgr/EnvVar/plaintext) |
ILogger<T> |
Serilog-backed structured logging |
| OpenTelemetry (optional) | AddOpenTelemetry().WithMetrics() when Telemetry:Enabled: true |
Testing
59 unit tests in tests/JenkinsAsService.Tests/:
| Test Class | Tests | Coverage |
|---|---|---|
ValidateSettingsTests |
6 | Empty URL/secret, default port, malformed URL, valid settings, SecretMode backward compat |
ParseArgumentsTests |
7 | Null, whitespace, simple split, quoted args, extra whitespace, single arg, escaped quotes |
ResolveJavaPathTests |
5 | No java, bad paths, configured path, JAVA_HOME/bin, priority order |
ParseAgentOutputTests |
6 | INFO/WARNING/SEVERE prefix parsing, debug mode, non-debug mode, whitespace lines |
UpdateSecretCommandTests |
17 | Help, silent mode validation, ParseMode, file/env secret input, impersonation, unknown option |
HttpJarDownloaderTests |
5 | 200 download + ETag save, 304 skip, If-None-Match header, 200 without ETag, orphan guard |
SecretResolverTests |
6 | Unprotected, DPAPI decrypt, DPAPI invalid base64, env var missing, credential manager missing, empty secret |
SecretWriterTests |
3 | DPAPI write, unprotected write, preserve existing fields |
Test Stack
| Package | Version | Purpose |
|---|---|---|
| xUnit | 2.9.3 | Test framework |
| NSubstitute | 5.3.0 | Mocking |
| FluentAssertions | 8.10.0 | Fluent assertions |
| Microsoft.NET.Test.Sdk | 18.6.0 | Test platform SDK |
| coverlet.collector | 10.0.1 | Code coverage |