Files
scadalink-design/lmxproxy/docs/plans/phase-7-integration-deployment.md
Joseph Doherty 4303f06fc3 docs(lmxproxy): add v2 rebuild design, 7-phase implementation plans, and execution prompt
Design doc covers architecture, v2 protocol (TypedValue/QualityCode), COM threading
model, session lifecycle, subscription semantics, error model, and guardrails.
Implementation plans are detailed enough for autonomous Claude Code execution.
Verified all dev tooling on windev (Grpc.Tools, protobuf-net.Grpc, Polly v8, xUnit).
2026-03-21 23:29:42 -04:00

24 KiB

Phase 7: Integration Tests & Deployment — Implementation Plan

Date: 2026-03-21 Prerequisites: Phase 4 (Host complete) and Phase 6 (Client complete) both passing. All unit tests green. Working Directory (Mac): /Users/dohertj2/Desktop/scadalink-design/lmxproxy Working Directory (windev): C:\src\lmxproxy windev SSH: ssh windev (alias configured in ~/.ssh/config, passwordless ed25519, user dohertj2)

Guardrails

  1. Never stop the v1 service until v2 is verified — deploy v2 on alternate ports first.
  2. Take a Veeam backup before cutover — provides rollback point.
  3. Integration tests run from Mac against windev — they use Grpc.Net.Client which is cross-platform.
  4. All integration tests must pass before cutover.
  5. API keys: The existing apikeys.json on windev is the source of truth for valid keys. Read it to get test keys.
  6. Real MxAccess tags: Use tags from the JoeAppEngine namespace which is the live AVEVA System Platform instance on windev.

Step 1: Build Host on windev

1.1 Pull latest code

ssh windev "cd C:\src\lmxproxy && git pull"

If the repo doesn't exist on windev yet:

ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy"

1.2 Publish Host binary

ssh windev "cd C:\src\lmxproxy && dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2\"

Expected output: C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe plus dependencies.

1.3 Create v2 appsettings.json

Create C:\publish-v2\appsettings.json configured for testing on alternate ports:

ssh windev "powershell -Command \"@'
{
  \"GrpcPort\": 50052,
  \"ApiKeyConfigFile\": \"apikeys.json\",
  \"Connection\": {
    \"MonitorIntervalSeconds\": 5,
    \"ConnectionTimeoutSeconds\": 30,
    \"ReadTimeoutSeconds\": 5,
    \"WriteTimeoutSeconds\": 5,
    \"MaxConcurrentOperations\": 10,
    \"AutoReconnect\": true
  },
  \"Subscription\": {
    \"ChannelCapacity\": 1000,
    \"ChannelFullMode\": \"DropOldest\"
  },
  \"Tls\": {
    \"Enabled\": false
  },
  \"WebServer\": {
    \"Enabled\": true,
    \"Port\": 8081
  },
  \"Serilog\": {
    \"MinimumLevel\": {
      \"Default\": \"Information\",
      \"Override\": {
        \"Microsoft\": \"Warning\",
        \"System\": \"Warning\",
        \"Grpc\": \"Information\"
      }
    },
    \"WriteTo\": [
      { \"Name\": \"Console\" },
      {
        \"Name\": \"File\",
        \"Args\": {
          \"path\": \"logs/lmxproxy-v2-.txt\",
          \"rollingInterval\": \"Day\",
          \"retainedFileCountLimit\": 30
        }
      }
    ]
  }
}
'@ | Set-Content -Path 'C:\publish-v2\appsettings.json' -Encoding UTF8\""

Key differences from production config: gRPC port is 50052 (not 50051), web port is 8081 (not 8080), log file prefix is lmxproxy-v2-.

1.4 Copy apikeys.json

If v2 should use the same API keys as v1:

ssh windev "copy C:\publish\apikeys.json C:\publish-v2\apikeys.json"

If C:\publish\apikeys.json doesn't exist (the v2 service will auto-generate one on first start):

ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate"

1.5 Verify the publish directory

