diff --git a/.local/single-container-notes.md b/.local/single-container-notes.md new file mode 100644 index 0000000000..7c2ef2bbb5 --- /dev/null +++ b/.local/single-container-notes.md @@ -0,0 +1,390 @@ +# Single ServiceControl container / entrypoint research + +Research target: [Particular/ServiceControl#5028](https://github.com/Particular/ServiceControl/issues/5028), “Deploy only one ServiceControl container image with roles support” (opened 2025-06-30). + +The issue asks for one image containing Primary/Error, Audit, Monitoring, and optionally ServicePulse, selected with a role variable such as `SERVICE_CONTROL_ROLE=Audit`, `Primary,Monitoring`, or `All`. + +## Executive summary + +There are two separate goals hidden in the issue: + +1. **One distributable image and one container entrypoint** — comparatively small and low risk. +2. **One runtime host for multiple roles** — a substantial architecture change. + +The recommended path is incremental: + +1. Build one image containing the existing three application artifacts in isolated directories. +2. Add one container launcher/entrypoint that parses roles and starts the selected application(s). +3. Initially preserve each role’s process, DI container, HTTP port, endpoint, persistence lifecycle, and command parser. +4. Treat ServicePulse as a capability of Primary using the existing integrated ServicePulse support. +5. Only pursue a true single-process/single-host implementation later, if measurements show that reducing container count without reducing process count is insufficient. + +Calling `AddServiceControl`, `AddServiceControlAudit`, and `AddServiceControlMonitoring` on one `WebApplicationBuilder` is **not viable as-is**. The roles have unkeyed endpoint-specific DI services, unkeyed NServiceBus endpoint registration, colliding HTTP routes, independently configured MVC/auth/CORS/logging, and independently owned persistence. Integrated ServicePulse is not an equivalent precedent: it is static UI/middleware added to Primary, whereas Audit and Monitoring are active NServiceBus endpoints with hosted services and (for Audit) persistence. + +A single image with one role per container directly addresses image proliferation. A launcher supervising multiple existing processes is the lowest-risk way to add `Primary,Audit,Monitoring` and `All`. It reduces topology/container count, but not process memory to the same degree as true in-process embedding. + +## Current runtime shape + +There are three independent ASP.NET Core executables: + +| Role | Entrypoint | Normal host construction | Container port | +|---|---|---|---:| +| Primary/Error | `src/ServiceControl/Program.cs` | `src/ServiceControl/Hosting/Commands/RunCommand.cs` | 33333 | +| Audit | `src/ServiceControl.Audit/Program.cs` | `src/ServiceControl.Audit/Infrastructure/Hosting/Commands/RunCommand.cs` | 44444 | +| Monitoring | `src/ServiceControl.Monitoring/Program.cs` | `src/ServiceControl.Monitoring/Hosting/Commands/RunCommand.cs` | 33633 | + +All three programs independently: + +- populate legacy executable configuration; +- construct role-namespaced logging settings; +- configure process-global logging; +- run the `--setup-and-run` workaround in `src/ServiceControl.Infrastructure/IntegratedSetup.cs`; +- parse a role-specific command set; +- construct role-specific settings; +- dispatch to a role-specific internal `CommandRunner`. + +The run commands independently create, build, and run a `WebApplication`. There is no role dispatcher today. + +The role registration seams are already reasonably clear: + +- Primary: `AddServiceControl` in `src/ServiceControl/HostApplicationBuilderExtensions.cs`. +- Audit: `AddServiceControlAudit` in `src/ServiceControl.Audit/HostApplicationBuilderExtensions.cs`. +- Monitoring: `AddServiceControlMonitoring` in `src/ServiceControl.Monitoring/HostApplicationBuilderExtensions.cs`. + +However, the entrypoint/command types are internal and the extensions assume one role and one NServiceBus endpoint per service provider. + +### Existing ServicePulse integration + +Primary already references `Particular.ServicePulse.Core` and supports `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE`: + +- setting and relative `/api/` override: `src/ServiceControl/Infrastructure/Settings/Settings.cs`; +- middleware mounting: `src/ServiceControl/Hosting/Commands/RunCommand.cs`; +- package reference: `src/ServiceControl/ServiceControl.csproj`. + +A proposed `ServicePulse` role should therefore be a role/capability validation rule, not a fourth independently hosted application: + +- `ServicePulse` should imply `Primary`, or be rejected unless `Primary` is selected; +- `All` should probably expand to `Primary,Audit,Monitoring,ServicePulse`; +- `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE=true` should remain compatible. + +This contract needs an explicit product decision. + +## Current image and packaging shape + +There are three near-identical application Dockerfiles: + +- `src/ServiceControl/Dockerfile` builds/copies `/deploy/Particular.ServiceControl` and starts `/app/ServiceControl`. +- `src/ServiceControl.Audit/Dockerfile` builds/copies `/deploy/Particular.ServiceControl.Audit` and starts `/app/ServiceControl.Audit`. +- `src/ServiceControl.Monitoring/Dockerfile` builds/copies `/deploy/Particular.ServiceControl.Monitoring` and starts `/app/ServiceControl.Monitoring`. + +They differ in executable, exposed port, defaults, and health URL. The chiseled runtime image should not be assumed to contain a shell, so a .NET launcher is preferable to a shell supervisor. + +The artifact model needs care: + +- transports are build-only references and are copied as artifact side effects (`src/ProjectReferences.Transports.props`); +- Primary and Audit use separate persister artifact lists (`src/ProjectReferences.Persisters.Primary.props` and `.Audit.props`); +- each executable currently emits a separate deployment directory; +- installer packaging intentionally compares shared files and emits separate ZIPs (`src/ServiceControlInstaller.Packaging/ServiceControlInstaller.Packaging.csproj`). + +The unified image should initially copy all three deployment directories without flattening them, for example `/app/primary`, `/app/audit`, and `/app/monitoring`. Flattening can overwrite same-named assemblies and bypass existing artifact/plugin behavior. + +RavenDB should remain a separate image. `src/ServiceControl.RavenDB/Dockerfile` has a distinct base, storage lifecycle, and upgrade guard. + +## Configuration source and naming behavior + +`src/ServiceControl.Configuration/EnvironmentVariableSettingsReader.cs` first reads the namespaced environment key and then, for the three application namespaces only, falls back to the unprefixed key. + +| Role | Logical namespace | Namespaced prefix | Example | +|---|---|---|---| +| Primary | `ServiceControl` | `SERVICECONTROL_` | `SERVICECONTROL_TRANSPORTTYPE` | +| Audit | `ServiceControl.Audit` | `SERVICECONTROL_AUDIT_` | `SERVICECONTROL_AUDIT_TRANSPORTTYPE` | +| Monitoring | `Monitoring` | `MONITORING_` | `MONITORING_TRANSPORTTYPE` | +| Shared queues | `ServiceBus` | `SERVICEBUS_` | `SERVICEBUS_ERRORQUEUE` | + +For the first three namespaces, `TRANSPORTTYPE` is also accepted as a common fallback. Namespaced values win. The `ServiceBus` namespace does **not** allow this fallback. + +This behavior is useful for shared transport/auth configuration, but unsafe for role-identity, persistence, and retention settings in a combined container. + +## Configuration that should be shared across roles + +“Shared” means the values must be operationally compatible. It does not necessarily mean they must be represented by one unprefixed variable. + +| Configuration | Required relationship | Recommendation | +|---|---|---| +| `TransportType` | All selected messaging roles must use the same transport technology. | A shared `TRANSPORTTYPE` is appropriate; permit role-prefixed overrides for advanced cases but validate equality for a combined topology. | +| transport `ConnectionString` | Must point at the same logical broker/infrastructure for the roles to communicate. | A shared `CONNECTIONSTRING` is appropriate. Do not confuse this with persistence connection strings. | +| authentication authority/audience/validation | One ServicePulse token must be accepted by Primary, Audit, and Monitoring APIs. | Share unprefixed `AUTHENTICATION_*` values and validate consistency. `Authentication.ServicePulse.*` remains Primary-only. | +| role/RBAC claim names and authorization switch | Authorization must behave consistently on all APIs. | Share `RolesClaim`, `RoleBasedAuthorizationEnabled`, `SubjectIdClaim`, and `SubjectNameClaim`. | +| forwarded-header trust | Roles behind the same proxy must interpret host/scheme consistently. | Usually share `FORWARDEDHEADERS_*`. | +| CORS policy | ServicePulse must be allowed to call all exposed APIs. | Usually share `CORS_*`; validate the browser-visible ServicePulse origin. | +| TLS certificate/policy | Usually the same public deployment policy/certificate. | Sharing is sensible, but `Https.Port` is listener/redirect-specific and cannot blindly be one value when retaining three ports. | +| validation switch | `ValidateConfig` has the same semantics/default (`true`). | Safe to share. | +| shutdown timeout | Same semantics and current default (5 seconds). | Safe for separate processes; a single host needs one deliberate global value. | + +### Values that must be coordinated but use different keys + +| Relationship | Current keys | Risk | +|---|---|---| +| Primary and Monitoring error queue must match | `SERVICEBUS_ERRORQUEUE` vs `MONITORING_ERRORQUEUE` | Setting only unprefixed `ERRORQUEUE` configures Monitoring, not Primary. | +| Primary must know Audit API | `SERVICECONTROL_REMOTEINSTANCES` | In a combined container this can use loopback/internal ports, but browser-visible URLs have different requirements. | +| Audit must know Primary queue address | `SERVICECONTROL_AUDIT_SERVICECONTROLQUEUEADDRESS` | Must track Primary’s actual `InstanceName`; a rename can silently break custom-check routing. | +| Integrated ServicePulse must know Monitoring API | package variables `MONITORING_URL` / `MONITORING_URLS` | URL is consumed by a browser; container-local `localhost` may be wrong unless requests are same-host/proxied. | + +## Configuration that must remain role-specific + +### Identity and endpoint settings + +- `InstanceName` must be unique for Primary, Audit, and Monitoring. A shared `INSTANCENAME` is a startup/routing blocker. +- `MaximumConcurrencyLevel` has the same shape but independently tunes each endpoint. +- ingestion/forwarding settings are role-specific. +- queue identities other than explicitly coordinated error-queue values are role-specific. + +Always prefer: + +- `SERVICECONTROL_INSTANCENAME`; +- `SERVICECONTROL_AUDIT_INSTANCENAME`; +- `MONITORING_INSTANCENAME`. + +The launcher should reject a combined configuration in which selected roles resolve to duplicate endpoint names. + +### Persistence settings + +Primary and Audit may share a RavenDB server connection/certificate, but must not share storage identity: + +| Raven setting | Sharing rule | +|---|---| +| server/cluster connection string | May be shared. | +| client certificate | May be shared if both databases grant access. | +| database name | Must differ (`primary` and `audit` defaults). | +| embedded `DbPath` | Must differ; one path cannot be independently owned twice. | +| database maintenance port | Must differ for separate embedded servers. | +| log path | Prefer separate paths/process-labelled output. | + +Unprefixed `RAVENDB_*`, `DBPATH`, and `DATABASEMAINTENANCEPORT` are dangerous in combined roles because both Primary and Audit can consume them. Use role-prefixed values and add launcher validation. + +The most important in-process blocker is embedded RavenDB: Primary and Audit independently use the process-global `EmbeddedServer.Instance`. Two embedded server lifecycles cannot simply be started by two role modules in one process. A true combined host must either: + +- own one embedded server explicitly and open distinct `primary`/`audit` databases; +- require external RavenDB for combined roles; or +- retain separate role processes. + +### Retention and similarly named settings + +`AuditRetentionPeriod` is a concrete conflict: + +- Primary treats it as optional/null. +- Audit code defaults it to 30 days. +- the Audit Dockerfile currently injects 7 days. + +A shared unprefixed `AUDITRETENTIONPERIOD` changes both roles. Keep it namespaced. + +Likewise, do not share merely because suffixes match: + +- `PersistenceType` (providers may match, storage identities must not); +- `EnableFullTextSearchOnBodies`; +- `MaxBodySizeToStore`; +- `MaximumConcurrencyLevel`; +- `LogPath`; +- role retention, ingestion, forwarding, and retry timing values. + +### Ports and hostnames + +Container settings currently hard-code listeners in constructors: + +- Primary `* : 33333` in `src/ServiceControl/Infrastructure/Settings/Settings.cs`; +- Audit `* : 44444` in `src/ServiceControl.Audit/Infrastructure/Settings/Settings.cs`; +- Monitoring `* : 33633` in `src/ServiceControl.Monitoring/Settings.cs`. + +The normal `Port`/`Hostname` settings are ignored in containers. This is acceptable if the first combined implementation preserves all three ports, but blocks user-selectable internal ports and complicates one-public-port hosting. + +### Logging conflict + +`src/ServiceControl.Infrastructure/LoggingSettings.cs` mutates `LoggerUtil.ActiveLoggers` and `LoggerUtil.SeqAddress`, which are process-global static state. Constructing multiple role settings in one process means the last role initialized controls provider selection/Seq globally. The role host extensions also configure/clear logging providers independently. + +For a true single process, either: + +- logging provider and Seq settings become one global launcher/host configuration, with role-enriched log events; or +- logging setup is refactored to remove global mutable state. + +Separate child processes avoid this conflict while allowing role-specific paths/providers. + +## Why naive in-process embedding fails + +### NServiceBus endpoint registration and DI + +NServiceBus supports multiple endpoints in one Generic Host when each endpoint is registered with a unique identifier/key. Current registrations call `AddNServiceBusEndpoint(configuration)` without identifiers in all three host extensions. + +The larger problem is endpoint-specific unkeyed registration. Each role registers types such as: + +- `ITransportCustomization`/transport-specific services; +- `TransportSettings`; +- `Lazy`; +- endpoint sessions/dispatchers consumed by hosted services and controllers. + +In one root container, ordinary unkeyed resolution returns the last applicable registration. Merely adding endpoint identifiers is insufficient. A single-host design needs keyed services throughout or explicit role-specific façades such as `IPrimaryMessageSession`, `IAuditMessageSession`, and `IMonitoringMessageSession`. + +### HTTP route collisions + +Primary already contains composite Audit-facing APIs that call configured remote Audit instances. Audit exposes many of the same routes directly. Exact collisions include variants of: + +- `/api/messages`; +- `/api/endpoints/{endpoint}/messages`; +- `/api/messages/{id}/body`; +- `/api/conversations/{conversationId}`; +- `/api/connection`; +- `/api/configuration`; +- `/api/instance-info`. + +Loading both controller assemblies into one MVC application would create ambiguous matches and could bypass Primary’s existing aggregation behavior. + +Monitoring uses root-relative routes such as `/`, `/connection`, and `/monitored-endpoints`; `/` conflicts with integrated ServicePulse’s Primary-root UI. + +### Host-global web policy + +Each role independently registers controllers, application parts, filters, model binders, CORS, authentication, authorization, HTTPS, and middleware. In one web host these accumulate into shared options. One role must become the policy owner, likely Primary, and embedded roles must contribute explicitly designed APIs rather than importing their current web stacks unchanged. + +### Lifecycle and failure coupling + +Audit relies on registration order so NServiceBus starts before audit ingestion. Primary and Audit persistence also use hosted-service lifecycles. In one host: + +- one role’s startup failure prevents all roles from starting; +- shutdown is one unit; +- there is one effective `HostOptions.ShutdownTimeout`; +- the desired response to one endpoint’s critical error must be defined (stop all, degrade one, or restart one). + +A launcher also needs an explicit policy, but process isolation makes behavior and cleanup clearer. + +## Architecture options + +### Option A — one image, one selected role per container + +One launcher selects exactly one existing role. + +**Pros:** directly removes image variants; preserves behavior; lowest implementation risk; existing ports, DI, routes, persistence, and command modes remain intact. + +**Cons:** does not reduce container count for small installations. + +This should be the first deliverable regardless of later composition. + +### Option B — one entrypoint supervising isolated role processes (recommended first combined mode) + +The image contains all three artifacts. A small .NET PID-1 launcher starts selected executables and preserves the current isolation boundaries. + +**Pros:** enables `Primary,Audit,Monitoring`/`All`; avoids DI, route, static logging, and embedded Raven process-global collisions; reuses existing commands and setup behavior. + +**Cons:** multi-process container supervision and aggregate health are required; memory/process footprint remains near the current total; one container becomes a shared scaling/failure boundary. + +The launcher must forward SIGTERM/SIGINT, stop all children when one fails unexpectedly, preserve role-labelled output, wait within the platform shutdown budget, and return a useful non-zero exit code. + +### Option C — one process with independent child hosts/service providers + +A launcher creates a separate `WebApplication` per role in one process, preserving ports and route/DI isolation. + +**Pros:** fewer processes while retaining service-provider isolation. + +**Cons:** process-global logging and embedded Raven remain blockers; host/signal ownership is more complex; repeated host-global/static setup was not designed for this. + +This is only attractive with external RavenDB and after logging/bootstrap refactoring. + +### Option D — Primary web host with embedded Audit/Monitoring workers and façades + +Primary owns the only public web pipeline. Audit and Monitoring run as worker modules/endpoints; their existing controllers are not imported unchanged. Primary’s existing composite Audit APIs access local Audit persistence when embedded, and Monitoring gets deliberately designed façade routes. + +**Pros:** closest to a true integrated product and to the desired “like integrated ServicePulse” user experience; can eventually expose one public origin. + +**Cons:** large refactor of keyed endpoint services, persistence ownership, APIs, URL compatibility, and lifecycle. It is not analogous to mounting ServicePulse middleware. + +This is a viable long-term target, not the safest first implementation of #5028. + +## Recommended implementation outline + +### 1. Define the role contract + +Decide and test: + +- canonical environment variable spelling (`SERVICE_CONTROL_ROLE` as proposed by the issue, or a convention-aligned alternative); +- case/whitespace/duplicate handling; +- omitted value (recommend `Primary` for the canonical Primary image’s compatibility); +- valid values: `Primary`, `Audit`, `Monitoring`, `ServicePulse`, `All`; +- whether `ServicePulse` implies Primary; +- whether `All` includes ServicePulse; +- unknown-role diagnostics; +- whether maintenance/import arguments are legal with multiple selected roles (recommend rejecting ambiguous multi-role non-run commands initially). + +An environment-based role survives `IntegratedSetup.Run` child execution automatically. If a role argument is supported, ensure it survives the `--setup-and-run` re-exec path. + +### 2. Add a container-focused launcher + +Prefer a small .NET executable as the sole OCI `ENTRYPOINT`. Keep Windows installer executables and ZIPs unchanged. + +For each selected role, launch the existing artifact/entrypoint with the original arguments. Keep role application directories isolated. Explicitly model child startup, failure, cancellation, signal propagation, output attribution, and exit codes. + +Avoid changing `Assembly.GetExecutingAssembly()` semantics in existing programs unless their app-config loading is deliberately refactored; simply moving all current `Program.cs` bodies into a new executing assembly could change legacy configuration behavior. + +### 3. Build one application image + +- Build all three applications and the launcher/health helper. +- Copy all three existing deployment artifacts into separate directories. +- Include both Primary and Audit persisters and all transports via the existing artifact targets. +- Expose 33333, 44444, and 33633 initially. +- Keep RavenDB image/build independent. +- Apply role defaults with namespaced variables or launcher defaults; do not inject Audit’s unprefixed retention default globally. + +### 4. Add role-aware health + +Current Docker health checks assume one fixed endpoint: + +- Primary: `http://localhost:33333/api/configuration`; +- Audit: `http://localhost:44444/api/configuration`; +- Monitoring: `http://localhost:33633/connection`. + +The unified helper must inspect selected roles and require each selected child and role-specific HTTP endpoint to be healthy. Unselected roles must be ignored. Consider separate startup/readiness and liveness semantics; at minimum diagnostics should identify the failed role. + +### 5. Update release/deployment assets + +Affected areas include: + +- `.github/workflows/build-containers.yml` (three-image matrix); +- `.github/workflows/push-container-images.yml` (three repositories/descriptions); +- `.github/workflows/clean-ghcr.yml`; +- role container READMEs and `docs/deployment.md`; +- `src/container-integration-test/servicecontrol.yml` and transport overlays; +- synchronized external compose examples listed at the top of that file. + +Old image names cannot be transparent OCI aliases if each alias requires a different default role: an image alias cannot inject role-specific environment metadata. Compatibility requires temporary wrapper images, role-specific entrypoint defaults, or a documented migration to one canonical repository. + +### 6. Test in increments + +1. Use the unified image three times, one role per container, across the existing transport matrix. +2. Add one combined container selecting all roles. +3. Verify role-specific endpoints plus actual behavior, not only container health. +4. Verify Primary-to-Audit aggregation and Audit-to-Primary queue routing. +5. Verify Monitoring uses the same error queue as Primary. +6. Verify integrated ServicePulse UI and browser-visible Monitoring URL. +7. Test shared unprefixed transport/auth values and namespaced identity/persistence overrides. +8. Test all role parsing, setup/re-exec, process failure, signals, and aggregate health. +9. Test upgrades from each old image to the equivalent role in the unified image without data changes. + +## Validation recommended at launcher startup + +For combined roles, fail fast with actionable errors when: + +- selected role endpoint names collide; +- selected roles resolve to different transport types; +- required transport connection settings are absent/incompatible; +- Primary and Monitoring error queues differ; +- Primary and Audit resolve to the same Raven database, embedded path, or maintenance port; +- Audit’s Primary queue address conflicts with Primary’s endpoint name; +- authentication authority/audience/RBAC settings differ across exposed APIs; +- `ServicePulse` is selected without its required Primary relationship; +- a multi-role command is ambiguous; +- selected roles attempt to bind the same port; +- combined in-process mode is requested with unsupported embedded Raven/logging configuration. + +## Final recommendation + +Implement #5028 first as **one canonical image plus one role-aware .NET entrypoint**, preserving the three existing role applications internally. Support one role per container first, then composable roles through supervised isolated processes. This satisfies image consolidation and offers the requested small-shop topology without forcing an immediate rewrite of ServiceControl’s hosting model. + +Do not initially host Audit and Monitoring in Primary by calling their existing host extensions beside `AddServiceControl`. To make that model safe requires a dedicated modularization effort: keyed NServiceBus endpoints and endpoint-specific services, one web-policy owner, removal/replacement of colliding controllers, explicit local Audit/Monitoring façades, one persistence ownership model, one logging model, and defined cross-role failure semantics. + +If future profiling proves that the multi-process combined container does not deliver enough footprint reduction, evolve toward the Primary-owned façade/worker model (Option D), using Primary’s existing composite Audit API as the seam rather than importing Audit’s duplicate HTTP API. \ No newline at end of file diff --git a/.local/single-container-plan.md b/.local/single-container-plan.md new file mode 100644 index 0000000000..dfd9549404 --- /dev/null +++ b/.local/single-container-plan.md @@ -0,0 +1,452 @@ +# Implementation plan: single ServiceControl container image with roles + +Source recommendations: [`.local/single-container-notes.md`](single-container-notes.md) +Finalized design contract: [`docs/single-container-design.md`](../docs/single-container-design.md) +Target issue: Particular/ServiceControl#5028 + +## Plan maintenance instructions + +- Check off each checklist item (`[x]`) as soon as its work is completed and verified. +- Link every document created while implementing this plan from this plan, using a repository-relative Markdown link. +- Add document links near the plan header when they apply to the overall design; otherwise add them beside the relevant phase or checklist item. +- Keep incomplete or partially implemented work unchecked, and note partial progress beneath the relevant item when useful. + +## Goal + +Ship one canonical ServiceControl application image that contains the existing Primary, Audit, and Monitoring applications and uses a .NET PID-1 launcher to select one or more roles. Preserve each role as an independent child process with its existing command parser, DI container, NServiceBus endpoint, HTTP port, persistence lifecycle, and deployment artifact. + +Deliver this incrementally: + +1. Run the unified image once per role as a drop-in replacement for the three current images. +2. Support `Primary,Audit,Monitoring` and `All` in one container by supervising the same isolated applications. +3. Defer a true single-process/single-web-host design until footprint measurements justify the architectural work. + +## High-level implementation checklist + +- [x] Finalize and document the role-selection, compatibility, command, failure, shutdown, and publishing contracts. +- [x] Add the .NET launcher and tests for role parsing, argument forwarding, and ServicePulse capability handling. +- [x] Implement child-process startup, supervision, signal forwarding, graceful shutdown, and failure propagation. +- [ ] Add fail-fast validation for unsafe combined-role configuration while preserving existing per-role validation. +- [ ] Build one canonical image containing isolated Primary, Audit, Monitoring, and launcher artifacts. +- [ ] Replace the standalone health-check application with launcher-owned aggregate health checks. +- [ ] Prove the unified image as a drop-in replacement with one role per container across the existing transport matrix. +- [ ] Add and verify the combined `All` topology, including role APIs, ServicePulse, routing, persistence isolation, and process failure behavior. +- [ ] Update build, publish, cleanup, and integration-test workflows; decide and implement the legacy-image compatibility period. +- [ ] Update container documentation, deployment guidance, migration examples, and synchronized external samples. +- [ ] Complete unit, package, multi-architecture image, container, shutdown, crash, and upgrade verification. +- [ ] Roll out the canonical image, retain old publishing paths until compatibility is proven, and record follow-up footprint measurements. + +## Scope and non-goals + +### In scope + +- One canonical Linux image containing all three application artifacts. +- A role contract based on `SERVICE_CONTROL_ROLE`. +- A .NET launcher that acts as PID 1 and supervises child processes. +- Role-aware aggregate health. +- Fail-fast validation of role selection and high-risk combined-role configuration. +- CI, integration tests, release publishing, deployment samples, and documentation. +- A migration path from the current role-specific repositories. + +### Not in scope + +- Combining `AddServiceControl`, `AddServiceControlAudit`, and `AddServiceControlMonitoring` in one service provider. +- Changing Windows installer ZIPs, executable entrypoints, or installer packaging. +- Flattening deployment artifacts into one directory. +- Embedding RavenDB into the application image; `ServiceControl.RavenDB` remains separate. +- Reworking API routes or exposing all roles through one HTTP port. +- Reducing the three selected roles to one OS process. + +## Product decisions to record before coding + +Document these decisions in the issue and in the container README. The recommended answers below should become tests. + +| Question | Recommended contract | +|---|---| +| Variable name | `SERVICE_CONTROL_ROLE` | +| Default when omitted | `Primary`, preserving the current `particular/servicecontrol` behavior | +| Parsing | Case-insensitive, trim whitespace, accept comma-separated values, remove duplicates | +| Canonical roles | `Primary`, `Audit`, `Monitoring`, `ServicePulse`, `All` | +| `ServicePulse` | A capability, not a child process; it implies `Primary` | +| `All` | Expands to `Primary,Audit,Monitoring,ServicePulse` | +| Existing integrated ServicePulse variable | Continue supporting `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE`; reject an explicit `false` when the selected role set requires `ServicePulse` rather than silently overriding it | +| Role start order | Start in canonical order: Primary, Audit, Monitoring; do not treat ordering as a readiness dependency | +| Multi-role commands | Initially allow normal run and `--setup-and-run`; reject maintenance/import/help/setup commands with multiple process roles and explain that they must be run one role at a time | +| Failure policy | Any unexpected child exit stops all remaining children and makes the container exit non-zero | +| Shutdown policy | Forward termination to all children, wait for the configured launcher grace period, then kill remaining process trees | +| Ports | Preserve 33333 (Primary), 44444 (Audit), and 33633 (Monitoring) | +| Old image repositories | Recommended: publish thin compatibility wrapper images for one major-version transition, each setting a role-specific default, then retire them. A plain OCI alias is insufficient because aliases cannot inject a different role default. | + +## Proposed code structure + +Add a small executable and test project: + +```text +src/Platform.Launcher/ + Platform.Launcher.csproj + Program.cs + ContainerRole.cs + RoleSelection.cs + RoleDescriptor.cs + ContainerCommand.cs + ConfigurationValidator.cs + ChildProcessSupervisor.cs + HealthCheck.cs + UnixSignal.cs + +src/Platform.Launcher.UnitTests/ + Platform.Launcher.UnitTests.csproj + RoleSelectionTests.cs + ContainerCommandTests.cs + ConfigurationValidatorTests.cs + ChildProcessSupervisorTests.cs + HealthCheckTests.cs + Fakes/... +``` + +`Platform.Launcher` should be the only new OCI entrypoint and should support two modes: + +- default / `run`: validate and supervise selected role applications; +- `health`: parse the same role selection and probe every selected role endpoint. + +Using one executable for supervision and health avoids duplicating role parsing between the launcher and `HealthCheckApp`. After migration, remove `src/HealthCheckApp` if no other packaging path uses it. + +Add both projects to `src/ServiceControl.slnx`, placing the executable under `/Instances/Shared/` and tests under `/Instances/Shared/Testing/`. + +## Phase 1: role model and launcher command contract + +### 1.1 Implement role parsing + +In `RoleSelection.cs`: + +- Read `SERVICE_CONTROL_ROLE`, defaulting to `Primary` only when absent or empty according to the agreed contract. +- Split on commas, trim entries, compare using `OrdinalIgnoreCase`, and deduplicate. +- Reject empty list elements only if the team wants strict parsing; otherwise ignore whitespace-only elements consistently. +- Expand `All` and `ServicePulse` before validation. +- Return process roles separately from capabilities, e.g. process roles `{Primary, Audit}` and capability `{ServicePulse}`. +- Emit canonical role names in diagnostics and health output. +- Reject unknown values with the complete allowed-value list. + +Define immutable `RoleDescriptor` data for each process role: + +| Role | Executable | Working directory | Health endpoint | Port | +|---|---|---|---|---:| +| Primary | `/app/primary/ServiceControl` | `/app/primary` | `http://localhost:33333/api/configuration` | 33333 | +| Audit | `/app/audit/ServiceControl.Audit` | `/app/audit` | `http://localhost:44444/api/configuration` | 44444 | +| Monitoring | `/app/monitoring/ServiceControl.Monitoring` | `/app/monitoring` | `http://localhost:33633/connection` | 33633 | + +Allow the application root to be injected in tests instead of hard-coding `/app` throughout the implementation. + +### 1.2 Parse launcher versus child arguments + +`Program.cs` should consume only launcher-owned modes/options and pass all existing application arguments unchanged to each selected child. Do not move the current `Program.cs` bodies into the launcher and do not reference the three application projects. + +Rules: + +- With one process role, pass all arguments through unchanged. This preserves setup, maintenance, import, and help behavior. +- With multiple process roles, permit only no arguments (normal run) and `--setup-and-run` initially. +- Return a distinct usage/configuration exit code for invalid role or command combinations. +- Ensure `SERVICE_CONTROL_ROLE` remains in the environment inherited by child and `IntegratedSetup.Run` re-executed processes. +- Print a startup summary containing selected process roles, capabilities, child paths, and ports, but never connection strings, certificates, or tokens. + +### 1.3 ServicePulse capability handling + +Before starting Primary: + +- If the role set includes the `ServicePulse` capability and `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE` is unset, add `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE=true` to the Primary child environment. +- If it is explicitly false, fail with an actionable conflict message. +- Do not start a fourth process. +- Preserve package-owned `MONITORING_URL`/`MONITORING_URLS` values. Documentation must emphasize that these are browser-visible URLs and normally cannot use container-local `localhost` when users access ServicePulse externally. + +## Phase 2: process supervision and shutdown + +Implement `ChildProcessSupervisor` behind small interfaces for process creation, time, and signal delivery so behavior can be unit tested without launching ServiceControl. + +### Startup + +- Verify every selected executable exists before launching any child. +- Use `ProcessStartInfo` with the role-specific working directory and original arguments. +- Do not use a shell; the chiseled runtime does not guarantee one. +- Inherit the launcher environment, applying only capability-specific child overrides. +- Redirect stdout/stderr and attribute each complete line with the canonical role, while keeping stdout and stderr separate. If structured JSON logs must remain byte-for-byte valid, instead inherit output unchanged and emit only role-labelled launcher lifecycle events; make this choice explicitly before implementation. +- Start children in canonical order and begin observing all exit tasks immediately. + +### Runtime failure policy + +- If any child exits before cancellation, record its role and exit code. +- Initiate shutdown of every other child. +- Exit with the failed child’s non-zero code when usable; otherwise use a stable launcher failure code. +- If a child exits zero while siblings are still expected to run, still treat it as an unexpected termination and stop the container; a multi-role container must not continue partially healthy. +- Guard against races where multiple children exit together and select the first observed failure deterministically for diagnostics. + +### Signal handling + +- Register SIGTERM and SIGINT using .NET Unix/POSIX signal APIs and also handle `Console.CancelKeyPress` for local execution. +- Forward the same graceful termination signal to every direct child. Since `Process.Kill` is not graceful, isolate native signal delivery in `UnixSignal.cs` rather than using a shell command. +- Wait for all roles within a launcher shutdown grace period. Default it so the role applications retain time inside the platform’s stop timeout; expose a launcher-specific environment variable if configurability is required. +- After the grace period, call `Kill(entireProcessTree: true)` for remaining children so an `IntegratedSetup.Run` subprocess cannot be orphaned. +- Dispose process and signal registrations and return only after output pumps complete. + +Add an integration-style unit test fixture using tiny fake child executables or the existing `SetupProcessFake` pattern to verify argument forwarding, output, exit propagation, sibling shutdown, and timeout escalation. + +## Phase 3: combined-role configuration validation + +Keep normal role-owned validation in the existing applications. The launcher should add only cross-role checks that an individual process cannot perform. + +### 3.1 Reuse environment precedence + +The current precedence is implemented by the internal `EnvironmentVariableSettingsReader` in `src/ServiceControl.Configuration/EnvironmentVariableSettingsReader.cs`. Avoid independently reimplementing prefix normalization and fallback rules. + +Recommended approach: + +- Add `InternalsVisibleTo` for `Platform.Launcher` in `ServiceControl.Configuration.csproj` and reference that project from the launcher. +- Use `EnvironmentVariableSettingsReader` with `SettingsRootNamespace("ServiceControl")`, `("ServiceControl.Audit")`, `("Monitoring")`, and `("ServiceBus")`. +- Keep the launcher container-only; it does not need registry/config-file resolution. +- Centralize setting names in the validator and test namespaced-over-unprefixed precedence. + +Do not instantiate the three application `Settings` classes in the launcher. That would load application dependencies and repeat process-global logging/configuration behavior the launcher is intended to isolate. + +### 3.2 Required startup checks + +For multiple selected process roles, fail before starting any child when: + +1. Resolved `InstanceName` values collide, using current defaults when unset: + - `Particular.ServiceControl`; + - `Particular.ServiceControl.Audit`; + - `Particular.Monitoring`. +2. Resolved `TransportType` values differ or a required value is absent. +3. Resolved transport `ConnectionString` values differ where they are directly comparable, or required values are absent. Do not print secret values. +4. Primary’s `SERVICEBUS_ERRORQUEUE` differs from Monitoring’s `MONITORING_ERRORQUEUE` after defaults are applied. +5. Audit’s `ServiceControlQueueAddress`, when supplied, conflicts with Primary’s resolved instance name. +6. Primary and Audit resolve to the same Raven database name, embedded `DbPath`, or database maintenance port. +7. Authentication authority, audience, authorization switch, and claim-name settings differ across selected HTTP APIs after fallback resolution. +8. Selected role descriptors contain duplicate listener ports. +9. `ServicePulse` capability conflicts with an explicitly disabled integrated ServicePulse setting. +10. Arguments request an unsupported multi-role command. + +Validation messages must name roles and setting keys, explain the required relationship, and avoid values for connection strings/certificates. Unit tests should cover shared unprefixed values, namespaced overrides, defaults, conflicts, and redaction. + +Some persistence providers have provider-specific settings and aliases. Implement Raven identity checks first, because RavenDB is the container default, then add equivalent provider-specific checks only where shared ownership is actually unsafe. Do not block valid external Raven configurations merely because Primary and Audit share the same server URL; only storage identity must differ. + +## Phase 4: unified image + +Replace the application content of `src/ServiceControl/Dockerfile` with the canonical unified build while leaving `src/ServiceControl.RavenDB/Dockerfile` unchanged. + +### Build stage + +- Build these projects for `$TARGETARCH` in Release: + - `src/ServiceControl/ServiceControl.csproj`; + - `src/ServiceControl.Audit/ServiceControl.Audit.csproj`; + - `src/ServiceControl.Monitoring/ServiceControl.Monitoring.csproj`. +- Publish `src/Platform.Launcher/Platform.Launcher.csproj` to a dedicated launcher directory. +- Continue relying on each application’s existing `Artifact` items and imported `ProjectReferences.Transports.props` / persister props so transport and persistence plugins are copied exactly as today. +- Add a build-time assertion that all three artifact directories and their executables exist. + +### Runtime stage + +Use isolated directories: + +```text +/app/launcher/ +/app/primary/ +/app/audit/ +/app/monitoring/ +``` + +- Copy `/deploy/Particular.ServiceControl` to `/app/primary`. +- Copy `/deploy/Particular.ServiceControl.Audit` to `/app/audit`. +- Copy `/deploy/Particular.ServiceControl.Monitoring` to `/app/monitoring`. +- Never flatten these directories. +- Expose 33333, 44444, and 33633. +- Set `ENTRYPOINT ["/app/launcher/Platform.Launcher"]`. +- Set health to `HEALTHCHECK --start-period=10s CMD ["/app/launcher/Platform.Launcher", "health"]`. +- Retain `USER $APP_UID`. + +Replace unsafe unprefixed image defaults with namespaced equivalents: + +```text +SERVICECONTROL_PERSISTENCETYPE=RavenDB +SERVICECONTROL_FORWARDERRORMESSAGES=false +SERVICECONTROL_ERRORRETENTIONPERIOD=15 +SERVICECONTROL_AUDIT_PERSISTENCETYPE=RavenDB +SERVICECONTROL_AUDIT_AUDITRETENTIONPERIOD=7 +``` + +Confirm exact environment key normalization in tests. Do not set unprefixed `PersistenceType`, `AuditRetentionPeriod`, `RAVENDB_*`, `DBPATH`, or maintenance-port defaults in the unified image. + +After the canonical image works, either remove the Audit and Monitoring Dockerfiles or convert them into explicitly temporary compatibility wrappers according to the release decision. Do not continue three full independent builds. + +## Phase 5: aggregate health + +Implement the launcher’s `health` mode with no dependency on the role child processes or application assemblies. + +- Parse and expand `SERVICE_CONTROL_ROLE` using the same `RoleSelection` implementation as run mode. +- Probe every selected process role’s descriptor URL concurrently with a short per-request timeout. +- Require HTTP success, `application/json`, and non-empty content, matching the current `HealthCheckApp` behavior. +- Ignore unselected roles and treat ServicePulse as covered by Primary’s endpoint; optionally add a Primary-root probe only if it provides a stable non-redirecting health contract. +- Print one line per role and a final aggregate result. +- Return zero only when all selected role endpoints pass. +- Redact response bodies and configuration values from errors. + +Child liveness is covered by supervision: if a child exits, PID 1 exits and the container stops. Endpoint probes therefore cover readiness/functional liveness without requiring the separate health process to inspect the supervisor’s in-memory state. + +Tests should use an in-process HTTP server to cover one role, all roles, timeout, non-success, invalid content type, empty body, and partial failure with the failed role named. + +## Phase 6: container integration tests + +Update `src/container-integration-test` in two steps so regressions can be localized. + +### 6.1 Unified image, separate containers + +Change all three services in `servicecontrol.yml` to use `ghcr.io/particular/servicecontrol:${SERVICECONTROL_TAG}` and set: + +- `SERVICE_CONTROL_ROLE=Primary` on `servicecontrol`; +- `SERVICE_CONTROL_ROLE=Audit` on `servicecontrol-audit`; +- `SERVICE_CONTROL_ROLE=Monitoring` on `servicecontrol-monitoring`. + +Keep current ports, names, dependencies, transport overlays, and expected healthy-container counts. This proves the image can replace each legacy image without changing topology. + +### 6.2 Combined container profile + +Add a second compose file/profile, for example `src/container-integration-test/combined.yml`, that: + +- replaces the three application services with one service using `SERVICE_CONTROL_ROLE=All`; +- publishes all three ports; +- supplies explicit namespaced instance and persistence identity values; +- points Primary’s remote Audit URL at `http://localhost:44444/api` for server-side aggregation; +- sets Audit’s Primary queue address to Primary’s resolved endpoint name; +- sets Primary and Monitoring to the same error queue; +- uses one external RavenDB server but different Primary/Audit databases; +- configures a browser-valid Monitoring URL when integrated ServicePulse is tested. + +Extend `.github/workflows/container-integration-test.yml` to run both topologies across the transport matrix, or run the combined profile on a representative transport first if CI cost is too high. Update expected healthy counts and log dumping for the combined container. + +Do more than container-count health for the combined profile. Add scripted assertions for: + +- all three role health endpoints; +- Primary configuration/API availability; +- Audit API availability; +- Monitoring connection API availability; +- integrated ServicePulse root availability; +- Primary-to-Audit aggregation; +- Audit-to-Primary custom-check/queue routing where practical; +- actual Monitoring consumption from Primary’s error queue. + +Preserve diagnostic dumps on failure, including the combined launcher output and all dependent containers. + +## Phase 7: CI and release publishing + +### Build workflow + +Update `.github/workflows/build-containers.yml`: + +- remove the three-application matrix; +- build one `servicecontrol` application image from `src/ServiceControl/Dockerfile`; +- keep the RavenDB build path separate wherever it is currently invoked; +- update OCI title/description to describe Primary, Audit, Monitoring, and integrated ServicePulse roles; +- continue multi-arch `linux/amd64,linux/arm64`, SBOM, and current labels. + +Validate workflow changes with `actionlint` because these files are GitHub Actions workflows. + +### Push workflow + +Update `.github/workflows/push-container-images.yml`: + +- publish the canonical `particular/servicecontrol` and unchanged `particular/servicecontrol-ravendb` repositories; +- update the canonical Docker Hub description from `src/ServiceControl/Container-README.md`; +- if compatibility wrappers are approved, publish them from explicit wrapper manifests and mark their READMEs deprecated with migration examples; +- otherwise remove Audit/Monitoring repositories from the publish loop and call out the breaking repository change in release notes. + +### Cleanup workflow + +Update `.github/workflows/clean-ghcr.yml` to clean the canonical and RavenDB repositories, plus temporary compatibility repositories only for as long as they are published. + +## Phase 8: documentation and synchronized samples + +Update: + +- `src/ServiceControl/Container-README.md` to document role syntax, defaults, examples, ports, ServicePulse semantics, namespaced configuration, health/failure behavior, and separate-vs-combined deployment examples. +- `src/ServiceControl.Audit/Container-README.md` and `src/ServiceControl.Monitoring/Container-README.md` only if compatibility wrappers remain; otherwise replace/remove them as part of repository retirement. +- `docs/deployment.md` to list one application image plus RavenDB and explain migration. +- `src/container-integration-test/README.md` for both topologies and revised healthy counts. +- `docs/test-ghcr-tag/compose.yml` to use the canonical image and explicit roles. +- External synchronized examples named in `src/container-integration-test/servicecontrol.yml`: + - `Particular/PlatformContainerExamples` Docker Compose samples; + - `ParticularLabs/AwsLoanBrokerSample/docker-compose.yml`. + +Documentation must distinguish: + +- shared transport/auth values from role-specific identity/persistence/retention values; +- server-side loopback URLs from browser-visible ServicePulse URLs; +- one image used in three containers from `All` in one container; +- one container from one process—the combined mode intentionally still runs three role processes. + +Provide an upgrade table: + +| Old image | New image | Required role | +|---|---|---| +| `particular/servicecontrol` | `particular/servicecontrol` | omitted or `Primary` | +| `particular/servicecontrol-audit` | `particular/servicecontrol` | `Audit` | +| `particular/servicecontrol-monitoring` | `particular/servicecontrol` | `Monitoring` | + +State that existing volumes/databases/queues remain unchanged when role-specific settings are preserved. + +## Verification matrix + +### Unit tests + +- Role parsing: default, casing, whitespace, duplicates, `All`, ServicePulse implication, unknown values, conflicts. +- Argument policy: complete single-role pass-through and rejected multi-role commands. +- Configuration precedence and every cross-role validation rule. +- Secret redaction in validation and process diagnostics. +- Child lifecycle: startup, argument/environment forwarding, one-child compatibility, failure propagation, sibling shutdown, signal forwarding, escalation, simultaneous exits. +- Health: selected-only probes and aggregate failure diagnostics. + +### Build/package tests + +- `dotnet build src/ServiceControl.slnx --configuration Release`. +- Launcher unit tests. +- Docker build for amd64 and arm64 in CI. +- Inspect image contents to ensure three isolated artifact trees contain expected transport and persister plugins. +- Confirm old installer ZIP/package outputs are byte-equivalent except for unrelated build metadata; the launcher must not enter installer packaging. + +### Container behavior + +- Each role alone with omitted/explicit selection as applicable. +- Unified image in three containers across every existing transport. +- `All` in one container. +- `Primary,Monitoring`, `Primary,Audit`, and a reordered/duplicated input. +- Integrated ServicePulse enabled through both role capability and legacy variable. +- Invalid role, invalid multi-role command, duplicate identity, transport mismatch, persistence collision, and auth mismatch. +- SIGTERM during normal run and during `--setup-and-run`; no orphan processes and exit within platform timeout. +- Deliberate child crash; siblings stop and container exits non-zero. +- Upgrade from each old image to equivalent canonical role using existing storage. + +## Suggested pull request sequence + +1. **Role contract and launcher core** — new projects, parser, descriptors, argument policy, supervisor, unit tests; no publishing changes. +2. **Unified image and health** — canonical Dockerfile, namespaced defaults, aggregate health, one-role image tests. +3. **Combined-role validation and topology** — cross-role validator, combined compose profile, signal/failure integration tests. +4. **CI/release migration** — build/push/cleanup workflows and compatibility-wrapper decision. +5. **Documentation and external sample synchronization** — migration guide, deployment examples, release notes. + +Keep each PR independently deployable where possible. Do not remove old publishing paths until the canonical image has passed the existing separate-container transport matrix. + +## Exit criteria + +The implementation is complete when: + +- one canonical application image contains and can launch all three existing role artifacts; +- the same image passes the existing transport matrix with one role per container; +- `SERVICE_CONTROL_ROLE=All` starts Primary, Audit, Monitoring, and integrated ServicePulse in one container; +- health fails if any selected endpoint fails and names the role; +- an unexpected role-process exit tears down the whole container with a non-zero code; +- SIGTERM gracefully reaches all children without leaving setup descendants; +- dangerous cross-role configuration collisions fail before processes start; +- RavenDB remains a separate image and installer/package artifacts are unaffected; +- release workflows publish the agreed canonical/compatibility repositories; +- deployment docs clearly describe role selection, configuration boundaries, migration, and the retained multi-process architecture. + +## Deferred follow-up: true integrated host + +Only open the single-process effort after measuring image size, RSS, startup time, and operational complexity of the supervised design. That follow-up requires a separate architecture plan covering keyed NServiceBus endpoints and endpoint-specific services, one web-policy owner, removal or replacement of colliding controllers, local Audit/Monitoring façades, persistence ownership (especially embedded RavenDB), global logging, and cross-role failure semantics. It must not be implemented by simply calling the three existing host extension methods on one `WebApplicationBuilder`. diff --git a/docs/single-container-design.md b/docs/single-container-design.md new file mode 100644 index 0000000000..0e117151e9 --- /dev/null +++ b/docs/single-container-design.md @@ -0,0 +1,171 @@ +# Single ServiceControl container design + +## Status + +This document records the contracts for Particular/ServiceControl#5028 before implementation. These contracts are intended to remain stable and to be covered by automated tests. + +The implementation will ship one canonical Linux application image containing the existing Primary, Audit, and Monitoring applications. A .NET launcher will run as PID 1 and select one or more applications as isolated child processes. It will not combine their command parsers, dependency injection containers, NServiceBus endpoints, HTTP hosts, or persistence lifecycles. + +A true single-process, single-web-host architecture is not part of this design. + +## Role-selection contract + +The launcher reads `SERVICE_CONTROL_ROLE`. + +- When the variable is absent, the selected role is `Primary`. +- An explicitly empty or whitespace-only value is invalid. +- Values are case-insensitive. +- Comma-separated values are accepted and surrounding whitespace is trimmed. +- Empty elements are invalid. For example, `Primary,,Audit` is rejected. +- Duplicate values are removed. +- Unknown values are rejected with a message containing the complete allowed-value list. +- Diagnostics and health output use canonical role names. + +The allowed values are: + +- `Primary` +- `Audit` +- `Monitoring` +- `ServicePulse` +- `All` + +`Primary`, `Audit`, and `Monitoring` are process roles. `ServicePulse` is a capability, not a fourth process role. + +Role expansion follows these rules: + +- `ServicePulse` implies the `Primary` process role and enables the `ServicePulse` capability. +- `All` expands to the `Primary`, `Audit`, and `Monitoring` process roles plus the `ServicePulse` capability. +- Process roles start in canonical order: Primary, Audit, Monitoring. +- Start order is deterministic but does not represent a readiness dependency. + +## Role descriptors + +The launcher owns immutable descriptors for the process roles: + +| Role | Executable | Working directory | Health endpoint | Port | +|---|---|---|---|---:| +| Primary | `/app/primary/ServiceControl` | `/app/primary` | `http://localhost:33333/api/configuration` | 33333 | +| Audit | `/app/audit/ServiceControl.Audit` | `/app/audit` | `http://localhost:44444/api/configuration` | 44444 | +| Monitoring | `/app/monitoring/ServiceControl.Monitoring` | `/app/monitoring` | `http://localhost:33633/connection` | 33633 | + +The application root will be injectable for tests. Production uses `/app`. + +## Launcher command contract + +The launcher supports two modes: + +- default or `run`: validate configuration and supervise selected process roles; +- `health`: probe all selected process-role endpoints and report aggregate health. + +Launcher-owned modes and options are consumed by the launcher. Application arguments are otherwise passed to children unchanged; the launcher does not parse or recreate the existing application command contracts. + +For one selected process role, all application arguments are passed through unchanged. This preserves setup, maintenance, import, and help behavior. + +For multiple selected process roles, only these application argument sets are supported initially: + +- no arguments, for a normal run; +- exactly `--setup-and-run`. + +Maintenance, import, help, setup-only, and other commands are rejected for multiple process roles with an actionable message instructing the user to select and operate one process role at a time. + +Invalid role selections and unsupported command combinations return a stable usage/configuration failure distinct from a child-process failure. + +`SERVICE_CONTROL_ROLE` remains available in each child's inherited environment, including processes re-executed by `IntegratedSetup.Run`. + +At startup, the launcher reports selected process roles, capabilities, executable paths, and ports. It never reports connection strings, certificates, tokens, or other secret configuration values. + +## Integrated ServicePulse contract + +Selecting the `ServicePulse` capability does not start another process. + +Before Primary starts: + +- if `SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE` is unset, the launcher sets it to `true` in the Primary child's environment; +- if it is explicitly `false`, startup fails with an actionable conflict message; +- an explicitly enabled value remains enabled. + +Package-owned `MONITORING_URL` and `MONITORING_URLS` values are preserved. They are browser-visible URLs and generally must not use container-local `localhost` when ServicePulse is accessed externally. + +## Process and failure contract + +Every selected executable must exist before any child is started. Children run from their role-specific working directories without a shell and inherit the launcher environment except for documented capability-specific overrides. + +The launcher observes every child from startup. + +- Any unexpected child exit initiates shutdown of all remaining children. +- A non-zero child exit is propagated when its exit code is usable; otherwise the launcher returns a stable launcher failure. +- A zero exit is still an unexpected failure while sibling roles are expected to continue running. A multi-role container never remains running in a partially healthy state. +- Simultaneous exits are resolved deterministically for diagnostics and exit-code selection. + +Child stdout and stderr are inherited unchanged so structured logs remain byte-for-byte valid and the two streams remain separate. The launcher emits only its own role-labelled lifecycle events; it does not prefix or rewrite child output. + +## Shutdown contract + +The launcher handles SIGTERM and SIGINT and also supports `Console.CancelKeyPress` during local execution. + +On shutdown it: + +1. forwards the matching graceful termination signal to every direct child; +2. waits for all children for the launcher shutdown grace period; +3. kills the entire process tree of each remaining child after the grace period; +4. waits for output handling to complete and disposes process and signal registrations before exiting. + +The grace period is configured by `SERVICECONTROL_LAUNCHER_SHUTDOWN_TIMEOUT` and defaults to `20s`. An invalid or non-positive value is a configuration error. The timeout must leave the role applications time to shut down within the hosting platform's overall stop timeout. + +Native signal delivery is implemented directly and does not depend on a shell. Process-tree escalation prevents an `IntegratedSetup.Run` descendant from being orphaned. + +## Health contract + +`health` parses and expands `SERVICE_CONTROL_ROLE` using the same implementation as run mode. It probes all selected process-role endpoints concurrently with a short per-request timeout. + +A role is healthy only when its endpoint returns: + +- an HTTP success status; +- an `application/json` content type; +- a non-empty response body. + +Unselected roles are ignored. The `ServicePulse` capability is covered by the Primary endpoint and does not add a fourth health probe. + +Health output contains one result per selected process role and a final aggregate result. The command returns success only when every selected role passes. Errors name failed roles but do not include response bodies or configuration values. + +Child liveness is enforced by supervision: when a child exits, PID 1 exits and the container stops. + +## Compatibility and publishing contract + +The first deployment increment uses the canonical image once per role as a drop-in replacement for the existing images. Combined-role operation follows after that compatibility path is proven. + +The canonical image is `particular/servicecontrol`. Migration is: + +| Old image | New image | Required role | +|---|---|---| +| `particular/servicecontrol` | `particular/servicecontrol` | omitted or `Primary` | +| `particular/servicecontrol-audit` | `particular/servicecontrol` | `Audit` | +| `particular/servicecontrol-monitoring` | `particular/servicecontrol` | `Monitoring` | + +The old Audit and Monitoring repositories will be published as thin compatibility wrapper images for one major-version transition. Each wrapper supplies its role-specific default. A plain OCI alias is not used because an alias cannot inject a different default role. + +The RavenDB image remains separate as `particular/servicecontrol-ravendb`. + +The unified application image retains isolated deployment directories and exposes ports 33333, 44444, and 33633. The .NET launcher is its only OCI entrypoint. Installer ZIPs, executable entrypoints outside the container image, and Windows installer packaging are unchanged. + +Existing volumes, databases, and queues remain unchanged when equivalent role-specific settings are preserved during migration. + +## Configuration ownership + +Each child application retains ownership of its existing validation. The launcher adds only cross-role validation that an individual process cannot perform safely. + +Shared transport and authentication values remain distinct from role-specific identity, persistence, and retention values. Combined-role operation must fail before starting any child when selected roles have unsafe identity, transport, queue, persistence, authentication, capability, command, or listener-port conflicts. + +Validation diagnostics name roles and setting keys and explain the required relationship. Connection strings, certificates, tokens, and other secret values are always redacted. + +## Deferred work + +This design does not: + +- combine the three applications in one dependency injection container; +- expose all roles through one HTTP port; +- flatten application artifacts into one directory; +- embed RavenDB in the application image; +- reduce a combined-role container to one application process. + +A single-process host requires a separate architecture decision after measuring image size, memory use, startup time, and operational complexity of the supervised design. diff --git a/src/Platform.Launcher.UnitTests/ChildProcessStartInfoFactoryTests.cs b/src/Platform.Launcher.UnitTests/ChildProcessStartInfoFactoryTests.cs new file mode 100644 index 0000000000..c73e7be9c7 --- /dev/null +++ b/src/Platform.Launcher.UnitTests/ChildProcessStartInfoFactoryTests.cs @@ -0,0 +1,36 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class ChildProcessStartInfoFactoryTests +{ + [Test] + public void Child_arguments_and_environment_overrides_are_forwarded_without_a_shell() + { + var descriptor = new RoleDescriptor( + ContainerRole.Primary, + Path.Combine("test-app", "primary", "ServiceControl"), + Path.Combine("test-app", "primary"), + new Uri("http://localhost:33333/api/configuration"), + 33333); + var child = new ChildLaunch( + descriptor, + ["--import-failed-errors", "file name.zip", "--value=quoted \"text\""], + new Dictionary + { + ["SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE"] = "true" + }); + + var startInfo = ChildProcessStartInfoFactory.Create(child); + + using (Assert.EnterMultipleScope()) + { + Assert.That(startInfo.FileName, Is.EqualTo(descriptor.ExecutablePath)); + Assert.That(startInfo.WorkingDirectory, Is.EqualTo(descriptor.WorkingDirectory)); + Assert.That(startInfo.UseShellExecute, Is.False); + Assert.That(startInfo.ArgumentList, Is.EqualTo(child.Arguments)); + Assert.That(startInfo.Environment["SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE"], Is.EqualTo("true")); + } + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/ChildProcessSupervisorTests.cs b/src/Platform.Launcher.UnitTests/ChildProcessSupervisorTests.cs new file mode 100644 index 0000000000..4ec8fab99d --- /dev/null +++ b/src/Platform.Launcher.UnitTests/ChildProcessSupervisorTests.cs @@ -0,0 +1,195 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class ChildProcessSupervisorTests +{ + string applicationRoot = null!; + + [SetUp] + public void SetUp() + { + applicationRoot = Path.Combine(Path.GetTempPath(), $"launcher-tests-{Guid.NewGuid():N}"); + foreach (var descriptor in RoleDescriptor.Create(applicationRoot)) + { + Directory.CreateDirectory(descriptor.WorkingDirectory); + File.WriteAllText(descriptor.ExecutablePath, string.Empty); + } + } + + [TearDown] + public void TearDown() => Directory.Delete(applicationRoot, true); + + [Test] + public async Task Children_start_in_canonical_order_and_an_exit_stops_siblings() + { + var factory = new FakeProcessFactory(); + var signals = new FakeSignalSender(stopOnSignal: true); + var supervisor = CreateSupervisor(factory, signals); + var run = supervisor.Run(CreatePlan("Monitoring,Primary,Audit"), NeverShutdown(CancellationToken.None), TimeSpan.FromSeconds(1)); + + factory.Processes[1].Exit(42); + var exitCode = await run.ConfigureAwait(false); + + using (Assert.EnterMultipleScope()) + { + Assert.That(factory.Processes.Select(process => process.Role), Is.EqualTo(Enum.GetValues())); + Assert.That(signals.Sent.Select(sent => sent.Role), Is.EqualTo(new[] { ContainerRole.Primary, ContainerRole.Monitoring })); + Assert.That(signals.Sent.Select(sent => sent.Signal), Has.All.EqualTo(ShutdownSignal.Terminate)); + Assert.That(exitCode, Is.EqualTo(42)); + Assert.That(factory.Processes, Has.All.Property("Disposed").True); + } + } + + [Test] + public async Task Requested_signal_is_forwarded_to_every_running_child() + { + var factory = new FakeProcessFactory(); + var signals = new FakeSignalSender(stopOnSignal: true); + var shutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run = CreateSupervisor(factory, signals) + .Run(CreatePlan("Primary,Audit"), shutdown.Task, TimeSpan.FromSeconds(1)); + + shutdown.SetResult(ShutdownSignal.Interrupt); + var exitCode = await run.ConfigureAwait(false); + + using (Assert.EnterMultipleScope()) + { + Assert.That(signals.Sent.Select(sent => sent.Role), Is.EqualTo(new[] { ContainerRole.Primary, ContainerRole.Audit })); + Assert.That(signals.Sent.Select(sent => sent.Signal), Has.All.EqualTo(ShutdownSignal.Interrupt)); + Assert.That(exitCode, Is.Zero); + } + } + + [Test] + public async Task Zero_exit_is_a_failure_when_sibling_roles_were_expected_to_keep_running() + { + var factory = new FakeProcessFactory(); + var signals = new FakeSignalSender(stopOnSignal: true); + var run = CreateSupervisor(factory, signals) + .Run(CreatePlan("Primary,Audit"), NeverShutdown(CancellationToken.None), TimeSpan.FromSeconds(1)); + + factory.Processes[0].Exit(0); + + Assert.That(await run.ConfigureAwait(false), Is.EqualTo(ChildProcessSupervisor.LauncherFailureExitCode)); + } + + [Test] + public async Task Single_role_zero_exit_is_preserved_for_maintenance_commands() + { + var factory = new FakeProcessFactory(); + var run = CreateSupervisor(factory, new FakeSignalSender(stopOnSignal: true)) + .Run(CreatePlan("Primary"), NeverShutdown(CancellationToken.None), TimeSpan.FromSeconds(1)); + + factory.Processes[0].Exit(0); + + Assert.That(await run.ConfigureAwait(false), Is.Zero); + } + + [Test] + public async Task Children_still_running_after_the_grace_period_have_their_process_trees_killed() + { + var factory = new FakeProcessFactory(); + var signals = new FakeSignalSender(stopOnSignal: false); + var shutdown = Task.FromResult(ShutdownSignal.Terminate); + + var exitCode = await CreateSupervisor(factory, signals) + .Run(CreatePlan("Primary,Audit"), shutdown, TimeSpan.FromMilliseconds(10)) + .ConfigureAwait(false); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exitCode, Is.Zero); + Assert.That(factory.Processes, Has.All.Property("TreeKilled").True); + Assert.That(factory.Processes, Has.All.Property("HasExited").True); + } + } + + [Test] + public void Every_executable_is_verified_before_any_child_starts() + { + File.Delete(RoleDescriptor.Create(applicationRoot)[1].ExecutablePath); + var factory = new FakeProcessFactory(); + + var exception = Assert.ThrowsAsync(() => CreateSupervisor(factory, new FakeSignalSender(true)) + .Run(CreatePlan("Primary,Audit"), NeverShutdown(CancellationToken.None), TimeSpan.FromSeconds(1))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception!.Message, Does.Contain("Audit executable was not found")); + Assert.That(factory.Processes, Is.Empty); + } + } + + LaunchPlan CreatePlan(string roles) + { + var selection = RoleSelection.Parse(roles); + var command = ContainerCommand.Parse([], selection.ProcessRoles.Count); + return LaunchPlan.Create(selection, command, RoleDescriptor.Create(applicationRoot), new Dictionary()); + } + + static ChildProcessSupervisor CreateSupervisor(FakeProcessFactory factory, FakeSignalSender signals) => + new(factory, signals, TimeProvider.System); + + static Task NeverShutdown(CancellationToken cancellationToken) => + Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ContinueWith( + _ => ShutdownSignal.Terminate, + cancellationToken, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + sealed class FakeProcessFactory : IChildProcessFactory + { + public List Processes { get; } = []; + + public IChildProcess Start(ChildLaunch child) + { + var process = new FakeProcess(child.Descriptor.Role, Processes.Count + 100); + Processes.Add(process); + return process; + } + } + + sealed class FakeProcess(ContainerRole role, int processId) : IChildProcess + { + readonly TaskCompletionSource exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ContainerRole Role { get; } = role; + public int ProcessId { get; } = processId; + public bool HasExited => exited.Task.IsCompleted; + public int ExitCode { get; private set; } + public bool TreeKilled { get; private set; } + public bool Disposed { get; private set; } + + public Task WaitForExit(CancellationToken cancellationToken = default) => exited.Task.WaitAsync(cancellationToken); + + public void Exit(int code) + { + ExitCode = code; + exited.TrySetResult(); + } + + public void KillTree() + { + TreeKilled = true; + Exit(137); + } + + public void Dispose() => Disposed = true; + } + + sealed class FakeSignalSender(bool stopOnSignal) : IChildSignalSender + { + public List<(ContainerRole Role, ShutdownSignal Signal)> Sent { get; } = []; + + public void Send(IChildProcess child, ShutdownSignal signal) + { + Sent.Add((child.Role, signal)); + if (stopOnSignal) + { + ((FakeProcess)child).Exit(0); + } + } + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/ContainerCommandTests.cs b/src/Platform.Launcher.UnitTests/ContainerCommandTests.cs new file mode 100644 index 0000000000..1eda18abf0 --- /dev/null +++ b/src/Platform.Launcher.UnitTests/ContainerCommandTests.cs @@ -0,0 +1,75 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class ContainerCommandTests +{ + [Test] + public void Single_role_arguments_are_forwarded_unchanged() + { + string[] arguments = ["--import-failed-errors", "input.zip", "--some-option=value with spaces"]; + + var command = ContainerCommand.Parse(arguments, 1); + + using (Assert.EnterMultipleScope()) + { + Assert.That(command.Mode, Is.EqualTo(LauncherMode.Run)); + Assert.That(command.ChildArguments, Is.EqualTo(arguments)); + } + } + + [Test] + public void Explicit_run_mode_is_consumed_and_remaining_arguments_are_forwarded() + { + var command = ContainerCommand.Parse(["run", "--setup-and-run"], 1); + + Assert.That(command.ChildArguments, Is.EqualTo(new[] { "--setup-and-run" })); + } + + [TestCaseSource(nameof(AllowedMultipleRoleArguments))] + public void Multiple_roles_allow_only_normal_run_and_setup_and_run(string[] arguments) + { + var command = ContainerCommand.Parse(arguments, 3); + + Assert.That(command.ChildArguments, Is.EqualTo(arguments.FirstOrDefault() == "run" ? arguments.Skip(1) : arguments)); + } + + [TestCase("--help")] + [TestCase("--setup")] + [TestCase("--setup-and-run", "extra")] + public void Multiple_roles_reject_other_child_commands(params string[] arguments) + { + var exception = Assert.Throws(() => ContainerCommand.Parse(arguments, 2)); + + Assert.That(exception!.Message, Does.Contain("Select one process role")); + } + + [Test] + public void Health_mode_is_launcher_owned() + { + var command = ContainerCommand.Parse(["health"], 3); + + using (Assert.EnterMultipleScope()) + { + Assert.That(command.Mode, Is.EqualTo(LauncherMode.Health)); + Assert.That(command.ChildArguments, Is.Empty); + } + } + + [Test] + public void Health_mode_rejects_application_arguments() + { + Assert.That( + () => ContainerCommand.Parse(["health", "--help"], 1), + Throws.TypeOf()); + } + + static IEnumerable AllowedMultipleRoleArguments() + { + yield return []; + yield return ["run"]; + yield return ["--setup-and-run"]; + yield return ["run", "--setup-and-run"]; + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/LaunchPlanTests.cs b/src/Platform.Launcher.UnitTests/LaunchPlanTests.cs new file mode 100644 index 0000000000..77c433f7fe --- /dev/null +++ b/src/Platform.Launcher.UnitTests/LaunchPlanTests.cs @@ -0,0 +1,95 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class LaunchPlanTests +{ + [Test] + public void Child_launches_follow_canonical_order_and_receive_unchanged_arguments() + { + var selection = RoleSelection.Parse("Monitoring,Primary,Audit"); + var command = ContainerCommand.Parse(["run", "--setup-and-run"], selection.ProcessRoles.Count); + + var plan = LaunchPlan.Create(selection, command, RoleDescriptor.Create("/test-app"), EmptyEnvironment()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(plan.Children.Select(child => child.Descriptor.Role), Is.EqualTo(Enum.GetValues())); + Assert.That(plan.Children, Has.All.Matches(child => child.Arguments.SequenceEqual(["--setup-and-run"]))); + } + } + + [Test] + public void ServicePulse_sets_the_primary_child_environment_when_unset() + { + var selection = RoleSelection.Parse("All"); + var command = ContainerCommand.Parse([], selection.ProcessRoles.Count); + + var plan = LaunchPlan.Create(selection, command, RoleDescriptor.Create("/test-app"), EmptyEnvironment()); + + var primary = plan.Children.Single(child => child.Descriptor.Role == ContainerRole.Primary); + using (Assert.EnterMultipleScope()) + { + Assert.That(primary.EnvironmentOverrides, Contains.Key("SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE")); + Assert.That(primary.EnvironmentOverrides["SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE"], Is.EqualTo("true")); + Assert.That(plan.Children.Where(child => child.Descriptor.Role != ContainerRole.Primary), + Has.All.Matches(child => child.EnvironmentOverrides.Count == 0)); + } + } + + [Test] + public void Explicitly_enabled_integrated_service_pulse_is_preserved() + { + var environment = new Dictionary + { + ["servicecontrol_enableintegratedservicepulse"] = "TRUE", + ["MONITORING_URL"] = "https://example.test/monitoring" + }; + + var plan = CreateServicePulsePlan(environment); + + Assert.That(plan.Children.Single().EnvironmentOverrides, Is.Empty); + } + + [TestCase("false")] + [TestCase("FALSE")] + public void Explicitly_disabled_integrated_service_pulse_is_rejected(string value) + { + var environment = new Dictionary + { + ["SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE"] = value + }; + + var exception = Assert.Throws(() => CreateServicePulsePlan(environment)); + + Assert.That(exception!.Message, Does.Contain("requires SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE=true")); + } + + [Test] + public void Role_descriptors_use_the_injected_application_root() + { + var descriptors = RoleDescriptor.Create(Path.Combine("root", "apps")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(descriptors[0], Is.EqualTo(new RoleDescriptor( + ContainerRole.Primary, + Path.Combine("root", "apps", "primary", "ServiceControl"), + Path.Combine("root", "apps", "primary"), + new Uri("http://localhost:33333/api/configuration"), + 33333))); + Assert.That(descriptors[1].ExecutablePath, Is.EqualTo(Path.Combine("root", "apps", "audit", "ServiceControl.Audit"))); + Assert.That(descriptors[2].HealthEndpoint, Is.EqualTo(new Uri("http://localhost:33633/connection"))); + } + } + + static LaunchPlan CreateServicePulsePlan(IReadOnlyDictionary environment) + { + var selection = RoleSelection.Parse("ServicePulse"); + var command = ContainerCommand.Parse([], selection.ProcessRoles.Count); + return LaunchPlan.Create(selection, command, RoleDescriptor.Create("/test-app"), environment); + } + + static IReadOnlyDictionary EmptyEnvironment() => new Dictionary(); +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/LauncherEnvironmentTests.cs b/src/Platform.Launcher.UnitTests/LauncherEnvironmentTests.cs new file mode 100644 index 0000000000..392f23e29a --- /dev/null +++ b/src/Platform.Launcher.UnitTests/LauncherEnvironmentTests.cs @@ -0,0 +1,58 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class LauncherEnvironmentTests +{ + [Test] + public void Run_in_place_uses_each_role_project_output_directory() + { + var launcherBaseDirectory = Path.Combine(Path.GetTempPath(), "repo", "src", "Platform.Launcher", "bin", "Debug", "net10.0"); + + var descriptors = RoleDescriptor.CreateDevelopment(launcherBaseDirectory, "Debug"); + + var sourceRoot = Path.Combine(Path.GetTempPath(), "repo", "src"); + var executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + using (Assert.EnterMultipleScope()) + { + Assert.That(descriptors[0].WorkingDirectory, + Is.EqualTo(Path.Combine(sourceRoot, "ServiceControl", "bin", "Debug", "net10.0"))); + Assert.That(descriptors[0].ExecutablePath, + Is.EqualTo(Path.Combine(descriptors[0].WorkingDirectory, $"ServiceControl{executableSuffix}"))); + Assert.That(descriptors[1].WorkingDirectory, + Is.EqualTo(Path.Combine(sourceRoot, "ServiceControl.Audit", "bin", "Debug", "net10.0"))); + Assert.That(descriptors[2].WorkingDirectory, + Is.EqualTo(Path.Combine(sourceRoot, "ServiceControl.Monitoring", "bin", "Debug", "net10.0"))); + } + } + + [Test] + public void Run_in_place_preserves_the_output_shape_used_by_development_plugin_discovery() + { + var sourceRoot = Path.Combine(Path.GetTempPath(), "repo", "src"); + var launcherBaseDirectory = Path.Combine(sourceRoot, "Platform.Launcher", "bin", "Debug", "net10.0"); + + var primary = RoleDescriptor.CreateDevelopment(launcherBaseDirectory, "Debug")[0]; + var sourceRootDiscoveredByPluginLoader = Path.GetFullPath(Path.Combine(primary.WorkingDirectory, "..", "..", "..", "..")); + + Assert.That(sourceRootDiscoveredByPluginLoader, Is.EqualTo(sourceRoot)); + } + + [Test] + public void Disabled_run_in_place_uses_the_configured_application_root() + { + var descriptors = LauncherEnvironment.CreateRoleDescriptors("false", "/custom-app", "/ignored"); + + Assert.That(descriptors[0].WorkingDirectory, Is.EqualTo(Path.Combine("/custom-app", "primary"))); + } + + [Test] + public void Invalid_run_in_place_value_is_rejected() + { + var exception = Assert.Throws(() => + LauncherEnvironment.CreateRoleDescriptors("sometimes", null, "/ignored")); + + Assert.That(exception!.Message, Is.EqualTo("SERVICECONTROL_LAUNCHER_RUN_IN_PLACE must be 'true' or 'false'.")); + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/LauncherShutdownTimeoutTests.cs b/src/Platform.Launcher.UnitTests/LauncherShutdownTimeoutTests.cs new file mode 100644 index 0000000000..6fcbb7f8a6 --- /dev/null +++ b/src/Platform.Launcher.UnitTests/LauncherShutdownTimeoutTests.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class LauncherShutdownTimeoutTests +{ + [Test] + public void Missing_value_uses_twenty_second_default() => + Assert.That(LauncherShutdownTimeout.Parse(null), Is.EqualTo(TimeSpan.FromSeconds(20))); + + [TestCase("2.5s", 2.5)] + [TestCase("00:00:03", 3)] + public void Positive_durations_are_accepted(string value, double seconds) => + Assert.That(LauncherShutdownTimeout.Parse(value), Is.EqualTo(TimeSpan.FromSeconds(seconds))); + + [TestCase("")] + [TestCase("never")] + [TestCase("0s")] + [TestCase("-1s")] + public void Invalid_or_non_positive_durations_are_rejected(string value) + { + var exception = Assert.Throws(() => LauncherShutdownTimeout.Parse(value)); + + Assert.That(exception!.Message, Does.Contain(LauncherShutdownTimeout.EnvironmentVariable)); + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/Platform.Launcher.UnitTests.csproj b/src/Platform.Launcher.UnitTests/Platform.Launcher.UnitTests.csproj new file mode 100644 index 0000000000..ae8aef0780 --- /dev/null +++ b/src/Platform.Launcher.UnitTests/Platform.Launcher.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + DefaultCore + + + + + + + + + + + + + + + + diff --git a/src/Platform.Launcher.UnitTests/RoleSelectionTests.cs b/src/Platform.Launcher.UnitTests/RoleSelectionTests.cs new file mode 100644 index 0000000000..d2dc2ef38d --- /dev/null +++ b/src/Platform.Launcher.UnitTests/RoleSelectionTests.cs @@ -0,0 +1,74 @@ +namespace ServiceControl.Launcher.UnitTests; + +using NUnit.Framework; + +[TestFixture] +public class RoleSelectionTests +{ + [Test] + public void Missing_value_defaults_to_primary() + { + var selection = RoleSelection.Parse(null); + + Assert.That(selection.ProcessRoles, Is.EqualTo(new[] { ContainerRole.Primary })); + Assert.That(selection.Capabilities, Is.Empty); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("Primary,,Audit")] + [TestCase(",Primary")] + public void Empty_values_are_rejected(string value) + { + var exception = Assert.Throws(() => RoleSelection.Parse(value)); + + Assert.That(exception!.Message, Does.Contain("Allowed values: Primary, Audit, Monitoring, ServicePulse, All")); + } + + [Test] + public void Values_are_trimmed_case_insensitive_deduplicated_and_canonically_ordered() + { + var selection = RoleSelection.Parse(" monitoring,PRIMARY, audit,primary "); + + Assert.That(selection.ProcessRoles, Is.EqualTo(new[] + { + ContainerRole.Primary, + ContainerRole.Audit, + ContainerRole.Monitoring + })); + } + + [Test] + public void ServicePulse_is_a_capability_that_implies_primary() + { + var selection = RoleSelection.Parse("ServicePulse"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(selection.ProcessRoles, Is.EqualTo(new[] { ContainerRole.Primary })); + Assert.That(selection.Capabilities, Is.EqualTo(new[] { ContainerCapability.ServicePulse })); + } + } + + [Test] + public void All_expands_to_every_process_role_and_service_pulse() + { + var selection = RoleSelection.Parse("All"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(selection.ProcessRoles, Is.EqualTo(Enum.GetValues())); + Assert.That(selection.Capabilities, Is.EqualTo(new[] { ContainerCapability.ServicePulse })); + } + } + + [TestCase("Unknown")] + [TestCase("1")] + public void Unknown_values_are_rejected_with_the_complete_allowed_list(string value) + { + var exception = Assert.Throws(() => RoleSelection.Parse(value)); + + Assert.That(exception!.Message, Is.EqualTo( + $"Invalid SERVICE_CONTROL_ROLE. Unknown role '{value}'. Allowed values: Primary, Audit, Monitoring, ServicePulse, All.")); + } +} \ No newline at end of file diff --git a/src/Platform.Launcher.UnitTests/UnixSignalSenderTests.cs b/src/Platform.Launcher.UnitTests/UnixSignalSenderTests.cs new file mode 100644 index 0000000000..9af0eb0ab9 --- /dev/null +++ b/src/Platform.Launcher.UnitTests/UnixSignalSenderTests.cs @@ -0,0 +1,41 @@ +namespace ServiceControl.Launcher.UnitTests; + +using System.Diagnostics; +using NUnit.Framework; + +[TestFixture] +public class UnixSignalSenderTests +{ + [Test] + public async Task Terminate_signal_is_delivered_to_a_real_child_process() + { + if (OperatingSystem.IsWindows()) + { + Assert.Ignore("POSIX signal delivery is used by the Linux container."); + } + + var sleepExecutable = File.Exists("/bin/sleep") ? "/bin/sleep" : "/usr/bin/sleep"; + using var process = Process.Start(new ProcessStartInfo(sleepExecutable) + { + ArgumentList = { "30" }, + UseShellExecute = false + }) ?? throw new InvalidOperationException("Failed to start the signal test process."); + using var child = new SystemChildProcess(ContainerRole.Primary, process); + + try + { + new UnixSignalSender().Send(child, ShutdownSignal.Terminate); + await child.WaitForExit().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.That(child.HasExited, Is.True); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/Platform.Launcher/ChildProcessStartInfoFactory.cs b/src/Platform.Launcher/ChildProcessStartInfoFactory.cs new file mode 100644 index 0000000000..4f5510bafc --- /dev/null +++ b/src/Platform.Launcher/ChildProcessStartInfoFactory.cs @@ -0,0 +1,29 @@ +namespace ServiceControl.Launcher; + +using System.Diagnostics; + +static class ChildProcessStartInfoFactory +{ + public static ProcessStartInfo Create(ChildLaunch child) + { + ArgumentNullException.ThrowIfNull(child); + + var startInfo = new ProcessStartInfo(child.Descriptor.ExecutablePath) + { + WorkingDirectory = child.Descriptor.WorkingDirectory, + UseShellExecute = false + }; + + foreach (var argument in child.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + foreach (var environmentOverride in child.EnvironmentOverrides) + { + startInfo.Environment[environmentOverride.Key] = environmentOverride.Value; + } + + return startInfo; + } +} diff --git a/src/Platform.Launcher/ChildProcessSupervisor.cs b/src/Platform.Launcher/ChildProcessSupervisor.cs new file mode 100644 index 0000000000..050a9c5f0e --- /dev/null +++ b/src/Platform.Launcher/ChildProcessSupervisor.cs @@ -0,0 +1,165 @@ +namespace ServiceControl.Launcher; + +using System.Diagnostics; + +sealed class ChildProcessSupervisor( + IChildProcessFactory processFactory, + IChildSignalSender signalSender, + TimeProvider timeProvider) +{ + public const int LauncherFailureExitCode = 1; + + public async Task Run( + LaunchPlan plan, + Task shutdownRequested, + TimeSpan shutdownTimeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(shutdownRequested); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(shutdownTimeout, TimeSpan.Zero); + + VerifyExecutables(plan.Children); + + var children = new List(plan.Children.Count); + var exits = new List(plan.Children.Count); + try + { + foreach (var childLaunch in plan.Children) + { + Console.WriteLine($"Starting {childLaunch.Descriptor.Role}: {childLaunch.Descriptor.ExecutablePath} (port {childLaunch.Descriptor.Port})"); + var child = processFactory.Start(childLaunch); + children.Add(child); + exits.Add(ObserveExit(child, cancellationToken)); + } + + await Task.WhenAny(exits.Append(shutdownRequested)).ConfigureAwait(false); + + if (shutdownRequested.IsCompleted) + { + var signal = await shutdownRequested.ConfigureAwait(false); + Console.WriteLine($"Launcher received {signal}; stopping {children.Count} child process(es)."); + await StopChildren(children, signal, shutdownTimeout, cancellationToken).ConfigureAwait(false); + return 0; + } + + // Select the first role in canonical launch order when multiple children exit together. + var exitedChild = children.First(child => child.HasExited); + var exitCode = exitedChild.ExitCode; + Console.Error.WriteLine($"{exitedChild.Role} exited unexpectedly with code {exitCode}; stopping remaining child processes."); + await StopChildren(children, ShutdownSignal.Terminate, shutdownTimeout, cancellationToken).ConfigureAwait(false); + + return exitCode != 0 ? exitCode : plan.Children.Count == 1 ? 0 : LauncherFailureExitCode; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await StopChildren(children, ShutdownSignal.Terminate, shutdownTimeout, CancellationToken.None).ConfigureAwait(false); + throw; + } + catch + { + await StopChildren(children, ShutdownSignal.Terminate, shutdownTimeout, CancellationToken.None).ConfigureAwait(false); + throw; + } + finally + { + foreach (var child in children) + { + child.Dispose(); + } + } + } + + static void VerifyExecutables(IEnumerable children) + { + foreach (var child in children) + { + if (!File.Exists(child.Descriptor.ExecutablePath)) + { + throw new LauncherConfigurationException( + $"The {child.Descriptor.Role} executable was not found at '{child.Descriptor.ExecutablePath}'."); + } + } + } + + static async Task ObserveExit(IChildProcess child, CancellationToken cancellationToken) + { + await child.WaitForExit(cancellationToken).ConfigureAwait(false); + Console.WriteLine($"{child.Role} exited with code {child.ExitCode}."); + } + + async Task StopChildren( + IReadOnlyCollection children, + ShutdownSignal signal, + TimeSpan shutdownTimeout, + CancellationToken cancellationToken) + { + var running = children.Where(child => !child.HasExited).ToArray(); + foreach (var child in running) + { + signalSender.Send(child, signal); + } + + if (running.Length == 0) + { + return; + } + + var allExited = Task.WhenAll(running.Select(child => child.WaitForExit(CancellationToken.None))); + var timeout = Task.Delay(shutdownTimeout, timeProvider, cancellationToken); + if (await Task.WhenAny(allExited, timeout).ConfigureAwait(false) == allExited) + { + await allExited.ConfigureAwait(false); + return; + } + + foreach (var child in running.Where(child => !child.HasExited)) + { + Console.Error.WriteLine($"{child.Role} did not stop within {shutdownTimeout}; killing its process tree."); + child.KillTree(); + } + + await Task.WhenAll(running.Select(child => child.WaitForExit(CancellationToken.None))).ConfigureAwait(false); + } +} + +interface IChildProcessFactory +{ + IChildProcess Start(ChildLaunch child); +} + +interface IChildProcess : IDisposable +{ + ContainerRole Role { get; } + int ProcessId { get; } + bool HasExited { get; } + int ExitCode { get; } + Task WaitForExit(CancellationToken cancellationToken = default); + void KillTree(); +} + +interface IChildSignalSender +{ + void Send(IChildProcess child, ShutdownSignal signal); +} + +sealed class SystemChildProcessFactory : IChildProcessFactory +{ + public IChildProcess Start(ChildLaunch child) + { + var process = Process.Start(ChildProcessStartInfoFactory.Create(child)) + ?? throw new InvalidOperationException($"Failed to start {child.Descriptor.Role}."); + return new SystemChildProcess(child.Descriptor.Role, process); + } +} + +sealed class SystemChildProcess(ContainerRole role, Process process) : IChildProcess +{ + public ContainerRole Role { get; } = role; + public int ProcessId => process.Id; + public bool HasExited => process.HasExited; + public int ExitCode => process.ExitCode; + public Task WaitForExit(CancellationToken cancellationToken = default) => process.WaitForExitAsync(cancellationToken); + public void KillTree() => process.Kill(entireProcessTree: true); + public void Dispose() => process.Dispose(); +} \ No newline at end of file diff --git a/src/Platform.Launcher/ContainerCommand.cs b/src/Platform.Launcher/ContainerCommand.cs new file mode 100644 index 0000000000..a4df8cce5f --- /dev/null +++ b/src/Platform.Launcher/ContainerCommand.cs @@ -0,0 +1,54 @@ +namespace ServiceControl.Launcher; + +enum LauncherMode +{ + Run, + Health +} + +sealed class ContainerCommand +{ + ContainerCommand(LauncherMode mode, IReadOnlyList childArguments) + { + Mode = mode; + ChildArguments = childArguments; + } + + public LauncherMode Mode { get; } + public IReadOnlyList ChildArguments { get; } + + public static ContainerCommand Parse(IReadOnlyList arguments, int processRoleCount) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentOutOfRangeException.ThrowIfLessThan(processRoleCount, 1); + + var mode = LauncherMode.Run; + var childArgumentOffset = 0; + + if (arguments.Count > 0 && arguments[0].Equals("run", StringComparison.OrdinalIgnoreCase)) + { + childArgumentOffset = 1; + } + else if (arguments.Count > 0 && arguments[0].Equals("health", StringComparison.OrdinalIgnoreCase)) + { + mode = LauncherMode.Health; + childArgumentOffset = 1; + } + + var childArguments = arguments.Skip(childArgumentOffset).ToArray(); + + if (mode == LauncherMode.Health && childArguments.Length > 0) + { + throw new LauncherConfigurationException("The health command does not accept application arguments."); + } + + if (mode == LauncherMode.Run && processRoleCount > 1 && + !(childArguments.Length == 0 || childArguments is ["--setup-and-run"])) + { + throw new LauncherConfigurationException( + "Multiple process roles support only normal run or exactly '--setup-and-run'. Select one process role to use maintenance, import, help, setup, or other application commands."); + } + + return new ContainerCommand(mode, childArguments); + } +} diff --git a/src/Platform.Launcher/ContainerRole.cs b/src/Platform.Launcher/ContainerRole.cs new file mode 100644 index 0000000000..677b990b6a --- /dev/null +++ b/src/Platform.Launcher/ContainerRole.cs @@ -0,0 +1,13 @@ +namespace ServiceControl.Launcher; + +enum ContainerRole +{ + Primary, + Audit, + Monitoring +} + +enum ContainerCapability +{ + ServicePulse +} diff --git a/src/Platform.Launcher/LaunchPlan.cs b/src/Platform.Launcher/LaunchPlan.cs new file mode 100644 index 0000000000..38e750117e --- /dev/null +++ b/src/Platform.Launcher/LaunchPlan.cs @@ -0,0 +1,78 @@ +namespace ServiceControl.Launcher; + +sealed record ChildLaunch( + RoleDescriptor Descriptor, + IReadOnlyList Arguments, + IReadOnlyDictionary EnvironmentOverrides); + +sealed class LaunchPlan +{ + const string IntegratedServicePulseVariable = "SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE"; + + LaunchPlan(RoleSelection selection, ContainerCommand command, IReadOnlyList children) + { + Selection = selection; + Command = command; + Children = children; + } + + public RoleSelection Selection { get; } + public ContainerCommand Command { get; } + public IReadOnlyList Children { get; } + + public static LaunchPlan Create( + RoleSelection selection, + ContainerCommand command, + IEnumerable descriptors, + IReadOnlyDictionary environment) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(descriptors); + ArgumentNullException.ThrowIfNull(environment); + + var descriptorsByRole = descriptors.ToDictionary(descriptor => descriptor.Role); + var servicePulseSelected = selection.HasCapability(ContainerCapability.ServicePulse); + var integratedServicePulseValue = FindEnvironmentValue(environment, IntegratedServicePulseVariable); + + if (servicePulseSelected && bool.TryParse(integratedServicePulseValue, out var enabled) && !enabled) + { + throw new LauncherConfigurationException( + $"The ServicePulse capability requires {IntegratedServicePulseVariable}=true, but it is explicitly disabled."); + } + + var children = selection.ProcessRoles.Select(role => + { + if (!descriptorsByRole.TryGetValue(role, out var descriptor)) + { + throw new LauncherConfigurationException($"No launcher descriptor is configured for the {role} role."); + } + + IReadOnlyDictionary overrides = new Dictionary(); + if (role == ContainerRole.Primary && servicePulseSelected && integratedServicePulseValue is null) + { + overrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [IntegratedServicePulseVariable] = bool.TrueString.ToLowerInvariant() + }; + } + + return new ChildLaunch(descriptor, command.ChildArguments.ToArray(), overrides); + }).ToArray(); + + return new LaunchPlan(selection, command, children); + } + + static string? FindEnvironmentValue(IReadOnlyDictionary environment, string name) + { + foreach (var pair in environment) + { + if (pair.Key.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return pair.Value; + } + } + + return null; + } +} diff --git a/src/Platform.Launcher/LauncherConfigurationException.cs b/src/Platform.Launcher/LauncherConfigurationException.cs new file mode 100644 index 0000000000..6b51343c49 --- /dev/null +++ b/src/Platform.Launcher/LauncherConfigurationException.cs @@ -0,0 +1,3 @@ +namespace ServiceControl.Launcher; + +sealed class LauncherConfigurationException(string message) : Exception(message); diff --git a/src/Platform.Launcher/LauncherEnvironment.cs b/src/Platform.Launcher/LauncherEnvironment.cs new file mode 100644 index 0000000000..8a0c38e557 --- /dev/null +++ b/src/Platform.Launcher/LauncherEnvironment.cs @@ -0,0 +1,36 @@ +namespace ServiceControl.Launcher; + +static class LauncherEnvironment +{ + public const string ApplicationRoot = "SERVICECONTROL_LAUNCHER_APP_ROOT"; + public const string RunInPlace = "SERVICECONTROL_LAUNCHER_RUN_IN_PLACE"; + + public static IReadOnlyList CreateRoleDescriptors( + string? runInPlaceValue, + string? applicationRoot, + string launcherBaseDirectory) + { + if (runInPlaceValue is null) + { + return RoleDescriptor.Create(applicationRoot ?? "/app"); + } + + if (!bool.TryParse(runInPlaceValue, out var runInPlace)) + { + throw new LauncherConfigurationException($"{RunInPlace} must be 'true' or 'false'."); + } + + return runInPlace + ? RoleDescriptor.CreateDevelopment(launcherBaseDirectory, BuildConfiguration.Name) + : RoleDescriptor.Create(applicationRoot ?? "/app"); + } + + static class BuildConfiguration + { +#if DEBUG + public const string Name = "Debug"; +#else + public const string Name = "Release"; +#endif + } +} diff --git a/src/Platform.Launcher/LauncherShutdownTimeout.cs b/src/Platform.Launcher/LauncherShutdownTimeout.cs new file mode 100644 index 0000000000..fb19335728 --- /dev/null +++ b/src/Platform.Launcher/LauncherShutdownTimeout.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.Launcher; + +static class LauncherShutdownTimeout +{ + public const string EnvironmentVariable = "SERVICECONTROL_LAUNCHER_SHUTDOWN_TIMEOUT"; + public static readonly TimeSpan Default = TimeSpan.FromSeconds(20); + + public static TimeSpan Parse(string? value) + { + if (value is null) + { + return Default; + } + + var trimmed = value.Trim(); + TimeSpan timeout; + if (trimmed.EndsWith('s') && + double.TryParse(trimmed[..^1], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var seconds)) + { + timeout = TimeSpan.FromSeconds(seconds); + } + else if (!TimeSpan.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out timeout)) + { + throw InvalidTimeout(); + } + + if (timeout <= TimeSpan.Zero) + { + throw InvalidTimeout(); + } + + return timeout; + } + + static LauncherConfigurationException InvalidTimeout() => new( + $"{EnvironmentVariable} must be a positive duration, for example '20s' or '00:00:20'."); +} \ No newline at end of file diff --git a/src/Platform.Launcher/Platform.Launcher.csproj b/src/Platform.Launcher/Platform.Launcher.csproj new file mode 100644 index 0000000000..1317b50a34 --- /dev/null +++ b/src/Platform.Launcher/Platform.Launcher.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + Exe + enable + enable + true + + + + + + + + + + + + + + diff --git a/src/Platform.Launcher/Program.cs b/src/Platform.Launcher/Program.cs new file mode 100644 index 0000000000..a853779a20 --- /dev/null +++ b/src/Platform.Launcher/Program.cs @@ -0,0 +1,60 @@ +namespace ServiceControl.Launcher; + +using System.Collections; + +static class Program +{ + const int ConfigurationErrorExitCode = 2; + + public static async Task Main(string[] args) + { + try + { + var selection = RoleSelection.Parse(Environment.GetEnvironmentVariable("SERVICE_CONTROL_ROLE")); + var command = ContainerCommand.Parse(args, selection.ProcessRoles.Count); + var descriptors = LauncherEnvironment.CreateRoleDescriptors( + Environment.GetEnvironmentVariable(LauncherEnvironment.RunInPlace), + Environment.GetEnvironmentVariable(LauncherEnvironment.ApplicationRoot), + AppContext.BaseDirectory); + var plan = LaunchPlan.Create(selection, command, descriptors, ReadEnvironment()); + + if (command.Mode == LauncherMode.Health) + { + Console.Error.WriteLine("Launcher health checks will be implemented with aggregate health support."); + return ConfigurationErrorExitCode; + } + + var shutdownTimeout = LauncherShutdownTimeout.Parse( + Environment.GetEnvironmentVariable(LauncherShutdownTimeout.EnvironmentVariable)); + + Console.WriteLine($"Selected process roles: {string.Join(", ", plan.Selection.ProcessRoles)}"); + Console.WriteLine($"Selected capabilities: {(plan.Selection.Capabilities.Count == 0 ? "none" : string.Join(", ", plan.Selection.Capabilities))}"); + + using var shutdownSignals = new ShutdownSignalSource(); + var supervisor = new ChildProcessSupervisor( + new SystemChildProcessFactory(), + new UnixSignalSender(), + TimeProvider.System); + return await supervisor.Run(plan, shutdownSignals.Requested, shutdownTimeout).ConfigureAwait(false); + } + catch (LauncherConfigurationException exception) + { + Console.Error.WriteLine(exception.Message); + return ConfigurationErrorExitCode; + } + } + + static IReadOnlyDictionary ReadEnvironment() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables()) + { + if (variable.Key is string key) + { + environment[key] = variable.Value?.ToString(); + } + } + + return environment; + } +} \ No newline at end of file diff --git a/src/Platform.Launcher/Properties/launchSettings.json b/src/Platform.Launcher/Properties/launchSettings.json new file mode 100644 index 0000000000..698fdd7a09 --- /dev/null +++ b/src/Platform.Launcher/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "Platform Launcher": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SERVICE_CONTROL_ROLE": "All", + "SERVICECONTROL_LAUNCHER_RUN_IN_PLACE": "true" + } + }, + "Setup and Run Platform Launcher": { + "commandName": "Project", + "commandLineArgs": "--setup-and-run", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SERVICE_CONTROL_ROLE": "All", + "SERVICECONTROL_LAUNCHER_RUN_IN_PLACE": "true" + } + } + } +} diff --git a/src/Platform.Launcher/RoleDescriptor.cs b/src/Platform.Launcher/RoleDescriptor.cs new file mode 100644 index 0000000000..f0b46e6366 --- /dev/null +++ b/src/Platform.Launcher/RoleDescriptor.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Launcher; + +sealed record RoleDescriptor( + ContainerRole Role, + string ExecutablePath, + string WorkingDirectory, + Uri HealthEndpoint, + int Port) +{ + public static IReadOnlyList Create(string applicationRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(applicationRoot); + + return + [ + Create(applicationRoot, ContainerRole.Primary, "primary", "ServiceControl", "http://localhost:33333/api/configuration", 33333), + Create(applicationRoot, ContainerRole.Audit, "audit", "ServiceControl.Audit", "http://localhost:44444/api/configuration", 44444), + Create(applicationRoot, ContainerRole.Monitoring, "monitoring", "ServiceControl.Monitoring", "http://localhost:33633/connection", 33633) + ]; + } + + public static IReadOnlyList CreateDevelopment(string launcherBaseDirectory, string configuration) + { + ArgumentException.ThrowIfNullOrWhiteSpace(launcherBaseDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(configuration); + + // Keep each child in its project output directory. The transport and persistence plugin + // loaders use their assembly locations to find development manifests in sibling projects. + var sourceRoot = Path.GetFullPath(Path.Combine(launcherBaseDirectory, "..", "..", "..", "..")); + var executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + + return + [ + CreateDevelopment(sourceRoot, configuration, ContainerRole.Primary, "ServiceControl", $"ServiceControl{executableSuffix}", "http://localhost:33333/api/configuration", 33333), + CreateDevelopment(sourceRoot, configuration, ContainerRole.Audit, "ServiceControl.Audit", $"ServiceControl.Audit{executableSuffix}", "http://localhost:44444/api/configuration", 44444), + CreateDevelopment(sourceRoot, configuration, ContainerRole.Monitoring, "ServiceControl.Monitoring", $"ServiceControl.Monitoring{executableSuffix}", "http://localhost:33633/connection", 33633) + ]; + } + + static RoleDescriptor Create(string root, ContainerRole role, string directory, string executable, string healthEndpoint, int port) + { + var workingDirectory = Path.Combine(root, directory); + return new(role, Path.Combine(workingDirectory, executable), workingDirectory, new Uri(healthEndpoint), port); + } + + static RoleDescriptor CreateDevelopment(string sourceRoot, string configuration, ContainerRole role, string project, string executable, string healthEndpoint, int port) + { + var workingDirectory = Path.Combine(sourceRoot, project, "bin", configuration, "net10.0"); + return new(role, Path.Combine(workingDirectory, executable), workingDirectory, new Uri(healthEndpoint), port); + } +} diff --git a/src/Platform.Launcher/RoleSelection.cs b/src/Platform.Launcher/RoleSelection.cs new file mode 100644 index 0000000000..11acd7625f --- /dev/null +++ b/src/Platform.Launcher/RoleSelection.cs @@ -0,0 +1,68 @@ +namespace ServiceControl.Launcher; + +sealed class RoleSelection +{ + const string AllowedValues = "Primary, Audit, Monitoring, ServicePulse, All"; + + RoleSelection(IReadOnlyList processRoles, IReadOnlyList capabilities) + { + ProcessRoles = processRoles; + Capabilities = capabilities; + } + + public IReadOnlyList ProcessRoles { get; } + public IReadOnlyList Capabilities { get; } + + public bool HasCapability(ContainerCapability capability) => Capabilities.Contains(capability); + + public static RoleSelection Parse(string? value) + { + if (value is null) + { + return new RoleSelection([ContainerRole.Primary], []); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw InvalidSelection("The value cannot be empty."); + } + + var requestedRoles = new HashSet(); + var capabilities = new HashSet(); + + foreach (var element in value.Split(',')) + { + var role = element.Trim(); + if (role.Length == 0) + { + throw InvalidSelection("Empty role elements are not allowed."); + } + + if (role.Equals("All", StringComparison.OrdinalIgnoreCase)) + { + requestedRoles.UnionWith(Enum.GetValues()); + capabilities.Add(ContainerCapability.ServicePulse); + } + else if (role.Equals("ServicePulse", StringComparison.OrdinalIgnoreCase)) + { + requestedRoles.Add(ContainerRole.Primary); + capabilities.Add(ContainerCapability.ServicePulse); + } + else if (Enum.GetNames().FirstOrDefault(name => name.Equals(role, StringComparison.OrdinalIgnoreCase)) is { } roleName) + { + requestedRoles.Add(Enum.Parse(roleName)); + } + else + { + throw InvalidSelection($"Unknown role '{role}'."); + } + } + + var orderedRoles = Enum.GetValues().Where(requestedRoles.Contains).ToArray(); + var orderedCapabilities = Enum.GetValues().Where(capabilities.Contains).ToArray(); + return new RoleSelection(orderedRoles, orderedCapabilities); + } + + static LauncherConfigurationException InvalidSelection(string reason) => + new($"Invalid SERVICE_CONTROL_ROLE. {reason} Allowed values: {AllowedValues}."); +} diff --git a/src/Platform.Launcher/ShutdownSignal.cs b/src/Platform.Launcher/ShutdownSignal.cs new file mode 100644 index 0000000000..77184c21c4 --- /dev/null +++ b/src/Platform.Launcher/ShutdownSignal.cs @@ -0,0 +1,80 @@ +namespace ServiceControl.Launcher; + +using System.Runtime.InteropServices; + +// Values intentionally match the POSIX signal numbers used by Linux containers. +enum ShutdownSignal +{ + Interrupt = 2, + Terminate = 15 +} + +sealed class ShutdownSignalSource : IDisposable +{ + readonly TaskCompletionSource requested = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly PosixSignalRegistration? interruptRegistration; + readonly PosixSignalRegistration? terminateRegistration; + + public ShutdownSignalSource() + { + if (!OperatingSystem.IsWindows()) + { + interruptRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => + { + context.Cancel = true; + requested.TrySetResult(ShutdownSignal.Interrupt); + }); + terminateRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + context.Cancel = true; + requested.TrySetResult(ShutdownSignal.Terminate); + }); + } + + Console.CancelKeyPress += OnCancelKeyPress; + } + + public Task Requested => requested.Task; + + void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) + { + eventArgs.Cancel = true; + requested.TrySetResult(ShutdownSignal.Interrupt); + } + + public void Dispose() + { + Console.CancelKeyPress -= OnCancelKeyPress; + interruptRegistration?.Dispose(); + terminateRegistration?.Dispose(); + } +} + +sealed partial class UnixSignalSender : IChildSignalSender +{ + public void Send(IChildProcess child, ShutdownSignal signal) + { + ArgumentNullException.ThrowIfNull(child); + + // Console control events are delivered to attached children by Windows itself. The canonical + // container is Linux, where the launcher must explicitly forward POSIX signals. + if (OperatingSystem.IsWindows() || child.HasExited) + { + return; + } + + if (Kill(child.ProcessId, (int)signal) != 0) + { + var error = Marshal.GetLastPInvokeError(); + // ESRCH means the child exited between the liveness check and signal delivery. + if (error != 3) + { + throw new InvalidOperationException( + $"Failed to send {signal} to {child.Role} process {child.ProcessId} (errno {error})."); + } + } + } + + [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)] + private static partial int Kill(int processId, int signal); +} \ No newline at end of file diff --git a/src/ServiceControl.slnx b/src/ServiceControl.slnx index 622050f094..4f241548a5 100644 --- a/src/ServiceControl.slnx +++ b/src/ServiceControl.slnx @@ -58,6 +58,7 @@ + @@ -69,6 +70,7 @@ +