Scanner-worker .NET callgraph — M1 (restore-aware) enable
Audience: platform operators tuning the Stella Ops scanner-worker for high-fidelity .NET reachability. ADR:
ADR-013— .NET callgraph extractor dual-mode (M1 + M2). Status: opt-in. The default scanner-worker image runs M2 (lightweight, no SDK).
What this runbook covers
How to flip a scanner-worker shard from the default M2 lightweight .NET callgraph extractor (mcr.microsoft.com/dotnet/aspnet:10.0 runtime; no SDK; ~zero image growth) to the M1 restore-aware path (Roslyn-grade accuracy via MSBuildWorkspace; requires the .NET SDK and a one-shot dotnet restore per scan).
M1 trades image size + restore latency for higher callgraph fidelity:
| Aspect | M2 (default) | M1 (opt-in) |
|---|---|---|
| Default image growth | 0 bytes | +180-230 MB compressed (SDK) |
| Restore latency | none | 30 s - 5 min per .NET scan |
| Accuracy | lexical / AST; confidence ≤ 0.5 on generics + cross-assembly dispatch | Roslyn semantic model; generics + cross-assembly resolved |
| Air-gap | works | requires NUGET_OFFLINE_PACKAGES pre-mirror |
Pick M1 only when a downstream gate requires high-fidelity .NET reachability (e.g. pre-prod policy gate with DenyOnFailure=true on reachable findings). For most CI scans, M2 is the right default.
As-built note (code is source of truth). ADR-013 describes mode selection as an internal heuristic driven by a
restored-project-rootanalysis-context signal, with an operator…:DotNet:ForceModekey “reserved but not in scope”. The shipped code took a different (simpler) route: there is noDotNetExtractorModeSelector,DotNetExtractorModeenum, orDotNetRestoreAwareCallGraphExtractor— mode is chosen by the explicitScanner:Worker:Reachability:DotNetExtractorconfig key handled inReachabilityInputStageExecutor. In M1 mode the worker runs adotnet restorepre-step (DotNetProjectRestorer) and then the existing registrydotnetextractor (the Sprint 018MSBuildWorkspace-basedDotNetCallGraphExtractor); in M2 mode it constructsDotNetLightweightCallGraphExtractordirectly. This runbook documents the as-built config-key path; treat ADR-013’s selector/ForceMode prose as design intent that diverged, not as the wiring.
Step 1 — build an M1-capable scanner-worker variant image
The default scanner-worker image is built from devops/release/docker/Dockerfile.dotnet-service with the digest-pinned aspnet runtime. M1 requires the SDK in the runtime layer, not just the build layer.
Create a thin variant image based on the default:
# Dockerfile.scanner-worker.m1
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
# Pinned SDK image — keep this in lockstep with the SDK ARG in
# Dockerfile.dotnet-service.
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0
USER root
# Install the SDK into the runtime image. Use the official tarball
# (no apt-get) so this works on the alpine / debian base equally.
RUN curl -sSL -o /tmp/dotnet-install.sh https://dot.net/v1/dotnet-install.sh \
&& chmod +x /tmp/dotnet-install.sh \
&& /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \
&& ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \
&& rm /tmp/dotnet-install.sh
# Optional air-gap mode: mount /opt/nuget-offline as a read-only volume
# and the worker honours NUGET_OFFLINE_PACKAGES below.
ENV NUGET_OFFLINE_PACKAGES=""
Operator-side dependency-license gate (CLAUDE.md §2.6, docs/legal/THIRD-PARTY-DEPENDENCIES.md) applies — the .NET SDK is MIT but the operator is responsible for the variant image’s license review since the default ship does not include the SDK.
Step 2 — flip the worker config
In the worker’s appsettings*.json (or the env-overlay used by your compose / helm deployment), set:
{
"Scanner": {
"Worker": {
"Reachability": {
"DotNetExtractor": "msbuild",
"DotNetRestoreTimeout": "00:05:00"
}
}
}
}
Or via env vars (standard ASP.NET Core configuration double-underscore convention; the options bind under section Scanner:Worker — ScannerWorkerOptions.SectionName — with Reachability as a nested property):
Scanner__Worker__Reachability__DotNetExtractor=msbuild
Scanner__Worker__Reachability__DotNetRestoreTimeout=00:05:00
Accepted aliases for the mode flag: msbuild, restore, M1 (matched case-insensitively, so MSBuild / m1 also work). Default auto (or any other value) keeps the default behaviour and skips the restore pre-step. The selection lives in ReachabilityInputStageExecutor.ShouldRunDotNetRestore; the DotNetProjectRestorer is only constructed at all when one of these aliases is set (Program.cs), so auto carries zero restore-path footprint.
DotNetRestoreTimeout is the per-restore wall-clock bound passed to DotNetProjectRestorer (default 5 minutes — bound from ScannerWorkerOptions.ReachabilityOptions.DotNetRestoreTimeout, which itself defaults to 5 min and matches the restorer’s own DefaultTimeout fallback); on timeout the restore subprocess is killed (Process.Kill(entireProcessTree: true)) and the run falls through to the unrestored extractor (see Step 4).
Step 3 — air-gap mode (optional, recommended for prod)
If the scanner-worker host does NOT have egress to api.nuget.org, pre-mirror the packages the operator expects to see and mount them read-only into the container:
# docker-compose.scanner-worker.m1.override.yml
services:
scanner-worker:
image: stellaops/scanner-worker:m1-<tag>
environment:
NUGET_OFFLINE_PACKAGES: /opt/nuget-offline
volumes:
- /var/lib/stellaops/nuget-offline:/opt/nuget-offline:ro
DotNetProjectRestorer reads NUGET_OFFLINE_PACKAGES from the worker process environment. When set, dotnet restore runs with --source <path> + --no-cache so no external NuGet feed is contacted (matches the offline/air-gap-first posture per feedback_on_prem_first_no_cloud_defaults).
Step 4 — verify
After the worker restarts with M1 enabled, submit a .NET scan and tail the worker log for the restore line:
docker logs stellaops-scanner-worker 2>&1 | grep -E "dotnet restore|MSBuildWorkspace"
Expected on success:
dotnet restore: target=/tmp/stellaops/scan-pipeline/<scan>/rootfs/src/Sample.sln offline=False timeout=300s cache=/root/.nuget/packages
dotnet restore: succeeded for /tmp/stellaops/scan-pipeline/<scan>/rootfs/src/Sample.sln.
prepare-reachability-inputs: dotnet restore SUCCEEDED for /tmp/stellaops/scan-pipeline/<scan>/rootfs/src/Sample.sln (cache=/root/.nuget/packages); proceeding with MSBuildWorkspace extractor.
prepare-reachability-inputs: extracted dotnet call graph for job <id>: nodes=<N> edges=<M> paths=<P>
Expected on no-SDK fallback (operator misconfigured — running M1 mode against the default image with no SDK):
dotnet restore: failed to launch 'dotnet' — SDK likely missing on host.
prepare-reachability-inputs: dotnet restore FAILED for <target>: Failed to launch 'dotnet': ...; MSBuildWorkspace extractor will run unrestored.
The MSBuild extractor will still run after a restore failure; it will likely produce an empty graph (the Sprint 074 0-edge mode). Switch back to auto (M2) or fix the SDK availability and re-scan.
Beyond the logs, the worker also records the restore outcome on the scan’s lease metadata so it can be inspected after the fact:
- on success:
reachability.dotnet.restore.cache_path(ScanMetadataKeys.ReachabilityDotNetRestoreCachePath) = the resolved NuGet cache path the packages landed in. - on failure:
reachability.dotnet.restore.error(ScanMetadataKeys.ReachabilityDotNetRestoreError) = the captured stderr/stdout from the failed restore.
Only one of the two keys is set per run (success xor failure); neither is set in auto/M2 mode because the restore pre-step never runs.
Step 5 — back off
If M1 causes restore-timeout flakiness or pulls the worker into a NuGet-feed dependency you cannot satisfy, set Scanner:Worker:Reachability:DotNetExtractor=auto and restart the worker. The default M2 lightweight extractor produces a non-empty callgraph at scan-rootfs with no SDK or restore dependency (per ADR-013).
Related
- ADR-013 — .NET callgraph extractor dual-mode
- Sprint 075 dossier
- Source:
DotNetProjectRestorer.cs - Wiring:
ReachabilityInputStageExecutor.cs(theShouldRunDotNetRestoreselector).