ssh windev "dir C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe && dir C:\publish-v2\appsettings.json"

Step 2: Deploy v2 Host Service

2.1 Install as a separate Topshelf service

The v2 service runs alongside v1 on different ports. Install with a distinct service name:

ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\" -displayname \"SCADA Bridge LMX Proxy V2\" -description \"LmxProxy v2 gRPC service (test deployment)\" --autostart"

2.2 Start the v2 service

ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2"

2.3 Wait 10 seconds for startup, then verify

ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host.V2"

Expected: STATE: 4 RUNNING.

2.4 Verify status page

From Mac, use curl to check the v2 status page:

curl -s http://10.100.0.48:8081/ | head -20

Expected: HTML containing "LmxProxy Status Dashboard".

curl -s http://10.100.0.48:8081/api/health

Expected: OK with HTTP 200.

curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool | head -30

Expected: JSON with serviceName, connection.isConnected: true, version info.

2.5 Verify MxAccess connected

The status page should show MxAccess Connection: Connected. If it shows Disconnected, check the logs:

ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\""

2.6 Read the apikeys.json to get test keys

ssh windev "type C:\publish-v2\apikeys.json"

Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure:

{
  "Keys": [
    { "Key": "abc123...", "Role": "ReadWrite", "Description": "Default ReadWrite key" },
    { "Key": "def456...", "Role": "ReadOnly", "Description": "Default ReadOnly key" }
  ]
}

Step 3: Create Integration Test Project

3.1 Create project

On windev (or Mac — the test project is .NET 10 and cross-platform):

cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy
dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.IntegrationTests -o tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --framework net10.0

3.2 Configure csproj

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
  </ItemGroup>

  <ItemGroup>
    <None Update="appsettings.test.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

3.3 Add to solution

Edit ZB.MOM.WW.LmxProxy.slnx:

<Solution>
  <Folder Name="/src/">
    <Project Path="src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
    <Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
    <Project Path="tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj" />
    <Project Path="tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj" />
  </Folder>
</Solution>

3.4 Create test configuration

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json

{
  "LmxProxy": {
    "Host": "10.100.0.48",
    "Port": 50052,
    "ReadWriteApiKey": "REPLACE_WITH_ACTUAL_KEY",
    "ReadOnlyApiKey": "REPLACE_WITH_ACTUAL_KEY",
    "InvalidApiKey": "invalid-key-that-does-not-exist"
  }
}

IMPORTANT: After reading the actual apikeys.json from windev in Step 2.6, replace the placeholder values with the real keys.

3.5 Create test base class

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs

using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.LmxProxy.Client;

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public abstract class IntegrationTestBase : IAsyncLifetime
{
    protected IConfiguration Configuration { get; }
    protected string Host { get; }
    protected int Port { get; }
    protected string ReadWriteApiKey { get; }
    protected string ReadOnlyApiKey { get; }
    protected string InvalidApiKey { get; }
    protected LmxProxyClient? Client { get; set; }

    protected IntegrationTestBase()
    {
        Configuration = new ConfigurationBuilder()
            .AddJsonFile("appsettings.test.json")
            .Build();

        var section = Configuration.GetSection("LmxProxy");
        Host = section["Host"] ?? "10.100.0.48";
        Port = int.Parse(section["Port"] ?? "50052");
        ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured");
        ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured");
        InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key";
    }

    protected LmxProxyClient CreateClient(string? apiKey = null)
    {
        return new LmxProxyClientBuilder()
            .WithHost(Host)
            .WithPort(Port)
            .WithApiKey(apiKey ?? ReadWriteApiKey)
            .WithTimeout(TimeSpan.FromSeconds(10))
            .WithRetryPolicy(2, TimeSpan.FromSeconds(1))
            .WithMetrics()
            .Build();
    }

    public virtual async Task InitializeAsync()
    {
        Client = CreateClient();
        await Client.ConnectAsync();
    }

    public virtual async Task DisposeAsync()
    {
        if (Client is not null)
        {
            await Client.DisconnectAsync();
            Client.Dispose();
        }
    }
}

