param( [string]$ApiBaseUrl = "http://192.168.200.61:18121/api/v1", [string]$ClusterID = "cfc0743d-d960-49fb-9de8-96e063d5e4aa", [string]$ActorUserID = "f67d943f-5397-4b3a-a229-695fe67ad700", [string]$EntryNodeName = "test-1", [string]$RelayNodeName = "test-3", [string]$ExitNodeName = "test-2", [string]$ResultPath = "artifacts\c18z12-service-channel-route-quality-smoke-result.json" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).ProviderPath $runId = "c18z12-" + (Get-Date -Format "yyyyMMdd-HHmmss") $resourceId = "vpn-$runId" function Invoke-Api { param( [string]$Method, [string]$Path, [object]$Body = $null ) $uri = "$ApiBaseUrl$Path" try { if ($null -eq $Body) { return Invoke-RestMethod -Method $Method -Uri $uri -TimeoutSec 30 } return Invoke-RestMethod -Method $Method -Uri $uri -ContentType "application/json" -Body ($Body | ConvertTo-Json -Depth 80) -TimeoutSec 30 } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } $details = $_.ErrorDetails.Message if (-not $details) { $details = $_.Exception.Message } throw "$Method $Path failed with HTTP $statusCode`: $details" } } function Get-NodeByName { param([string]$Name) $nodes = (Invoke-Api -Method GET -Path "/clusters/$ClusterID/nodes?actor_user_id=$ActorUserID").nodes $node = @($nodes | Where-Object { $_.name -eq $Name }) | Select-Object -First 1 if ($null -eq $node) { throw "Node '$Name' was not found in cluster $ClusterID" } return $node } function Get-SmokeRouteLabel { param([object]$RouteIntent) if ($null -eq $RouteIntent -or $null -eq $RouteIntent.PSObject.Properties["policy"]) { return "" } $policy = $RouteIntent.policy if ($null -eq $policy -or $null -eq $policy.PSObject.Properties["metadata"]) { return "" } $metadata = $policy.metadata if ($null -eq $metadata) { return "" } $smoke = $metadata.PSObject.Properties["smoke"] if ($null -eq $smoke) { return "" } return [string]$smoke.Value } function Clear-SmokeRouteIntents { $items = (Invoke-Api -Method GET -Path "/clusters/$ClusterID/mesh/route-intents?actor_user_id=$ActorUserID").route_intents foreach ($item in @($items)) { if ([string]$item.lifecycle_status -ne "active") { continue } if ([string]$item.service_class -ne "vpn_packets") { continue } if ((Get-SmokeRouteLabel -RouteIntent $item) -ne "c18z12_service_channel_route_quality") { continue } Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents/$($item.id)/expire" -Body @{ actor_user_id = $ActorUserID } | Out-Null } } function New-RouteIntent { param( [string]$SourceNodeID, [string]$DestinationNodeID, [string[]]$Hops, [int]$Priority, [string]$Label ) $expiresAt = (Get-Date).ToUniversalTime().AddMinutes(10).ToString("o") return Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents" -Body @{ actor_user_id = $ActorUserID source_selector = @{ node_id = $SourceNodeID } destination_selector = @{ node_id = $DestinationNodeID } service_class = "vpn_packets" priority = $Priority policy = @{ synthetic_enabled = $true route_version = "$runId-$Label" policy_version = "$runId-$Label" peer_directory_version = "$runId-$Label" hops = @($Hops) allowed_channels = @("vpn_packet", "fabric_control") max_ttl = 8 max_hops = 8 expires_at = $expiresAt metadata = @{ smoke = "c18z12_service_channel_route_quality" run_id = $runId label = $Label route_quality_smoke = $true } } } } function New-ServiceChannelLease { param( [string]$EntryNodeID, [string]$ExitNodeID ) return (Invoke-Api -Method POST -Path "/clusters/$ClusterID/fabric/service-channels/leases" -Body @{ actor_user_id = $ActorUserID organization_id = "org-c18z12-smoke" user_id = $ActorUserID resource_id = $resourceId service_class = "vpn_packets" entry_node_ids = @($EntryNodeID) exit_node_ids = @($ExitNodeID) preferred_entry_node_id = $EntryNodeID preferred_exit_node_id = $ExitNodeID allowed_channels = @("vpn_packet", "bulk", "control") ttl_seconds = 300 metadata = @{ smoke = "c18z12_service_channel_route_quality" run_id = $runId } }).fabric_service_channel_lease } function Send-QualityHeartbeat { param( [string]$EntryNodeID, [string]$SlowRouteID, [string]$FastRouteID ) $observedAt = (Get-Date).ToUniversalTime().ToString("o") return Invoke-Api -Method POST -Path "/clusters/$ClusterID/nodes/$EntryNodeID/heartbeats" -Body @{ health_status = "healthy" reported_version = "0.2.185" capabilities = @{ fabric_service_channel_route_manager = $true fabric_service_channel_route_quality_feedback = $true } service_states = @{ smoke = "c18z12_route_quality_feedback" } metadata = @{ fabric_service_channel_runtime_report = @{ schema_version = "c18l.fabric_service_channel_runtime_report.v1" cluster_id = $ClusterID local_node_id = $EntryNodeID observed_at = $observedAt ingress = @{ flow_scheduler = @{ schema_version = "rap.fabric_flow_scheduler.v1" service_neutral = $true service_mode = "application_protocol_agnostic" channel_stats = @{ "quality-fast" = @{ last_route_id = $FastRouteID last_next_hop = "fast" last_send_duration_ms = 8 consecutive_failures = 0 stall_count = 0 route_rebuild_recommended = $false degraded_fallback_recommended = $false } "quality-slow" = @{ last_route_id = $SlowRouteID last_next_hop = "slow" last_send_duration_ms = 900 consecutive_failures = 0 stall_count = 0 route_rebuild_recommended = $false degraded_fallback_recommended = $false } } } } } } } } $entryNode = Get-NodeByName -Name $EntryNodeName $relayNode = Get-NodeByName -Name $RelayNodeName $exitNode = Get-NodeByName -Name $ExitNodeName $slowRouteID = "" $fastRouteID = "" $result = $null try { Clear-SmokeRouteIntents $slowIntent = New-RouteIntent ` -SourceNodeID $entryNode.id ` -DestinationNodeID $exitNode.id ` -Hops @($entryNode.id, $relayNode.id, $exitNode.id) ` -Priority 2000000000 ` -Label "slow-high-priority" $fastIntent = New-RouteIntent ` -SourceNodeID $entryNode.id ` -DestinationNodeID $exitNode.id ` -Hops @($entryNode.id, $exitNode.id) ` -Priority 1999999950 ` -Label "fast-lower-priority" $slowRouteID = $slowIntent.route_intent.id $fastRouteID = $fastIntent.route_intent.id $initialLease = New-ServiceChannelLease -EntryNodeID $entryNode.id -ExitNodeID $exitNode.id if ($initialLease.status -ne "ready" -or [string]$initialLease.primary_route.route_id -ne $slowRouteID) { throw "Initial lease should select higher-priority slow route '$slowRouteID': status=$($initialLease.status) route=$($initialLease.primary_route.route_id)" } $qualityHeartbeat = Send-QualityHeartbeat -EntryNodeID $entryNode.id -SlowRouteID $slowRouteID -FastRouteID $fastRouteID Start-Sleep -Seconds 2 $qualityLease = New-ServiceChannelLease -EntryNodeID $entryNode.id -ExitNodeID $exitNode.id if ($qualityLease.status -ne "ready" -or [string]$qualityLease.primary_route.route_id -ne $fastRouteID) { throw "Quality lease should select fast route '$fastRouteID': status=$($qualityLease.status) route=$($qualityLease.primary_route.route_id) score=$($qualityLease.primary_route.path_score)" } $expiredSlow = Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents/$slowRouteID/expire" -Body @{ actor_user_id = $ActorUserID } $expiredFast = Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents/$fastRouteID/expire" -Body @{ actor_user_id = $ActorUserID } $result = [ordered]@{ schema_version = "c18z12.service_channel_route_quality_smoke.v1" run_id = $runId base_url = $ApiBaseUrl cluster_id = $ClusterID entry_node = @{ name = $entryNode.name; id = $entryNode.id } relay_node = @{ name = $relayNode.name; id = $relayNode.id } exit_node = @{ name = $exitNode.name; id = $exitNode.id } resource_id = $resourceId route_intents = @{ slow_route_id = $slowRouteID fast_route_id = $fastRouteID slow_hops = @($entryNode.id, $relayNode.id, $exitNode.id) fast_hops = @($entryNode.id, $exitNode.id) expired_slow_status = $expiredSlow.route_intent.lifecycle_status expired_fast_status = $expiredFast.route_intent.lifecycle_status } initial_lease = @{ status = $initialLease.status primary_route_id = $initialLease.primary_route.route_id primary_path_score = $initialLease.primary_route.path_score score_reasons = $initialLease.primary_route.score_reasons } quality_lease = @{ status = $qualityLease.status primary_route_id = $qualityLease.primary_route.route_id primary_path_score = $qualityLease.primary_route.path_score score_reasons = $qualityLease.primary_route.score_reasons } feedback = @{ heartbeat_status = $qualityHeartbeat.heartbeat.health_status fast_last_send_duration_ms = 8 slow_last_send_duration_ms = 900 } passed = $true checks = [ordered]@{ initial_prefers_high_priority_slow_route = ([string]$initialLease.primary_route.route_id -eq $slowRouteID) quality_prefers_fast_route = ([string]$qualityLease.primary_route.route_id -eq $fastRouteID) fast_route_has_quality_reason = (@($qualityLease.primary_route.score_reasons | Where-Object { $_ -eq "service_channel_quality_latency_le_10ms" }).Count -ge 1) route_intents_expired = ($expiredSlow.route_intent.lifecycle_status -eq "expired" -and $expiredFast.route_intent.lifecycle_status -eq "expired") } } $failedChecks = @($result.checks.GetEnumerator() | Where-Object { $_.Value -ne $true }) if ($failedChecks.Count -gt 0) { throw "C18Z12 failed checks: $($failedChecks.Name -join ', ')" } } finally { if ($slowRouteID) { try { Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents/$slowRouteID/expire" -Body @{ actor_user_id = $ActorUserID } | Out-Null } catch {} } if ($fastRouteID) { try { Invoke-Api -Method POST -Path "/clusters/$ClusterID/mesh/route-intents/$fastRouteID/expire" -Body @{ actor_user_id = $ActorUserID } | Out-Null } catch {} } } $resultFullPath = Join-Path $repoRoot $ResultPath $resultDir = Split-Path -Parent $resultFullPath if (-not (Test-Path $resultDir)) { New-Item -ItemType Directory -Path $resultDir | Out-Null } $result | ConvertTo-Json -Depth 100 | Set-Content -Path $resultFullPath -Encoding UTF8 Write-Host "C18Z12 service-channel route quality smoke passed. Result: $resultFullPath" $result