---
title: "run.ps1 - MultiSiteManufacturing source"
url: "https://nsta1.github.io/Orleans.Lattice/samples/MultiSiteManufacturing/source/run-ps1.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/samples/MultiSiteManufacturing/run.ps1"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
---
# run.ps1

Part of [MultiSiteManufacturing source](../source.md).

````powershell
<#
.SYNOPSIS
  Launches the Multi-Site Manufacturing sample (M14: Docker Compose + Traefik).

.DESCRIPTION
  The sample runs as two independent Orleans clusters ("us" +
  "eu"), each with two silos and its own Azurite, all inside
  Docker Compose. A per-cluster Traefik reverse proxy provides sticky-
  session load balancing across the cluster's two silos. Three host
  ports are published:

    http://localhost:5001  traefik-us  -> silo-us-a | silo-us-b
    http://localhost:5002  traefik-eu  -> silo-eu-a | silo-eu-b
    http://localhost:3000  grafana     -> observability dashboards

  Individual silo HTTP ports (:8080), Orleans silo (:11111) and gateway
  (:30000) ports, and the per-cluster Azurite endpoints live on internal
  Compose networks only. The shared backup Azurite account is reachable from
  both clusters, and Prometheus is multi-homed only so it can scrape both.
  See docker-compose.yml for the topology and its rationale.

.PARAMETER Down
  Stop and remove all containers, networks, and named volumes.

.PARAMETER Clean
  Wipe any pre-existing containers, networks, and named volumes before
  starting the stack. Use this when you want a fresh run with no seeded
  state carried over from a previous ./run.ps1 invocation. Ignored when
  combined with -Down or -Logs.

.PARAMETER Logs
  Tail logs from all silos (follow mode). Ctrl+C detaches without
  stopping the containers.

.PARAMETER NoBuild
  Skip "docker compose build" - reuse the cached msmfg-host:dev image.

.PARAMETER Service
  Restrict -Logs to a single compose service, e.g. silo-us-a.