Step 4: Integration Test Scenarios

4.1 Connection Lifecycle

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class ConnectionTests : IntegrationTestBase
{
    [Fact]
    public async Task ConnectAndDisconnect_Succeeds()
    {
        // Client is connected in InitializeAsync
        Assert.True(await Client!.IsConnectedAsync());
        await Client.DisconnectAsync();
        Assert.False(await Client.IsConnectedAsync());
    }

    [Fact]
    public async Task ConnectWithInvalidApiKey_Fails()
    {
        using var badClient = CreateClient(InvalidApiKey);
        // Expect RpcException with StatusCode.Unauthenticated
        var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
            () => badClient.ConnectAsync());
        Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode);
    }

    [Fact]
    public async Task DoubleConnect_IsIdempotent()
    {
        await Client!.ConnectAsync(); // Already connected — should be no-op
        Assert.True(await Client.IsConnectedAsync());
    }
}

4.2 Read Tests

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class ReadTests : IntegrationTestBase
{
    [Fact]
    public async Task Read_StringTag_ReturnsStringValue()
    {
        // JoeAppEngine.Area is a string attribute that should return "JoeDev"
        var vtq = await Client!.ReadAsync("JoeAppEngine.Area");
        Assert.NotNull(vtq.Value);
        Assert.IsType<string>(vtq.Value);
        Assert.Equal("JoeDev", vtq.Value);
        // Quality should be Good (check via QualityExtensions.IsGood if available,
        // or check vtq.Quality == Quality.Good)
    }

    [Fact]
    public async Task Read_WritableTag_ReturnsTypedValue()
    {
        // JoeAppEngine.BTCS is a writable tag
        var vtq = await Client!.ReadAsync("JoeAppEngine.BTCS");
        Assert.NotNull(vtq.Value);
        // Verify timestamp is recent (within last hour)
        Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1));
    }

    [Fact]
    public async Task ReadBatch_MultiplesTags_ReturnsDictionary()
    {
        var tags = new[] { "JoeAppEngine.Area", "JoeAppEngine.BTCS" };
        var results = await Client!.ReadBatchAsync(tags);
        Assert.Equal(2, results.Count);
        Assert.True(results.ContainsKey("JoeAppEngine.Area"));
        Assert.True(results.ContainsKey("JoeAppEngine.BTCS"));
    }

    [Fact]
    public async Task Read_NonexistentTag_ReturnsBadQuality()
    {
        // Reading a tag that doesn't exist should return Bad quality
        // (or throw — depends on Host implementation. Adjust assertion accordingly.)
        var vtq = await Client!.ReadAsync("NonExistent.Tag.12345");
        // If the Host returns success=false, ReadAsync will throw.
        // If it returns success=true with bad quality, check quality.
        // Adjust based on actual behavior.
    }
}

4.3 Write Tests

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs

using ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class WriteTests : IntegrationTestBase
{
    [Fact]
    public async Task WriteAndReadBack_StringValue()
    {
        string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}";
        // Write to a writable string tag
        await Client!.WriteAsync("JoeAppEngine.BTCS",
            new TypedValue { StringValue = testValue });

        // Read back and verify
        await Task.Delay(500); // Allow time for write to propagate
        var vtq = await Client.ReadAsync("JoeAppEngine.BTCS");
        Assert.Equal(testValue, vtq.Value);
    }

    [Fact]
    public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied()
    {
        using var readOnlyClient = CreateClient(ReadOnlyApiKey);
        await readOnlyClient.ConnectAsync();

        var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
            () => readOnlyClient.WriteAsync("JoeAppEngine.BTCS",
                new TypedValue { StringValue = "should-fail" }));
        Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode);
    }
}

