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:

AspectM2 (default)M1 (opt-in)
Default image growth0 bytes+180-230 MB compressed (SDK)
Restore latencynone30 s - 5 min per .NET scan
Accuracylexical / AST; confidence ≤ 0.5 on generics + cross-assembly dispatchRoslyn semantic model; generics + cross-assembly resolved
Air-gapworksrequires 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-root analysis-context signal, with an operator …:DotNet:ForceMode key “reserved but not in scope”. The shipped code took a different (simpler) route: there is no DotNetExtractorModeSelector, DotNetExtractorMode enum, or DotNetRestoreAwareCallGraphExtractor — mode is chosen by the explicit Scanner:Worker:Reachability:DotNetExtractor config key handled in ReachabilityInputStageExecutor. In M1 mode the worker runs a dotnet restore pre-step (DotNetProjectRestorer) and then the existing registry dotnet extractor (the Sprint 018 MSBuildWorkspace-based DotNetCallGraphExtractor); in M2 mode it constructs DotNetLightweightCallGraphExtractor directly. 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:WorkerScannerWorkerOptions.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).

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:

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).