.PARAMETER Username
  Enable state-API authentication for the Orleans.Lattice.Explorer (issue
  #886). When supplied together with -Password, the stack comes up with the
  read-only state API requiring a Basic credential: the salted PBKDF2 hash is
  generated by tools/New-LatticeStateCredential.ps1 and delivered to every silo
  container as LATTICE_STATE_USER_<Username> via a git-ignored .env file. The
  plaintext password never reaches a container env, a command line, or the
  compose file. The web explorer does not seed a browser sign-in from these
  values by default; launch it, then enter the matching credentials in the
  sign-in dialog. Omit both to run anonymously (the default, unchanged
  behaviour).

.PARAMETER Password
  The plaintext password paired with -Username. Validated against the helper's
  policy (minimum 8 characters with an uppercase letter, a lowercase letter, and
  a digit). Read in-process and hashed; never written anywhere in plaintext.

.PARAMETER Backup
  Enable the backup / restore subsystem so the Orleans.Lattice.Explorer backup
  UI can drive capture and restore against this stack (issue #1131). Sets
  LATTICE_BACKUP_ENABLED=true in the git-ignored .env; every silo then registers
  the shared external backup sink, the control API, and its gRPC binding. The
  binding runs with authorization turned OFF - this is a demo-grade, insecure
  posture (anyone who can reach the cluster endpoint can back up or restore),
  matching how the state API runs anonymously by default. Omit to leave the
  backup subsystem out entirely (the default, unchanged behaviour). Independent
  of -Username/-Password: the two toggles can be combined or used separately.

.EXAMPLE
  ./run.ps1
    Build the image (if needed), start all services, wait until the two
    Traefik entrypoints accept TCP, then print the two cluster URLs.

.EXAMPLE
  ./run.ps1 -Logs
    Tail logs from every silo until Ctrl+C.

.EXAMPLE
  ./run.ps1 -Username alice -Password 'Sup3rSecret'
    Start the stack with state-API authentication enabled. Then browse it:
    ./run-explorer.ps1 -Username alice -Password 'Sup3rSecret'

.EXAMPLE
  ./run.ps1 -Backup
    Start the stack with the backup / restore subsystem enabled so the explorer
    backup UI can capture and restore. Demo-grade: the backup gRPC binding runs
    with authorization off.

.EXAMPLE
  ./run.ps1 -Down
    Tear everything down including the Azurite volumes (seeded state
    is deleted - next ./run.ps1 will re-seed). Also removes the generated
    .env credential file.

.EXAMPLE
  ./run.ps1 -Clean
    Wipe any previous state, then build and start the stack fresh.
#>
param(
  [switch]$Down,
  [switch]$Logs,
  [switch]$NoBuild,
  [switch]$Clean,
  [string]$Service,
  [string]$Username,
  [string]$Password,
  [switch]$Backup
)

$ErrorActionPreference = "Stop"

# Path to the git-ignored credential .env loaded by docker-compose (issue #886).
$EnvFilePath = Join-Path $PSScriptRoot ".env"

# Validate the -Username / -Password pairing up front: both or neither.
$authRequested = -not [string]::IsNullOrWhiteSpace($Username) -or -not [string]::IsNullOrWhiteSpace($Password)
$authEnabled = -not [string]::IsNullOrWhiteSpace($Username) -and -not [string]::IsNullOrWhiteSpace($Password)
if ($authRequested -and -not $authEnabled) {
    throw "Supply BOTH -Username and -Password to enable state-API authentication, or neither to run anonymously."
}

function Remove-CredentialEnvFile {
    if (Test-Path $EnvFilePath) {
        Remove-Item $EnvFilePath -Force -ErrorAction SilentlyContinue
        Write-Host "Removed generated credential .env" -ForegroundColor DarkGray
    }
}

function Write-CredentialEnvFile {
    # Always (re)write .env on the up path so a stale toggle from a previous run
    # never lingers into a later run. Both the state-API auth flag and the backup
    # flag are written every time (each defaulting to false) so .env reflects
    # exactly the switches passed to THIS invocation. The two flags are
    # independent; env_file injects whichever lines are present into every silo.
    $backupLine = if ($Backup) { "LATTICE_BACKUP_ENABLED=true" } else { "LATTICE_BACKUP_ENABLED=false" }

    if (-not $authEnabled) {
        Set-Content -Path $EnvFilePath -Value @("EXPLORER_STATE_AUTH=false", $backupLine) -Encoding ascii
        if ($Backup) {
            Write-Host "Backup subsystem enabled (LATTICE_BACKUP_ENABLED=true, .env)" -ForegroundColor Green
        }
        return
    }

    $helper = Join-Path $PSScriptRoot "..\..\tools\New-LatticeStateCredential.ps1"
    if (-not (Test-Path $helper)) {
        throw "Credential helper not found at $helper"
    }

    # Hand the plaintext password to the helper through an environment variable
    # (never on the command line) so it cannot be observed via the process table.
    # The helper hashes it in-process and writes only the salted hash to stdout.
    $env:MSMFG_STATE_PW = $Password
    try {
        $credLine = & pwsh -NoProfile -File $helper -Username $Username -PasswordEnv MSMFG_STATE_PW -Format env
        $helperExit = $LASTEXITCODE
    }
    finally {
        Remove-Item Env:MSMFG_STATE_PW -ErrorAction SilentlyContinue
    }

    if ($helperExit -eq 3) {
        throw "Password rejected by policy: minimum 8 characters with at least one uppercase letter, one lowercase letter, and one digit."
    }
    if ($helperExit -ne 0 -or [string]::IsNullOrWhiteSpace($credLine)) {
        throw "Credential generation failed (helper exit $helperExit)."
    }

    # $credLine is "LATTICE_STATE_USER_<user>=pbkdf2-sha256$...". Pair it with the
    # enable flag the host reads to turn RequireAuthorization on, plus the backup
    # toggle so the two subsystems can be enabled together.
    Set-Content -Path $EnvFilePath -Value @("EXPLORER_STATE_AUTH=true", $credLine, $backupLine) -Encoding ascii
    Write-Host "Generated state-API credential for '$Username' (.env, git-ignored)" -ForegroundColor Green
    if ($Backup) {
        Write-Host "Backup subsystem enabled (LATTICE_BACKUP_ENABLED=true, .env)" -ForegroundColor Green
    }
}

# Always run compose from this script's directory so docker-compose.yml,
# Dockerfile, and the relative build context (../../) resolve correctly.
Push-Location $PSScriptRoot
try {
    # Sanity-check: docker must be on PATH and the daemon reachable.
    $dockerCmd = Get-Command docker -ErrorAction SilentlyContinue
    if (-not $dockerCmd) {
        throw "docker is not on PATH. Install Docker Desktop (Windows) or the Docker engine."
    }
    & docker info --format "{{.ServerVersion}}" 2>$null | Out-Null
    if ($LASTEXITCODE -ne 0) {
        throw "docker daemon is not reachable. Start Docker Desktop and retry."
    }

    if ($Down) {
        Write-Host "Stopping msmfg stack and removing volumes..." -ForegroundColor Yellow
        & docker compose down --volumes --remove-orphans
        Remove-CredentialEnvFile
        return
    }

    if ($Logs) {
        if ($Service) {
            & docker compose logs -f $Service
        } else {
            & docker compose logs -f
        }
        return
    }

    if ($Clean) {
        Write-Host "Clearing pre-existing msmfg state (containers, networks, volumes)..." -ForegroundColor Yellow
        & docker compose down --volumes --remove-orphans
        if ($LASTEXITCODE -ne 0) { throw "docker compose down failed (exit $LASTEXITCODE)." }
        Remove-CredentialEnvFile
    }

    # Write (or refresh) the credential .env before bringing the stack up so
    # docker-compose's optional env_file picks it up. Anonymous runs reset it to
    # EXPLORER_STATE_AUTH=false; authenticated runs generate the salted hash.
    Write-CredentialEnvFile

    if (-not $NoBuild) {
        Write-Host "Building msmfg-host:dev image (docker compose build)..." -ForegroundColor Cyan

        # All four silos share the identical msmfg-host:dev image, so build it
        # once (silo-us-a) instead of letting compose run four identical builds.
        # BuildKit is required for the Dockerfile's NuGet cache mount, which keeps
        # the global-packages folder warm between builds so restore only hits
        # nuget.org on the very first build - the rest resolve locally.
        $env:DOCKER_BUILDKIT = "1"
        $env:COMPOSE_DOCKER_CLI_BUILD = "1"
        & docker compose build silo-us-a
        if ($LASTEXITCODE -ne 0) { throw "docker compose build failed (exit $LASTEXITCODE)." }
    }

    Write-Host "Starting msmfg stack (docker compose up -d)..." -ForegroundColor Cyan
    & docker compose up -d
    if ($LASTEXITCODE -ne 0) { throw "docker compose up failed (exit $LASTEXITCODE)." }

    # Poll the published ports on the host side until they accept TCP.
    # Each silo opens its HTTP listener well before the Orleans cluster
    # membership settles, so this is a minimal "process is alive" probe,
    # not a readiness gate. Full cluster bootstrap (replication reminders,
    # seed run) can take another 10-30 seconds and is visible in logs.
    function Wait-ForTcpPort {
        param([string]$HostName, [int]$Port, [int]$TimeoutSeconds = 60)
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        while ($sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
            try {
                $client = [System.Net.Sockets.TcpClient]::new()
                $iar = $client.BeginConnect($HostName, $Port, $null, $null)
                if ($iar.AsyncWaitHandle.WaitOne(1000)) {
                    $client.EndConnect($iar)
                    $client.Close()
                    return $true
                }
                $client.Close()
            } catch { Start-Sleep -Milliseconds 500 }
        }
        return $false
    }

    $urls = @(
        @{ Name = "traefik-us (US cluster)"; Port = 5001 }
        @{ Name = "traefik-eu (EU cluster)"; Port = 5002 }
    )

    foreach ($u in $urls) {
        Write-Host -NoNewline "Waiting for $($u.Name) on :$($u.Port)... "
        if (Wait-ForTcpPort -HostName "localhost" -Port $u.Port -TimeoutSeconds 90) {
            Write-Host "ready" -ForegroundColor Green
        } else {
            Write-Host "TIMEOUT" -ForegroundColor Red
            # Traefik itself comes up in ~1s; a timeout here usually means
            # no healthy backend silo, so point the operator at silo logs.
            Write-Host "  Check: docker compose logs traefik-us traefik-eu"
            Write-Host "  Check: docker compose logs silo-us-a silo-us-b silo-eu-a silo-eu-b"
        }
    }

    Write-Host ""
    Write-Host "Cluster URLs (sticky-LB via Traefik):" -ForegroundColor Cyan
    Write-Host "  http://localhost:5001  US cluster (routes to silo-us-a | silo-us-b)"
    Write-Host "  http://localhost:5002  EU cluster (routes to silo-eu-a | silo-eu-b)"
    Write-Host ""
    Write-Host "Explore the cluster with Orleans.Lattice.Explorer:" -ForegroundColor Cyan
    if ($authEnabled) {
        Write-Host "  State-API authentication is ENABLED (user '$Username')."
        Write-Host "  ./run-explorer.ps1 -Username $Username -Password <password>   browse US (signed in)"
        Write-Host "  ./run-explorer.ps1 -Cluster eu -Username $Username -Password <password>   browse EU"
    } else {
        Write-Host "  State-API authentication is DISABLED (anonymous, default)."
        Write-Host "  ./run-explorer.ps1                 browse US in the Blazor explorer"
        Write-Host "  ./run-explorer.ps1 -Cluster eu     browse EU"
        Write-Host "  ./run-explorer.ps1 -Client windows browse in the Windows desktop explorer"
    }
    if ($Backup) {
        Write-Host "  Backup subsystem is ENABLED (demo-grade, authorization off)."
        Write-Host "  Drive capture / restore from the explorer's backup UI."
    }
    Write-Host ""
    Write-Host "Useful commands:" -ForegroundColor Cyan
    Write-Host "  ./run.ps1 -Logs                    tail all silo logs"
    Write-Host "  ./run.ps1 -Logs -Service silo-us-a tail one silo"
    Write-Host "  docker compose ps                  show container state"
    Write-Host "  docker network disconnect msmfg_us-net msmfg-traefik-eu"
    Write-Host "                                     simulate a cross-cluster partition (sever US -> EU)"
    Write-Host "  ./run.ps1 -Username u -Password p   start with state-API auth enabled"
    Write-Host "  ./run.ps1 -Clean                   wipe state, then start fresh"
    Write-Host "  ./run.ps1 -Backup                  start with the backup subsystem enabled"
    Write-Host "  ./run.ps1 -Down                    stop + wipe volumes"
}
finally {
    Pop-Location
}
````

Previous: [run-explorer.ps1](run-explorer-ps1.md). Next: [src/MultiSiteManufacturing.Contracts/MultiSiteManufacturing.Contracts.csproj](src-multisitemanufacturing-contracts-multisitemanufacturing.md). Contents: [MultiSiteManufacturing source](../source.md).
