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).
This commit is contained in:
793
lmxproxy/docs/plans/phase-7-integration-deployment.md
Normal file
793
lmxproxy/docs/plans/phase-7-integration-deployment.md
Normal file
@@ -0,0 +1,793 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && git pull"
|
||||
```
|
||||
|
||||
If the repo doesn't exist on windev yet:
|
||||
|
||||
```bash
|
||||
ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy"
|
||||
```
|
||||
|
||||
### 1.2 Publish Host binary
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```bash
|
||||
ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate"
|
||||
```
|
||||
|
||||
### 1.5 Verify the publish directory
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2"
|
||||
```
|
||||
|
||||
### 2.3 Wait 10 seconds for startup, then verify
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/ | head -20
|
||||
```
|
||||
|
||||
Expected: HTML containing "LmxProxy Status Dashboard".
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/api/health
|
||||
```
|
||||
|
||||
Expected: `OK` with HTTP 200.
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\""
|
||||
```
|
||||
|
||||
### 2.6 Read the apikeys.json to get test keys
|
||||
|
||||
```bash
|
||||
ssh windev "type C:\publish-v2\apikeys.json"
|
||||
```
|
||||
|
||||
Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"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):
|
||||
|
||||
```bash
|
||||
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`
|
||||
|
||||
```xml
|
||||
<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`:
|
||||
|
||||
```xml
|
||||
<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`
|
||||
|
||||
```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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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)
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Verify stopped:
|
||||
|
||||
```bash
|
||||
ssh windev "sc query ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Expected: `STATE: 1 STOPPED`.
|
||||
|
||||
### 6.2 Stop v2 service
|
||||
|
||||
```bash
|
||||
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-`
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\""
|
||||
```
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
ssh windev "sc start ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
### 6.7 Verify on production ports
|
||||
|
||||
```bash
|
||||
ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Expected: `STATE: 4 RUNNING`.
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8080/api/health
|
||||
```
|
||||
|
||||
Expected: `OK`.
|
||||
|
||||
```bash
|
||||
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`
|
||||
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal
|
||||
```
|
||||
|
||||
All tests should pass on the production port.
|
||||
|
||||
### 6.9 Configure service recovery
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```markdown
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup"
|
||||
```
|
||||
|
||||
## Step 8: Veeam Backup
|
||||
|
||||
### 8.1 Take incremental backup
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```markdown
|
||||
| `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
|
||||
Reference in New Issue
Block a user