4.4 Subscribe Tests

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class SubscribeTests : IntegrationTestBase
{
    [Fact]
    public async Task Subscribe_ReceivesUpdates()
    {
        var received = new List<(string Tag, Vtq Vtq)>();
        var receivedEvent = new TaskCompletionSource<bool>();

        var subscription = await Client!.SubscribeAsync(
            new[] { "JoeAppEngine.Scheduler.ScanTime" },
            (tag, vtq) =>
            {
                received.Add((tag, vtq));
                if (received.Count >= 3)
                    receivedEvent.TrySetResult(true);
            },
            ex => receivedEvent.TrySetException(ex));

        // Wait up to 30 seconds for at least 3 updates
        var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30)));
        subscription.Dispose();

        Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}");

        // Verify the VTQ has correct structure
        var first = received[0];
        Assert.Equal("JoeAppEngine.Scheduler.ScanTime", first.Tag);
        Assert.NotNull(first.Vtq.Value);
        // ScanTime should be a DateTime value
        Assert.True(first.Vtq.Timestamp > DateTime.MinValue);
    }
}

4.5 WriteBatchAndWait Tests

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs

using ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class WriteBatchAndWaitTests : IntegrationTestBase
{
    [Fact]
    public async Task WriteBatchAndWait_TypeAwareComparison()
    {
        // This test requires a writable tag and a flag tag.
        // Adjust tag names based on available tags in JoeAppEngine.
        // Example: write values and poll a flag.

        var values = new Dictionary<string, TypedValue>
        {
            ["JoeAppEngine.BTCS"] = new TypedValue { StringValue = "BatchTest" }
        };

        // Poll the same tag we wrote to (simple self-check)
        var response = await Client!.WriteBatchAndWaitAsync(
            values,
            flagTag: "JoeAppEngine.BTCS",
            flagValue: new TypedValue { StringValue = "BatchTest" },
            timeoutMs: 5000,
            pollIntervalMs: 200);

        Assert.True(response.Success);
        Assert.True(response.FlagReached);
        Assert.True(response.ElapsedMs < 5000);
    }
}

4.6 CheckApiKey Tests

File: tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs

namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;

public class CheckApiKeyTests : IntegrationTestBase
{
    [Fact]
    public async Task CheckApiKey_ValidReadWrite_ReturnsValid()
    {
        var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey);
        Assert.True(info.IsValid);
    }

    [Fact]
    public async Task CheckApiKey_ValidReadOnly_ReturnsValid()
    {
        var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey);
        Assert.True(info.IsValid);
    }

    [Fact]
    public async Task CheckApiKey_Invalid_ReturnsInvalid()
    {
        var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345");
        Assert.False(info.IsValid);
    }
}

Step 5: Run Integration Tests

5.1 Build the test project (from Mac)

cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy
dotnet build tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests

5.2 Run integration tests against v2 on alternate port

dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal

All tests should pass against 10.100.0.48:50052.

5.3 Debug failures

If tests fail, check:

  1. v2 service is running: ssh windev "sc query ZB.MOM.WW.LmxProxy.Host.V2"
  2. v2 service logs: ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i error"
  3. Network connectivity: curl -s http://10.100.0.48:8081/api/health
  4. API keys match: ssh windev "type C:\publish-v2\apikeys.json"

5.4 Verify metrics after test run

curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool

Should show non-zero operation counts for Read, ReadBatch, Write, etc.

Step 6: Cutover

Only proceed if ALL integration tests pass.

6.1 Stop v1 service

ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host"

Verify stopped:

ssh windev "sc query ZB.MOM.WW.LmxProxy.Host"

Expected: STATE: 1 STOPPED.

6.2 Stop v2 service

ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host.V2"

6.3 Reconfigure v2 to production ports

Update C:\publish-v2\appsettings.json:

  • Change GrpcPort from 50052 to 50051
  • Change WebServer.Port from 8081 to 8080
  • Change log file prefix from lmxproxy-v2- to lmxproxy-
ssh windev "powershell -Command \"(Get-Content 'C:\publish-v2\appsettings.json') -replace '50052','50051' -replace '8081','8080' -replace 'lmxproxy-v2-','lmxproxy-' | Set-Content 'C:\publish-v2\appsettings.json'\""

