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:

  1. Log session banner=== SERVICE STARTING (PID: ...) ===
  2. ValidateSettings() — Checks mandatory fields, rejects default-port URLs
  3. ResolveJavaPath() — Checks JavaPath config → JAVA_HOMEJAVA_HOME\bin
  4. TestConnectivityAsync() — Delegates to IConnectivityChecker (TCP with 2s timeout)
  5. IJarDownloader.DownloadAsync() — Conditional GET with Polly resilience
  6. StartAgentProcess() — Launches Java via Process with async output parsing
  7. RunWatchdogAsync() — Enters monitoring loop

StopAsync

  1. Logs the stop request
  2. Kills the Java process tree (Process.Kill(entireProcessTree: true))
  3. Logs confirmation

Execution Model

The Java process is managed directly via System.Diagnostics.Process:

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

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>

Tip

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