6.4 Uninstall v1 service

ssh windev "C:\publish\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host\""

6.5 Uninstall v2 test service and reinstall as production service

ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\""
ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host\" -displayname \"SCADA Bridge LMX Proxy\" -description \"LmxProxy v2 gRPC service\" --autostart"

6.6 Start the production service

ssh windev "sc start ZB.MOM.WW.LmxProxy.Host"

6.7 Verify on production ports

ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host"

Expected: STATE: 4 RUNNING.

curl -s http://10.100.0.48:8080/api/health

Expected: OK.

curl -s http://10.100.0.48:8080/api/status | python3 -m json.tool | head -15

Expected: Connected, version shows v2.

6.8 Update test configuration and re-run integration tests

Update tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json:

  • Change Port from 50052 to 50051
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal

All tests should pass on the production port.

6.9 Configure service recovery

ssh windev "sc failure ZB.MOM.WW.LmxProxy.Host reset= 86400 actions= restart/60000/restart/300000/restart/600000"

This configures: restart after 1 min on first failure, 5 min on second, 10 min on subsequent. Reset counter after 1 day (86400 seconds).

Step 7: Documentation Updates

7.1 Update windev.md

Add a section about the LmxProxy v2 service to /Users/dohertj2/Desktop/scadalink-design/windev.md:

## LmxProxy v2

| Field | Value |
|---|---|
| Service Name | ZB.MOM.WW.LmxProxy.Host |
| Display Name | SCADA Bridge LMX Proxy |
| gRPC Port | 50051 |
| Status Page | http://10.100.0.48:8080/ |
| Health Endpoint | http://10.100.0.48:8080/api/health |
| Publish Directory | C:\publish-v2\ |
| API Keys | C:\publish-v2\apikeys.json |
| Logs | C:\publish-v2\logs\ |
| Protocol | v2 (TypedValue + QualityCode) |

7.2 Update lmxproxy CLAUDE.md

If lmxproxy/CLAUDE.md references v1 behavior, update:

  • Change "currently v1 protocol" references to "v2 protocol"
  • Update publish directory references from C:\publish\ to C:\publish-v2\
  • Update any value conversion notes (no more string heuristics)

7.3 Clean up v1 publish directory (optional)

ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup"

Step 8: Veeam Backup

8.1 Take incremental backup

ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\""

8.2 Wait for backup to complete (check status)

ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\""

Expected: State: Stopped, Result: Success.

8.3 Get the restore point ID

ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object Id, CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\""

8.4 Record in windev.md

Add a new row to the Restore Points table in windev.md:

| `XXXXXXXX` | 2026-XX-XX XX:XX | Increment | **Post-v2 deployment** — LmxProxy v2 live on port 50051 |

Replace placeholders with actual restore point ID and timestamp.

Completion Criteria

  • v2 Host binary published to C:\publish-v2\ on windev
  • v2 service installed and running on alternate ports (50052/8081) — verified via status page
  • Integration test project created at tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/
  • All integration tests pass against v2 on alternate ports:
    • Connect/disconnect lifecycle
    • Read string tag JoeAppEngine.Area — value "JoeDev", Good quality
    • Read writable tag JoeAppEngine.BTCS
    • Write string then read-back verification
    • ReadBatch multiple tags
    • Subscribe to JoeAppEngine.Scheduler.ScanTime — verify updates received with TypedValue + QualityCode
    • WriteBatchAndWait with type-aware flag comparison
    • CheckApiKey — valid ReadWrite, valid ReadOnly, invalid
    • Write with ReadOnly key — PermissionDenied
    • Connect with invalid API key — Unauthenticated
  • v1 service stopped and uninstalled
  • v2 service reconfigured to production ports (50051/8080) and reinstalled
  • All integration tests pass on production ports
  • Service recovery configured (restart on failure)
  • windev.md updated with v2 service details
  • lmxproxy/CLAUDE.md updated for v2
  • Veeam backup taken and restore point ID recorded in windev.md
  • v1 publish directory backed up or removed