diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8030575..874ba77 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,9 +28,14 @@ jobs: - name: Build run: dotnet build --no-restore - name: Test + env: + UseKubernetes: false # Use Docker in CI/CD run: | cdir=`pwd` cd LNUnit.Tests + # Pre-pull commonly used images to speed up tests + docker pull polarlightning/bitcoind:30.0 || true + docker pull lightninglabs/lnd:v0.20.0-beta || true dotnet test --filter FullyQualifiedName~LNUnit.Test --no-build --verbosity normal -l "console;verbosity=detailed" --collect:"XPlat Code Coverage" --logger "trx;LogFileName=test-results.trx" --results-directory $cdir/coverage - name: Test Report uses: dorny/test-reporter@v1 diff --git a/.gitignore b/.gitignore index 5fee00e..fa2a695 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ obj/ riderModule.iml /_ReSharper.Caches/ .idea +.DS_Store ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5138725 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LNUnit is a unit-testing framework for Bitcoin Lightning Network systems. It provides infrastructure to write tests for Lightning Network applications by spinning up containerized Bitcoin and Lightning nodes (LND, Eclair, CLN) in Docker environments. + +The repository contains: +- **LNUnit**: Core framework for managing Docker-based Lightning Network test environments +- **LNUnit.LND**: LND gRPC client wrappers and typed clients (generated from protobuf) +- **LNBolt**: C# BOLT protocol helpers (BigSize, TLV, onion routing, key derivation) +- **LNUnit.Tests**: Integration tests demonstrating the framework + +## Development Commands + +### Build +```bash +dotnet restore +dotnet build +``` + +### Format Check +```bash +dotnet tool install --global dotnet-format +dotnet format --verify-no-changes +``` + +### Run Tests +```bash +cd LNUnit.Tests +dotnet test --filter FullyQualifiedName~LNUnit.Test --verbosity normal +``` + +### Run Specific Test +```bash +cd LNUnit.Tests +dotnet test --filter "FullyQualifiedName~YourTestClassName.YourTestMethodName" +``` + +### Package NuGet +Packages are built automatically on build for projects with `GeneratePackageOnBuild` enabled. + +## Architecture + +### Core Components + +**LNUnitBuilder** (`LNUnit/Setup/LNUnitBuilder.cs`) +- Central orchestration class for setting up test networks +- Manages Docker containers for Bitcoin and Lightning nodes +- Handles node initialization, peer connections, and channel setup +- Uses fluent builder pattern with extension methods +- Key methods: + - `Build()`: Spins up containers and initializes network + - `Destroy()`: Tears down test environment + - Helper methods: `AddBitcoinCoreNode()`, `AddPolarLNDNode()`, `AddPolarEclairNode()`, etc. + +**LNUnitNetworkDefinition** (`LNUnit/Setup/LNUnitNetworkDefinition.cs`) +- Defines the structure of test networks (nodes, channels, configuration) +- Serializable to JSON for saving/loading network configurations +- Contains nested classes: `BitcoinNode`, `LndNode`, `EclairNode`, `CLNNode`, `Channel` + +**LNDNodePool** (`LNUnit.LND/LNDNodePool.cs`) +- Manages a pool of LND node connections +- Monitors node readiness with periodic health checks +- Provides rebalancing functionality between nodes +- Uses `PeriodicTimer` for polling node states +- Implements `IDisposable` for cleanup + +**LNDNodeConnection** (`LNUnit.LND/LNDNodeConnection.cs`) +- Wrapper around LND gRPC clients +- Manages gRPC channel with macaroon authentication and TLS +- Exposes typed clients: `LightningClient`, `RouterClient`, `StateClient`, etc. +- Provides node state checking: `IsRpcReady`, `IsServerReady` + +### HTLC & Channel Management + +The framework supports advanced Lightning features: +- **Channel Acceptors**: `LNDChannelAcceptor` for programmatic channel acceptance +- **HTLC Interceptors**: `LNDSimpleHtlcInterceptorHandler` for intercepting and modifying HTLCs +- **Channel Events**: `LNDChannelEventsHandler` for monitoring channel state changes +- **Custom Messages**: `LNDCustomMessageHandler` for protocol extensions + +### LNBolt Utilities + +BOLT protocol implementation helpers in `LNBolt/`: +- **BigSize**: Native BOLT #1 BigSize integer parsing +- **TLV**: Type-Length-Value record decoding +- **OnionBlob**: Onion routing packet decoding (BOLT #4) +- **ECKeyPair**: Key pair generation and shared secret derivation +- **PayReq**: BOLT #11 invoice parsing + +## Testing Patterns + +Tests inherit from `AbcLightningAbstractTests` which: +- Sets up a test network with multiple Lightning nodes +- Provides fixtures for different database backends (SQLite, BoltDB, Postgres) +- Handles setup/teardown of Docker containers +- Injects logging via Serilog + +Common test flow: +1. `OneTimeSetup`: Initialize `LNUnitBuilder` and build network +2. `PerTestSetUp`: Reset state, generate new blocks +3. Test execution: Use `Builder` to interact with nodes +4. `Dispose`: Cleanup containers and resources + +Example node access: +```csharp +var alice = await Builder.GetNodeFromAlias("alice"); +var invoice = await Builder.GeneratePaymentRequestFromAlias("bob", new Invoice { Value = 1000 }); +var payment = await Builder.MakeLightningPaymentFromAlias("alice", new SendPaymentRequest { PaymentRequest = invoice.PaymentRequest }); +``` + +## Docker Requirements + +### macOS Networking +Docker containers need direct network access. Use either: +- **Docker Mac Net Connect**: `brew install chipmk/tap/docker-mac-net-connect && sudo brew services start chipmk/tap/docker-mac-net-connect` +- **OrbStack**: Works out of the box + +### Container Images +Default images from Polar Lightning: +- Bitcoin: `polarlightning/bitcoind:29.0` +- LND: `polarlightning/lnd:0.17.4-beta` +- Eclair: `polarlightning/eclair:0.6.0` +- CLN: `polarlightning/clightning:0.10.0` + +## gRPC Code Generation + +LND gRPC clients in `LNUnit.LND/` are generated from protobuf files in `Grpc/`: +- Uses `Grpc.Tools` package for code generation +- Configured in `LNUnit.LND.csproj` with `` directives +- Generated code outputs to `obj/` directories during build +- Covers all LND subsystems: main API, router, signer, invoices, wallet, etc. + +## Project Dependencies + +Key NuGet packages: +- **Grpc.Net.ClientFactory** (2.71.0): gRPC client infrastructure +- **Google.Protobuf** (3.33.1): Protocol buffer serialization +- **Docker.DotNet** (3.125.15): Docker API client +- **NBitcoin** (9.0.3): Bitcoin primitives and scripting +- **ServiceStack** (8.10.0): JSON serialization and utilities +- **NUnit** (3.14.0): Testing framework + +## Target Framework + +All projects target **.NET 10.0** (`net10.0`) with: +- `ImplicitUsings` enabled +- `Nullable` reference types enabled +- C# language version 12.0-14.0 + +## ConfigureAwait Pattern + +The codebase consistently uses `ConfigureAwait(false)` on all `await` statements to avoid deadlocks and improve performance in library code. + +## Async/Streaming Patterns + +gRPC streaming calls use `AsyncServerStreamingCall`: +```csharp +var stream = client.StreamingMethod(request); +await foreach (var response in stream.ResponseStream.ReadAllAsync()) +{ + // Process response +} +``` + +## Important Notes + +- Always dispose `LNUnitBuilder` and `LNDNodeConnection` instances +- Test environments run on Bitcoin regtest network +- Channel policies (fees, HTLC limits) can be configured per channel +- Use `WaitUntilSyncedToChain()` before channel operations +- The framework handles macaroon authentication automatically via base64-encoded values extracted from Docker containers diff --git a/KUBERNETES_CLUSTER_SUPPORT.md b/KUBERNETES_CLUSTER_SUPPORT.md new file mode 100644 index 0000000..778a679 --- /dev/null +++ b/KUBERNETES_CLUSTER_SUPPORT.md @@ -0,0 +1,619 @@ +# In-Cluster Kubernetes Support Plan + +## Goal +Enable LNUnit tests to run inside a real Kubernetes cluster as a pod, with full pod-to-pod communication and dynamic resource creation. + +## Use Cases + +1. **Staging/QA Environment Testing**: Run integration tests in a staging cluster +2. **Production Validation**: Smoke tests in production-like environment +3. **CI/CD with Real Kubernetes**: GitLab CI, ArgoCD workflows, etc. +4. **Multi-Cluster Testing**: Test across different cluster configurations + +--- + +## Architecture + +### Current State (Local/CI) +``` +┌─────────────────┐ ┌──────────────────────┐ +│ Developer │ │ GitHub Actions │ +│ Laptop │───────→│ Runner (Host) │ +│ (OrbStack) │ │ │ +└─────────────────┘ └──────────────────────┘ + │ │ + ├─→ kubectl ├─→ kind cluster + │ (kubeconfig file) │ (pod IPs not accessible) + │ │ + ├─→ Pod IPs accessible ✅ ├─→ Use Docker instead ❌ +``` + +### Target State (In-Cluster) +``` +┌──────────────────────── Kubernetes Cluster ────────────────────────┐ +│ │ +│ ┌─────────────────────┐ │ +│ │ LNUnit Test Runner │ (Job/Pod) │ +│ │ - dotnet test │ │ +│ │ - In-cluster auth │ │ +│ │ - RBAC permissions │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ├─→ Creates test namespace │ +│ ├─→ Creates bitcoin pod (miner) │ +│ ├─→ Creates LND pods (alice, bob, carol) │ +│ ├─→ Creates services for DNS │ +│ │ │ +│ └─→ Connects via pod IPs (10.x.x.x) ✅ │ +│ or service names (miner.test-ns.svc.cluster.local) │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Plan + +### Phase 1: Auto-Detect In-Cluster Config + +**File**: `LNUnit/Setup/KubernetesOrchestrator.cs` + +**Current Constructor:** +```csharp +public KubernetesOrchestrator(string? defaultNamespace = null) +{ + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + config.SkipTlsVerify = true; + _client = new Kubernetes(config); + _defaultNamespace = defaultNamespace ?? "default"; +} +``` + +**New Constructor with Auto-Detection:** +```csharp +public KubernetesOrchestrator(string? defaultNamespace = null) +{ + KubernetesClientConfiguration config; + + // Try in-cluster config first (when running as a pod) + if (IsRunningInCluster()) + { + config = KubernetesClientConfiguration.InClusterConfig(); + Console.WriteLine("[KubernetesOrchestrator] Using in-cluster configuration"); + } + else + { + // Fallback to kubeconfig file (local development) + config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + config.SkipTlsVerify = true; // For dev environments only + Console.WriteLine("[KubernetesOrchestrator] Using kubeconfig file"); + } + + _client = new Kubernetes(config); + _defaultNamespace = defaultNamespace ?? "default"; +} + +private static bool IsRunningInCluster() +{ + // Kubernetes sets these environment variables in pods + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST")); +} +``` + +**Testing**: Create a simple test pod that prints the config type. + +--- + +### Phase 2: RBAC Configuration + +**File**: `k8s/rbac.yaml` (new file) + +Create RBAC resources for test runner pod: + +```yaml +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: lnunit-test-runner + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: lnunit-test-runner + namespace: default +rules: +# Namespace management +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["create", "delete", "get", "list"] + +# Pod management +- apiGroups: [""] + resources: ["pods", "pods/log", "pods/status"] + verbs: ["create", "delete", "get", "list", "watch"] + +# Service management +- apiGroups: [""] + resources: ["services"] + verbs: ["create", "delete", "get", "list"] + +# Pod exec (for file extraction) +- apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: lnunit-test-runner + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: lnunit-test-runner +subjects: +- kind: ServiceAccount + name: lnunit-test-runner + namespace: default + +--- +# For multi-namespace testing (optional) +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: lnunit-test-runner-cluster +rules: +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["create", "delete", "get", "list"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: lnunit-test-runner-cluster +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: lnunit-test-runner-cluster +subjects: +- kind: ServiceAccount + name: lnunit-test-runner + namespace: default +``` + +**Apply RBAC:** +```bash +kubectl apply -f k8s/rbac.yaml +``` + +--- + +### Phase 3: Test Runner Docker Image + +**File**: `Dockerfile.test-runner` (new file) + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + +WORKDIR /src + +# Copy solution and restore +COPY LNUnit.sln . +COPY LNUnit/LNUnit.csproj LNUnit/ +COPY LNUnit.LND/LNUnit.LND.csproj LNUnit.LND/ +COPY LNBolt/LNBolt.csproj LNBolt/ +COPY LNUnit.Tests/LNUnit.Tests.csproj LNUnit.Tests/ + +RUN dotnet restore + +# Copy source and build +COPY . . +RUN dotnet build LNUnit.Tests/LNUnit.Tests.csproj -c Release + +# Run tests +FROM build AS test +WORKDIR /src/LNUnit.Tests + +ENV UseKubernetes=true +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 + +ENTRYPOINT ["dotnet", "test", "--no-build", "--configuration", "Release", "--logger", "console;verbosity=detailed"] +``` + +**Build:** +```bash +docker build -f Dockerfile.test-runner -t lnunit-test-runner:latest . +``` + +--- + +### Phase 4: Kubernetes Job Manifest + +**File**: `k8s/test-job.yaml` (new file) + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: lnunit-test-run + labels: + app: lnunit + test-run: "true" +spec: + # Keep completed pods for log inspection + ttlSecondsAfterFinished: 3600 # 1 hour + backoffLimit: 0 # Don't retry on failure + + template: + metadata: + labels: + app: lnunit-test-runner + spec: + serviceAccountName: lnunit-test-runner + restartPolicy: Never + + containers: + - name: test-runner + image: lnunit-test-runner:latest + imagePullPolicy: IfNotPresent + + env: + - name: UseKubernetes + value: "true" + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: "1" + + # Optional: Filter specific tests + # args: ["--filter", "FullyQualifiedName~BasicPaymentTest"] + + resources: + requests: + memory: "1Gi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "2000m" +``` + +**Run Tests:** +```bash +kubectl apply -f k8s/test-job.yaml + +# Watch progress +kubectl logs -f job/lnunit-test-run + +# Check results +kubectl get job lnunit-test-run +``` + +--- + +### Phase 5: ConfigMap for Test Settings + +**File**: `k8s/test-config.yaml` (new file) + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: lnunit-test-config +data: + appsettings.json: | + { + "UseKubernetes": true, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] + } + } +``` + +**Mount in Job:** +```yaml +spec: + template: + spec: + containers: + - name: test-runner + volumeMounts: + - name: test-config + mountPath: /src/LNUnit.Tests/appsettings.json + subPath: appsettings.json + volumes: + - name: test-config + configMap: + name: lnunit-test-config +``` + +--- + +### Phase 6: Namespace Isolation Strategy + +**Current Approach**: Tests create random namespace names + +**Enhanced Approach**: Use job-specific namespaces with labels + +**Update**: `LNUnitBuilder.cs` + +```csharp +private string GenerateNetworkName(string baseName) +{ + // Include pod name if running in cluster for traceability + var podName = Environment.GetEnvironmentVariable("HOSTNAME") ?? "local"; + var randomHex = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLower(); + return $"{baseName}-{podName}-{randomHex}"; +} +``` + +**Example namespace names:** +- Local: `unit_test_a1b2c3d4` +- In-cluster: `unit_test_lnunit-test-run-abc123-a1b2c3d4` + +--- + +### Phase 7: Cleanup Strategy + +**Problem**: Failed tests may leave namespaces/pods behind + +**Solution 1: Namespace Labels** + +```csharp +public async Task CreateNetworkAsync(string networkName) +{ + var ns = new V1Namespace + { + Metadata = new V1ObjectMeta + { + Name = namespaceName, + Labels = new Dictionary + { + { "lnunit", "true" }, + { "test-run", Environment.GetEnvironmentVariable("HOSTNAME") ?? "local" }, + { "created-at", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() } + } + } + }; + + await _client.CoreV1.CreateNamespaceAsync(ns).ConfigureAwait(false); + return namespaceName; +} +``` + +**Solution 2: Cleanup CronJob** + +**File**: `k8s/cleanup-cronjob.yaml` (new file) + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: lnunit-cleanup +spec: + schedule: "0 * * * *" # Every hour + jobTemplate: + spec: + template: + spec: + serviceAccountName: lnunit-test-runner + restartPolicy: OnFailure + containers: + - name: cleanup + image: bitnami/kubectl:latest + command: + - /bin/bash + - -c + - | + # Delete test namespaces older than 1 hour + CUTOFF=$(date -u -d '1 hour ago' +%s) + + kubectl get namespaces -l lnunit=true -o json | \ + jq -r ".items[] | select(.metadata.labels[\"created-at\"] | tonumber < $CUTOFF) | .metadata.name" | \ + while read ns; do + echo "Deleting old test namespace: $ns" + kubectl delete namespace "$ns" --timeout=60s + done +``` + +--- + +### Phase 8: CI/CD Integration + +**GitLab CI Example:** + +**File**: `.gitlab-ci.yml` + +```yaml +stages: + - build + - test + +variables: + DOCKER_IMAGE: ${CI_REGISTRY_IMAGE}/test-runner:${CI_COMMIT_SHORT_SHA} + +build-test-image: + stage: build + image: docker:latest + services: + - docker:dind + script: + - docker build -f Dockerfile.test-runner -t ${DOCKER_IMAGE} . + - docker push ${DOCKER_IMAGE} + +test-kubernetes: + stage: test + image: bitnami/kubectl:latest + script: + # Update image in job manifest + - sed -i "s|lnunit-test-runner:latest|${DOCKER_IMAGE}|g" k8s/test-job.yaml + + # Apply RBAC (idempotent) + - kubectl apply -f k8s/rbac.yaml + + # Run test job + - kubectl apply -f k8s/test-job.yaml + + # Wait for completion (30 min timeout) + - kubectl wait --for=condition=complete --timeout=1800s job/lnunit-test-run + + # Get logs + - kubectl logs job/lnunit-test-run + + # Check if successful + - kubectl get job lnunit-test-run -o jsonpath='{.status.succeeded}' | grep -q 1 + after_script: + # Cleanup job + - kubectl delete job lnunit-test-run --ignore-not-found=true + only: + - merge_requests + - main +``` + +**GitHub Actions Example:** + +**File**: `.github/workflows/test-in-cluster.yml` + +```yaml +name: Test in Cluster + +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # Nightly + +jobs: + test-in-cluster: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config + + - name: Build test image + run: | + docker build -f Dockerfile.test-runner -t lnunit-test-runner:${{ github.sha }} . + + # Push to registry accessible by cluster + # docker push ... + + - name: Apply RBAC + run: kubectl apply -f k8s/rbac.yaml + + - name: Run tests + run: | + # Update image tag + sed -i "s|lnunit-test-runner:latest|lnunit-test-runner:${{ github.sha }}|g" k8s/test-job.yaml + + kubectl apply -f k8s/test-job.yaml + kubectl wait --for=condition=complete --timeout=30m job/lnunit-test-run + + - name: Get test logs + if: always() + run: kubectl logs job/lnunit-test-run + + - name: Cleanup + if: always() + run: kubectl delete job lnunit-test-run --ignore-not-found=true +``` + +--- + +## Testing Plan + +### Local Testing +1. Use kind or minikube (not OrbStack for this test) +2. Build test image locally +3. Load image into cluster: `kind load docker-image lnunit-test-runner:latest` +4. Apply RBAC: `kubectl apply -f k8s/rbac.yaml` +5. Run job: `kubectl apply -f k8s/test-job.yaml` +6. Watch logs: `kubectl logs -f job/lnunit-test-run` + +### Staging Cluster Testing +1. Deploy to staging cluster +2. Run subset of tests first +3. Verify cleanup works +4. Run full test suite + +### Production Validation +1. Use separate service account with read-only permissions where possible +2. Run smoke tests only +3. Implement stricter timeouts + +--- + +## Benefits + +1. **Realistic Environment**: Tests run in actual Kubernetes +2. **Network Policies**: Test with real security constraints +3. **Resource Limits**: Test with actual QoS and resource constraints +4. **Cloud Integration**: Test with cloud-specific features (LoadBalancers, StorageClasses) +5. **Multi-Cluster**: Can run same tests across dev/staging/prod +6. **No Networking Hacks**: Pod-to-pod communication just works + +--- + +## Limitations & Considerations + +1. **Cost**: Running tests consumes cluster resources +2. **Permissions**: Requires elevated RBAC permissions +3. **Cleanup**: Failed tests may leave resources behind +4. **Speed**: Slightly slower than Docker due to pod scheduling +5. **Isolation**: Tests must not interfere with production workloads + +--- + +## Migration Path + +**Current State**: +- Local dev: OrbStack (pod IPs accessible) +- CI/CD: Docker (simple, fast) + +**Target State**: +- Local dev: OrbStack (no change) +- CI/CD (kind): Docker (no change) +- CI/CD (real cluster): In-cluster (new capability) +- Production validation: In-cluster (new capability) + +**No breaking changes** - existing workflows continue to work! + +--- + +## Success Criteria + +✅ Tests run successfully in a real cluster +✅ Pod-to-pod communication works via pod IPs +✅ Service DNS resolution works +✅ Namespaces are created and cleaned up properly +✅ RBAC permissions are minimal and secure +✅ Failed tests don't leave resources behind +✅ CI/CD pipeline can run tests in cluster +✅ Logs are accessible for debugging + +--- + +## Open Questions + +1. Should we support persistent storage for bitcoin data between test runs? +2. Should we implement test parallelization with multiple namespaces? +3. Should we add Prometheus metrics for test execution? +4. Should we support network policies to test restricted environments? diff --git a/LNUnit.Tests/AbcLightningFixturePostgres.cs b/LNUnit.Tests/AbcLightningFixturePostgres.cs index 3c7f337..669253b 100644 --- a/LNUnit.Tests/AbcLightningFixturePostgres.cs +++ b/LNUnit.Tests/AbcLightningFixturePostgres.cs @@ -3,8 +3,8 @@ namespace LNUnit.Tests.Fixture; [Ignore("only local")] -//[TestFixture("postgres", "lightninglabs/lnd", "v0.18.3-beta", "/root/.lnd", true)] -[TestFixture("postgres", "custom_lnd", "latest", "/home/lnd/.lnd", false)] +[TestFixture("postgres", "lightninglabs/lnd", "v0.20.0-beta", "/root/.lnd", true)] +//[TestFixture("postgres", "custom_lnd", "latest", "/home/lnd/.lnd", false)] public class AbcLightningAbstractTestsPostgres : AbcLightningAbstractTests { public AbcLightningAbstractTestsPostgres(string dbType = "postgres", diff --git a/LNUnit.Tests/Abstract/AbcLightningAbstractTests.cs b/LNUnit.Tests/Abstract/AbcLightningAbstractTests.cs index a5a4173..3d70761 100644 --- a/LNUnit.Tests/Abstract/AbcLightningAbstractTests.cs +++ b/LNUnit.Tests/Abstract/AbcLightningAbstractTests.cs @@ -89,6 +89,7 @@ public async Task OneTimeSetup() .SetBasePath(Directory.GetCurrentDirectory()) // Set the current directory as the base path .AddJsonFile("appsettings.json", false, true) .AddJsonFile("appsettings.Development.json", false, true) + .AddEnvironmentVariables() //last will make this override. .Build(); var services = new ServiceCollection(); var loggerConfiguration = new LoggerConfiguration().Enrich.FromLogContext(); @@ -96,6 +97,14 @@ public async Task OneTimeSetup() Log.Logger = loggerConfiguration.CreateLogger(); services.AddLogging(); services.AddSerilog(Log.Logger, true); + + // Register orchestrator (defaults to Docker, can be overridden with UseKubernetes config) + var useKubernetes = configuration.GetValue("UseKubernetes", false); + if (useKubernetes) + services.AddSingleton(); + else + services.AddSingleton(); + _serviceProvider = services.BuildServiceProvider(); Builder = ActivatorUtilities.CreateInstance(_serviceProvider); @@ -106,22 +115,23 @@ public async Task OneTimeSetup() PostgresFixture.AddDb("carol"); } - await _client.CreateDockerImageFromPath("../../../../Docker/lnd", ["custom_lnd", "custom_lnd:latest"]); - await _client.CreateDockerImageFromPath("./../../../../Docker/bitcoin/30.0", - ["bitcoin:latest", "bitcoin:30.0"]); - await SetupNetwork(_lndImage, _tag, _lndRoot, _pullImage, "bitcoin", "30.0"); + // await _client.CreateDockerImageFromPath("../../../../Docker/lnd", ["custom_lnd", "custom_lnd:latest"]); + // await _client.CreateDockerImageFromPath("./../../../../Docker/bitcoin/30.0", + // ["bitcoin:latest", "bitcoin:30.0"]); + await SetupNetwork(_lndImage, _tag, _lndRoot, _pullImage); } public async Task SetupNetwork(string lndImage = "lightninglabs/lnd", string lndTag = "daily-testing-only", string lndRoot = "/root/.lnd", bool pullLndImage = false, string bitcoinImage = "polarlightning/bitcoind", - string bitcoinTag = "29.0", + string bitcoinTag = "30.0", bool pullBitcoinImage = false) { - await _client.RemoveContainer("miner"); - await _client.RemoveContainer("alice"); - await _client.RemoveContainer("bob"); - await _client.RemoveContainer("carol"); + // Cleanup any existing containers from previous test runs (works with both Docker and Kubernetes) + await Builder.RemoveContainerByNameIfExists("miner"); + await Builder.RemoveContainerByNameIfExists("alice"); + await Builder.RemoveContainerByNameIfExists("bob"); + await Builder.RemoveContainerByNameIfExists("carol"); Builder.AddBitcoinCoreNode(image: bitcoinImage, tag: bitcoinTag, pullImage: pullBitcoinImage); @@ -846,12 +856,12 @@ public async Task Keysend_To_Bob_PaymentsPerSecondMax_Threaded(int threads) $"Failed : {fail_count}".Print(); var successful_pps = success_count / (sw.ElapsedMilliseconds / 1000.0); $"Successful Payments per second: {successful_pps}".Print(); - var size = await Builder.GetFileSize("alice", "/root/.lnd/data/graph/regtest/channel.db"); - size.PrintDump(); - size = await Builder.GetFileSize("bob", "/root/.lnd/data/graph/regtest/channel.db"); - size.PrintDump(); - size = await Builder.GetFileSize("carol", "/root/.lnd/data/graph/regtest/channel.db"); - size.PrintDump(); + // var size = await Builder.GetFileSize("alice", "/root/.lnd/data/graph/regtest/channel.db"); + // size.PrintDump(); + // size = await Builder.GetFileSize("bob", "/root/.lnd/data/graph/regtest/channel.db"); + // size.PrintDump(); + // size = await Builder.GetFileSize("carol", "/root/.lnd/data/graph/regtest/channel.db"); + // size.PrintDump(); } [Test] @@ -1386,8 +1396,8 @@ await bob.LightningClient.DeleteAllPaymentsAsync(new DeleteAllPaymentsRequest Reversed = true, PendingOnly = false }); - Assert.That(li.Invoices.Count > 0); - Assert.That(li.Invoices.First().State == Invoice.Types.InvoiceState.Settled); + Assert.That(li.Invoices.Count, Is.GreaterThan(0)); + Assert.That(li.Invoices.First().State, Is.EqualTo(Invoice.Types.InvoiceState.Settled)); } public class PaymentStats diff --git a/LNUnit.Tests/DockerTest.cs b/LNUnit.Tests/DockerTest.cs index 2adfda2..149c653 100644 --- a/LNUnit.Tests/DockerTest.cs +++ b/LNUnit.Tests/DockerTest.cs @@ -116,14 +116,6 @@ await _client.CreateDockerImageFromPath("./../../../../Docker/lnd", new List { "custom_lnd", "custom_lnd:latest" }); } - // [Test] - // [Category("Docker")] - // public async Task BuildBitcoin_27_0_rc1_DockerImage() - // { - // await _client.CreateDockerImageFromPath("./../../../../Docker/bitcoin/27.0rc1", - // new List { "bitcoin:27.0rc1" }); - // } - [Test] [Category("Docker")] @@ -132,23 +124,6 @@ public async Task DetectGitlabRunner() await _client.GetGitlabRunnerNetworkId(); } - // public string GetIPAddress() - // { - // string IPAddress = ""; - // IPHostEntry Host = default(IPHostEntry); - // string Hostname = null; - // Hostname = System.Environment.MachineName; - // Host = Dns.GetHostEntry(Hostname); - // foreach (IPAddress IP in Host.AddressList) - // { - // if (IP.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - // { - // IPAddress = Convert.ToString(IP); - // } - // } - // - // return IPAddress; - // } [Test] [Category("Docker")] diff --git a/LNUnit.Tests/KubernetesOrchestratorTest.cs b/LNUnit.Tests/KubernetesOrchestratorTest.cs new file mode 100644 index 0000000..7783353 --- /dev/null +++ b/LNUnit.Tests/KubernetesOrchestratorTest.cs @@ -0,0 +1,401 @@ +using k8s; +using LNUnit.Setup; + +namespace LNUnit.Tests.Abstract; + +[Ignore("only local")] +[Category("Kubernetes")] +public class KubernetesOrchestratorTest +{ + private IKubernetes _kubeClient = null!; + private KubernetesOrchestrator _orchestrator = null!; + private readonly Random _random = new(); + private string? _testNamespace; + + [OneTimeSetUp] + public async Task Setup() + { + // Load kubeconfig from default location or KUBECONFIG env var + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + config.SkipTlsVerify = true; + + _kubeClient = new Kubernetes(config); + _orchestrator = new KubernetesOrchestrator(_kubeClient); + + // Verify connection + var version = await _kubeClient.Version.GetCodeAsync().ConfigureAwait(false); + Console.WriteLine($"Connected to Kubernetes {version.GitVersion}"); + } + + [OneTimeTearDown] + public async Task TearDown() + { + // Cleanup test namespace if it was created + if (_testNamespace != null) + try + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + Console.WriteLine($"Cleaned up test namespace: {_testNamespace}"); + } + catch + { + // Ignore cleanup errors + } + + _orchestrator?.Dispose(); + _kubeClient?.Dispose(); + await Task.CompletedTask; + } + + private string GetRandomHexString(int size = 8) + { + var b = new byte[size]; + _random.NextBytes(b); + return Convert.ToHexString(b).ToLower(); + } + + [Test] + [Category("Kubernetes")] + public async Task CreateNetwork() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + + try + { + // Create network (namespace) + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + Assert.That(namespaceId, Is.Not.Null.And.Not.Empty); + Console.WriteLine($"Created network/namespace: {namespaceId}"); + + // Verify namespace exists + var ns = await _kubeClient.CoreV1.ReadNamespaceAsync(namespaceId).ConfigureAwait(false); + Assert.That(ns, Is.Not.Null); + Assert.That(ns.Metadata.Name, Is.EqualTo(namespaceId)); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task CreateAndInspectContainer() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var containerName = $"test-redis-{GetRandomHexString(4)}"; + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create container + var createOptions = new ContainerCreateOptions + { + Name = containerName, + Image = "redis", + Tag = "5.0", + NetworkId = namespaceId, + Labels = new Dictionary + { + { "test", "true" }, + { "purpose", "integration-test" } + } + }; + + var containerInfo = await _orchestrator.CreateContainerAsync(createOptions).ConfigureAwait(false); + + Assert.That(containerInfo, Is.Not.Null); + Assert.That(containerInfo.Name, Is.EqualTo(containerName)); + Assert.That(containerInfo.Image, Does.Contain("redis:5.0")); + Assert.That(containerInfo.State, Is.EqualTo("Running")); + Assert.That(containerInfo.IpAddress, Is.Not.Null.And.Not.Empty); + Console.WriteLine($"Created container {containerName} with IP {containerInfo.IpAddress}"); + + // Inspect container + var inspected = await _orchestrator.InspectContainerAsync(containerName).ConfigureAwait(false); + Assert.That(inspected, Is.Not.Null); + Assert.That(inspected.Name, Is.EqualTo(containerName)); + Assert.That(inspected.Labels, Is.Not.Null); + Assert.That(inspected.Labels.ContainsKey("test"), Is.True); + Console.WriteLine($"Inspected container: {inspected.Name}, State: {inspected.State}"); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task ListContainers() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var container1Name = $"test-redis-1-{GetRandomHexString(4)}"; + var container2Name = $"test-redis-2-{GetRandomHexString(4)}"; + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create two containers + var createOptions1 = new ContainerCreateOptions + { + Name = container1Name, + Image = "redis", + Tag = "5.0", + NetworkId = namespaceId + }; + + var createOptions2 = new ContainerCreateOptions + { + Name = container2Name, + Image = "redis", + Tag = "5.0", + NetworkId = namespaceId + }; + + await _orchestrator.CreateContainerAsync(createOptions1).ConfigureAwait(false); + await _orchestrator.CreateContainerAsync(createOptions2).ConfigureAwait(false); + + // List containers + var containers = await _orchestrator.ListContainersAsync().ConfigureAwait(false); + + Assert.That(containers, Is.Not.Null); + Assert.That(containers.Count, Is.GreaterThanOrEqualTo(2)); + + var container1 = containers.FirstOrDefault(c => c.Name == container1Name); + var container2 = containers.FirstOrDefault(c => c.Name == container2Name); + + Assert.That(container1, Is.Not.Null); + Assert.That(container2, Is.Not.Null); + + Console.WriteLine($"Listed {containers.Count} containers"); + Console.WriteLine($"Found {container1Name}: {container1!.IpAddress}"); + Console.WriteLine($"Found {container2Name}: {container2!.IpAddress}"); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task RemoveContainer() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var containerName = $"test-redis-{GetRandomHexString(4)}"; + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create container + var createOptions = new ContainerCreateOptions + { + Name = containerName, + Image = "redis", + Tag = "5.0", + NetworkId = namespaceId + }; + + var containerInfo = await _orchestrator.CreateContainerAsync(createOptions).ConfigureAwait(false); + Console.WriteLine($"Created container {containerName}"); + + // Remove container + await _orchestrator.RemoveContainerAsync(containerName).ConfigureAwait(false); + Console.WriteLine($"Removed container {containerName}"); + + // Wait for removal to complete + await Task.Delay(2000).ConfigureAwait(false); + + // Verify container is gone + try + { + await _orchestrator.InspectContainerAsync(containerName).ConfigureAwait(false); + Assert.Fail("Container should have been removed"); + } + catch (Exception ex) + { + Console.WriteLine($"Container successfully removed: {ex.Message}"); + Assert.Pass("Container was successfully removed"); + } + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task ExtractTextFile() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var containerName = $"test-busybox-{GetRandomHexString(4)}"; + var testContent = "Hello from KubernetesOrchestrator!"; + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create container with test file + var createOptions = new ContainerCreateOptions + { + Name = containerName, + Image = "busybox", + Tag = "latest", + NetworkId = namespaceId, + Command = new List { "sh", "-c", $"echo '{testContent}' > /tmp/test.txt && sleep 30" } + }; + + await _orchestrator.CreateContainerAsync(createOptions).ConfigureAwait(false); + Console.WriteLine($"Created container {containerName}"); + + // Wait for file to be created + await Task.Delay(3000).ConfigureAwait(false); + + // Extract text file + var fileContent = await _orchestrator.ExtractTextFileAsync(containerName, "/tmp/test.txt") + .ConfigureAwait(false); + + Assert.That(fileContent, Is.Not.Null.And.Not.Empty); + Assert.That(fileContent.Trim(), Is.EqualTo(testContent)); + Console.WriteLine($"Extracted file content: {fileContent.Trim()}"); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task ExtractBinaryFile() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var containerName = $"test-busybox-{GetRandomHexString(4)}"; + var testData = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; // "Hello" + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create container with binary file + var createOptions = new ContainerCreateOptions + { + Name = containerName, + Image = "busybox", + Tag = "latest", + NetworkId = namespaceId, + Command = new List + { "sh", "-c", "printf '\\x48\\x65\\x6C\\x6C\\x6F' > /tmp/binary.dat && sleep 30" } + }; + + await _orchestrator.CreateContainerAsync(createOptions).ConfigureAwait(false); + Console.WriteLine($"Created container {containerName}"); + + // Wait for file to be created + await Task.Delay(3000).ConfigureAwait(false); + + // Extract binary file + var binaryContent = await _orchestrator.ExtractBinaryFileAsync(containerName, "/tmp/binary.dat") + .ConfigureAwait(false); + + Assert.That(binaryContent, Is.Not.Null); + Assert.That(binaryContent, Is.EqualTo(testData)); + Console.WriteLine($"Extracted {binaryContent.Length} bytes: {BitConverter.ToString(binaryContent)}"); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task CreateContainerWithEnvironmentVariables() + { + var networkName = $"test-network-{GetRandomHexString(4)}"; + var containerName = $"test-env-{GetRandomHexString(4)}"; + + try + { + // Create network + var namespaceId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + _testNamespace = namespaceId; + + // Create container with environment variables + var createOptions = new ContainerCreateOptions + { + Name = containerName, + Image = "busybox", + Tag = "latest", + NetworkId = namespaceId, + Command = new List { "sh", "-c", "echo \"TEST_VAR=$TEST_VAR\" > /tmp/env.txt && sleep 30" }, + Environment = new Dictionary + { + { "TEST_VAR", "test-value-123" } + } + }; + + await _orchestrator.CreateContainerAsync(createOptions).ConfigureAwait(false); + Console.WriteLine($"Created container {containerName} with environment variables"); + + // Wait for file to be created + await Task.Delay(3000).ConfigureAwait(false); + + // Extract and verify environment variable was set + var fileContent = await _orchestrator.ExtractTextFileAsync(containerName, "/tmp/env.txt") + .ConfigureAwait(false); + + Assert.That(fileContent, Does.Contain("TEST_VAR=test-value-123")); + Console.WriteLine($"Environment variable verification: {fileContent.Trim()}"); + } + finally + { + if (_testNamespace != null) + { + await _orchestrator.DeleteNetworkAsync(_testNamespace).ConfigureAwait(false); + _testNamespace = null; + } + } + } +} \ No newline at end of file diff --git a/LNUnit.Tests/KubernetesTest.cs b/LNUnit.Tests/KubernetesTest.cs new file mode 100644 index 0000000..0fc88bd --- /dev/null +++ b/LNUnit.Tests/KubernetesTest.cs @@ -0,0 +1,577 @@ +using k8s; +using k8s.Models; +using LNUnit.Setup; + +namespace LNUnit.Tests.Abstract; + +[Ignore("only local")] +[Category("Kubernetes")] +public class KubernetesTest +{ + private IKubernetes _client = null!; + private readonly Random _random = new(); + + [OneTimeSetUp] + public async Task Setup() + { + // Load kubeconfig from default location or KUBECONFIG env var + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + + // Skip SSL validation for development (fixes exec API SSL issues on macOS/OrbStack) + config.SkipTlsVerify = true; + + _client = new Kubernetes(config); + + // Verify connection by getting version + var version = await _client.Version.GetCodeAsync().ConfigureAwait(false); + Console.WriteLine($"Connected to Kubernetes {version.GitVersion}"); + Console.WriteLine($"SSL Verification: {(config.SkipTlsVerify ? "Disabled (Dev Mode)" : "Enabled")}"); + } + + [OneTimeTearDown] + public async Task TearDown() + { + _client?.Dispose(); + await Task.CompletedTask; + } + + private string GetRandomHexString(int size = 8) + { + var b = new byte[size]; + _random.NextBytes(b); + return Convert.ToHexString(b).ToLower(); // K8s names must be lowercase + } + + [Test] + [Category("Kubernetes")] + public async Task ListPods() + { + // List all pods in the default namespace + var pods = await _client.CoreV1.ListNamespacedPodAsync("default").ConfigureAwait(false); + + // Just verify the API call works - don't assert specific pod count + Assert.That(pods, Is.Not.Null); + Console.WriteLine($"Found {pods.Items.Count} pods in default namespace"); + } + + [Test] + [Category("Kubernetes")] + public async Task CreatePod() + { + var podName = $"test-redis-{GetRandomHexString(4)}"; + var namespaceName = "default"; + + try + { + // Create a simple redis pod + var pod = new V1Pod + { + Metadata = new V1ObjectMeta + { + Name = podName + }, + Spec = new V1PodSpec + { + Containers = new List + { + new() + { + Name = "redis", + Image = "redis:5.0", + Ports = new List + { + new() { ContainerPort = 6379 } + } + } + }, + RestartPolicy = "Never" + } + }; + + var createdPod = await _client.CoreV1.CreateNamespacedPodAsync(pod, namespaceName).ConfigureAwait(false); + Assert.That(createdPod.Metadata.Name, Is.EqualTo(podName)); + + // Wait a bit for pod to start + await Task.Delay(2000).ConfigureAwait(false); + + // Verify pod exists + var retrievedPod = + await _client.CoreV1.ReadNamespacedPodAsync(podName, namespaceName).ConfigureAwait(false); + Assert.That(retrievedPod, Is.Not.Null); + Console.WriteLine($"Pod {podName} created with status: {retrievedPod.Status.Phase}"); + } + finally + { + // Cleanup: Delete the pod + try + { + await _client.CoreV1.DeleteNamespacedPodAsync(podName, namespaceName, gracePeriodSeconds: 0) + .ConfigureAwait(false); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task Kubernetes_PullImage() + { + // Note: Kubernetes automatically pulls images when creating pods + // We can test this by creating a pod with the image and verifying it works + var podName = $"test-image-pull-{GetRandomHexString(4)}"; + var namespaceName = "default"; + + try + { + var pod = new V1Pod + { + Metadata = new V1ObjectMeta { Name = podName }, + Spec = new V1PodSpec + { + Containers = new List + { + new() + { + Name = "redis", + Image = "redis:5.0", + ImagePullPolicy = "IfNotPresent" // or "Always" to force pull + } + }, + RestartPolicy = "Never" + } + }; + + var createdPod = await _client.CoreV1.CreateNamespacedPodAsync(pod, namespaceName).ConfigureAwait(false); + Assert.That(createdPod.Metadata.Name, Is.EqualTo(podName)); + Console.WriteLine($"Pod {podName} created - image will be pulled if not present"); + } + finally + { + try + { + await _client.CoreV1.DeleteNamespacedPodAsync(podName, namespaceName, gracePeriodSeconds: 0) + .ConfigureAwait(false); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task CreateDestroyNamespace() + { + var randomString = GetRandomHexString(); + var namespaceName = $"unit-test-{randomString}"; + + // Create namespace + var ns = new V1Namespace + { + Metadata = new V1ObjectMeta + { + Name = namespaceName, + Labels = new Dictionary + { + { "lnunit-test", "true" }, + { "created-by", "lnunit" } + } + } + }; + + var createdNs = await _client.CoreV1.CreateNamespaceAsync(ns).ConfigureAwait(false); + Assert.That(createdNs.Metadata.Name, Is.EqualTo(namespaceName)); + Console.WriteLine($"Created namespace: {namespaceName}"); + + // Verify namespace exists + var retrievedNs = await _client.CoreV1.ReadNamespaceAsync(namespaceName).ConfigureAwait(false); + Assert.That(retrievedNs, Is.Not.Null); + + // Delete namespace + await _client.CoreV1.DeleteNamespaceAsync(namespaceName, gracePeriodSeconds: 0).ConfigureAwait(false); + Console.WriteLine($"Deleted namespace: {namespaceName}"); + + // Wait a bit for deletion to process + await Task.Delay(2000).ConfigureAwait(false); + } + + [Test] + [Category("Kubernetes")] + public async Task DetectKubernetesContext() + { + // Get cluster information + var version = await _client.Version.GetCodeAsync().ConfigureAwait(false); + Assert.That(version, Is.Not.Null); + Assert.That(version.GitVersion, Is.Not.Null.And.Not.Empty); + + Console.WriteLine($"Kubernetes Version: {version.GitVersion}"); + Console.WriteLine($"Platform: {version.Platform}"); + + // Try to get current namespace (typically "default" if not specified) + var namespaces = await _client.CoreV1.ListNamespaceAsync().ConfigureAwait(false); + Assert.That(namespaces.Items.Count, Is.GreaterThan(0)); + + Console.WriteLine($"Found {namespaces.Items.Count} namespaces in cluster"); + } + + [Test] + [Category("Kubernetes")] + public async Task CreatePodsInNamespace() + { + var randomString = GetRandomHexString(); + var namespaceName = $"unit-test-{randomString}"; + + try + { + // Create namespace + var ns = new V1Namespace + { + Metadata = new V1ObjectMeta { Name = namespaceName } + }; + await _client.CoreV1.CreateNamespaceAsync(ns).ConfigureAwait(false); + Console.WriteLine($"Created namespace: {namespaceName}"); + + // Create first pod (alice) + var alice = new V1Pod + { + Metadata = new V1ObjectMeta + { + Name = "alice", + Labels = new Dictionary { { "app", "alice" } } + }, + Spec = new V1PodSpec + { + Containers = new List + { + new() { Name = "redis", Image = "redis:5.0" } + }, + RestartPolicy = "Never" + } + }; + var alicePod = await _client.CoreV1.CreateNamespacedPodAsync(alice, namespaceName).ConfigureAwait(false); + Assert.That(alicePod.Metadata.Name, Is.EqualTo("alice")); + + // Create second pod (bob) + var bob = new V1Pod + { + Metadata = new V1ObjectMeta + { + Name = "bob", + Labels = new Dictionary { { "app", "bob" } } + }, + Spec = new V1PodSpec + { + Containers = new List + { + new() { Name = "redis", Image = "redis:5.0" } + }, + RestartPolicy = "Never" + } + }; + var bobPod = await _client.CoreV1.CreateNamespacedPodAsync(bob, namespaceName).ConfigureAwait(false); + Assert.That(bobPod.Metadata.Name, Is.EqualTo("bob")); + + Console.WriteLine($"Created pods alice and bob in namespace {namespaceName}"); + + // Wait for pods to get IP addresses + await Task.Delay(3000).ConfigureAwait(false); + + // Retrieve pods and verify networking + var aliceStatus = await _client.CoreV1.ReadNamespacedPodAsync("alice", namespaceName).ConfigureAwait(false); + var bobStatus = await _client.CoreV1.ReadNamespacedPodAsync("bob", namespaceName).ConfigureAwait(false); + + Console.WriteLine($"Alice IP: {aliceStatus.Status.PodIP}"); + Console.WriteLine($"Bob IP: {bobStatus.Status.PodIP}"); + + // Both pods should have IPs (or be in process of getting them) + // We don't assert IPs exist because pods might not be Running yet + Assert.That(aliceStatus.Status, Is.Not.Null); + Assert.That(bobStatus.Status, Is.Not.Null); + } + finally + { + // Cleanup: Delete namespace (this deletes all pods in it) + try + { + await _client.CoreV1.DeleteNamespaceAsync(namespaceName, gracePeriodSeconds: 0).ConfigureAwait(false); + Console.WriteLine($"Deleted namespace: {namespaceName}"); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task TestPersistentVolume() + { + var randomString = GetRandomHexString(); + var namespaceName = $"unit-test-{randomString}"; + var pvcName = $"test-pvc-{GetRandomHexString(4)}"; + var podName1 = $"test-pod-1-{GetRandomHexString(4)}"; + var podName2 = $"test-pod-2-{GetRandomHexString(4)}"; + var testData = "Hello from Kubernetes PVC!"; + + try + { + // Create namespace + var ns = new V1Namespace + { + Metadata = new V1ObjectMeta { Name = namespaceName } + }; + await _client.CoreV1.CreateNamespaceAsync(ns).ConfigureAwait(false); + Console.WriteLine($"Created namespace: {namespaceName}"); + + // Create PersistentVolumeClaim + var pvc = new V1PersistentVolumeClaim + { + Metadata = new V1ObjectMeta + { + Name = pvcName + }, + Spec = new V1PersistentVolumeClaimSpec + { + AccessModes = new List { "ReadWriteOnce" }, + Resources = new V1VolumeResourceRequirements + { + Requests = new Dictionary + { + { "storage", new ResourceQuantity("1Gi") } + } + } + } + }; + + var createdPvc = await _client.CoreV1.CreateNamespacedPersistentVolumeClaimAsync(pvc, namespaceName) + .ConfigureAwait(false); + Assert.That(createdPvc.Metadata.Name, Is.EqualTo(pvcName)); + Console.WriteLine($"Created PVC: {pvcName}"); + + // Wait for PVC to be bound (or pending) + await Task.Delay(2000).ConfigureAwait(false); + + // Create first pod with volume mount and write data + var pod1 = new V1Pod + { + Metadata = new V1ObjectMeta { Name = podName1 }, + Spec = new V1PodSpec + { + Containers = new List + { + new() + { + Name = "writer", + Image = "busybox", + Command = new List { "sh", "-c", $"echo '{testData}' > /data/test.txt && sleep 5" }, + VolumeMounts = new List + { + new() + { + Name = "data-volume", + MountPath = "/data" + } + } + } + }, + Volumes = new List + { + new() + { + Name = "data-volume", + PersistentVolumeClaim = new V1PersistentVolumeClaimVolumeSource + { + ClaimName = pvcName + } + } + }, + RestartPolicy = "Never" + } + }; + + await _client.CoreV1.CreateNamespacedPodAsync(pod1, namespaceName).ConfigureAwait(false); + Console.WriteLine($"Created pod {podName1} to write data"); + + // Wait for pod to complete writing + await Task.Delay(8000).ConfigureAwait(false); + + // Delete first pod + await _client.CoreV1.DeleteNamespacedPodAsync(podName1, namespaceName, gracePeriodSeconds: 0) + .ConfigureAwait(false); + Console.WriteLine($"Deleted pod {podName1}"); + await Task.Delay(3000).ConfigureAwait(false); + + // Create second pod to read data from same PVC + var pod2 = new V1Pod + { + Metadata = new V1ObjectMeta { Name = podName2 }, + Spec = new V1PodSpec + { + Containers = new List + { + new() + { + Name = "reader", + Image = "busybox", + Command = new List { "sh", "-c", "cat /data/test.txt && sleep 5" }, + VolumeMounts = new List + { + new() + { + Name = "data-volume", + MountPath = "/data" + } + } + } + }, + Volumes = new List + { + new() + { + Name = "data-volume", + PersistentVolumeClaim = new V1PersistentVolumeClaimVolumeSource + { + ClaimName = pvcName + } + } + }, + RestartPolicy = "Never" + } + }; + + await _client.CoreV1.CreateNamespacedPodAsync(pod2, namespaceName).ConfigureAwait(false); + Console.WriteLine($"Created pod {podName2} to read data"); + + // Wait for pod to complete reading + await Task.Delay(8000).ConfigureAwait(false); + + // The test passes if we got here without exceptions + // In a real test, we'd exec into the pod and verify the data, but that's Phase 2 + Console.WriteLine("PVC persistence test completed - data written by pod1 should be readable by pod2"); + + Assert.Pass("PVC created and used by multiple pods successfully"); + } + finally + { + // Cleanup: Delete namespace (this deletes all pods and PVCs in it) + try + { + await _client.CoreV1.DeleteNamespaceAsync(namespaceName, gracePeriodSeconds: 0).ConfigureAwait(false); + Console.WriteLine($"Deleted namespace: {namespaceName}"); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task TestFileExtraction_TextFile() + { + var namespaceName = "default"; + var podName = $"test-file-extract-{GetRandomHexString(4)}"; + + try + { + // Create a pod using KubernetesHelper + await _client.CreatePodAndWaitForRunning( + namespaceName, + podName, + "busybox", + "latest", + new List { "sh", "-c", "echo 'Hello from Kubernetes!' > /tmp/test.txt && sleep 30" }, + labels: new Dictionary { { "app", podName } }, + timeoutSeconds: 30 + ).ConfigureAwait(false); + + Console.WriteLine($"Pod {podName} is running"); + + // Wait a bit for the file to be created + await Task.Delay(2000).ConfigureAwait(false); + + // Extract the text file using KubernetesHelper + var fileContent = await _client.ExecAndReadTextFile( + namespaceName, + podName, + podName, + "/tmp/test.txt" + ).ConfigureAwait(false); + + Console.WriteLine($"Extracted text file content: {fileContent.Trim()}"); + + // Verify the content + Assert.That(fileContent.Trim(), Is.EqualTo("Hello from Kubernetes!")); + } + finally + { + try + { + await _client.RemovePod(namespaceName, podName, 0).ConfigureAwait(false); + } + catch + { + // Ignore cleanup errors + } + } + } + + [Test] + [Category("Kubernetes")] + public async Task TestFileExtraction_BinaryFile() + { + var namespaceName = "default"; + var podName = $"test-binary-extract-{GetRandomHexString(4)}"; + var testData = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; // "Hello" in hex + + try + { + // Create a pod that writes binary data + await _client.CreatePodAndWaitForRunning( + namespaceName, + podName, + "busybox", + "latest", + new List { "sh", "-c", "printf '\\x48\\x65\\x6C\\x6C\\x6F' > /tmp/binary.dat && sleep 30" }, + labels: new Dictionary { { "app", podName } }, + timeoutSeconds: 30 + ).ConfigureAwait(false); + + Console.WriteLine($"Pod {podName} is running"); + + // Wait for the file to be created + await Task.Delay(2000).ConfigureAwait(false); + + // Extract the binary file using KubernetesHelper + var binaryContent = await _client.ExecAndReadBinaryFile( + namespaceName, + podName, + podName, + "/tmp/binary.dat" + ).ConfigureAwait(false); + + Console.WriteLine($"Extracted {binaryContent.Length} bytes from binary file"); + Console.WriteLine($"Content: {BitConverter.ToString(binaryContent)}"); + + // Verify the content + Assert.That(binaryContent, Is.EqualTo(testData)); + } + finally + { + try + { + await _client.RemovePod(namespaceName, podName, 0).ConfigureAwait(false); + } + catch + { + // Ignore cleanup errors + } + } + } +} \ No newline at end of file diff --git a/LNUnit.Tests/LNUnit.Tests.csproj b/LNUnit.Tests/LNUnit.Tests.csproj index 304932b..5beb24c 100644 --- a/LNUnit.Tests/LNUnit.Tests.csproj +++ b/LNUnit.Tests/LNUnit.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/LNUnit.Tests/appsettings.Development.json b/LNUnit.Tests/appsettings.Development.json index 0c208ae..2239b59 100644 --- a/LNUnit.Tests/appsettings.Development.json +++ b/LNUnit.Tests/appsettings.Development.json @@ -1,4 +1,5 @@ { + "UseKubernetes": true, "Logging": { "LogLevel": { "Default": "Information", diff --git a/LNUnit.Tests/appsettings.json b/LNUnit.Tests/appsettings.json index 4d3eedc..4cac1df 100644 --- a/LNUnit.Tests/appsettings.json +++ b/LNUnit.Tests/appsettings.json @@ -1,4 +1,5 @@ { + "UseKubernetes": true, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/LNUnit/LNUnit.csproj b/LNUnit/LNUnit.csproj index 545ca5a..03d06cf 100644 --- a/LNUnit/LNUnit.csproj +++ b/LNUnit/LNUnit.csproj @@ -19,6 +19,7 @@ + diff --git a/LNUnit/Setup/ContainerCreateOptions.cs b/LNUnit/Setup/ContainerCreateOptions.cs new file mode 100644 index 0000000..8ada5af --- /dev/null +++ b/LNUnit/Setup/ContainerCreateOptions.cs @@ -0,0 +1,18 @@ +namespace LNUnit.Setup; + +/// +/// Unified options for creating containers/pods across Docker and Kubernetes +/// +public class ContainerCreateOptions +{ + public required string Name { get; set; } + public required string Image { get; set; } + public string Tag { get; set; } = "latest"; + public string? NetworkId { get; set; } + public List? Command { get; set; } + public Dictionary? Environment { get; set; } + public List? Binds { get; set; } // Volume mounts (Docker style: "/host:/container") + public List? Links { get; set; } // Container links (Docker) or Services (K8s) + public Dictionary? Labels { get; set; } + public List? ExposedPorts { get; set; } +} diff --git a/LNUnit/Setup/ContainerInfo.cs b/LNUnit/Setup/ContainerInfo.cs new file mode 100644 index 0000000..d78ebaf --- /dev/null +++ b/LNUnit/Setup/ContainerInfo.cs @@ -0,0 +1,15 @@ +namespace LNUnit.Setup; + +/// +/// Unified model for container/pod information across Docker and Kubernetes +/// +public class ContainerInfo +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Image { get; set; } + public required string State { get; set; } // Running, Stopped, Pending, etc. + public string? IpAddress { get; set; } + public IDictionary? Labels { get; set; } + public bool IsRunning => State == "Running"; +} diff --git a/LNUnit/Setup/DockerOrchestrator.cs b/LNUnit/Setup/DockerOrchestrator.cs new file mode 100644 index 0000000..fc60382 --- /dev/null +++ b/LNUnit/Setup/DockerOrchestrator.cs @@ -0,0 +1,253 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using SharpCompress.Readers; +using System.Text; + +namespace LNUnit.Setup; + +/// +/// Docker implementation of IContainerOrchestrator +/// +public class DockerOrchestrator : IContainerOrchestrator +{ + private readonly DockerClient _client; + + /// + /// Exposes the underlying Docker client for Docker-specific operations + /// + public DockerClient Client => _client; + + public DockerOrchestrator() + { + _client = new DockerClientConfiguration().CreateClient(); + } + + public async Task CreateNetworkAsync(string networkName) + { + var response = await _client.Networks.CreateNetworkAsync(new NetworksCreateParameters + { + Name = networkName, + Driver = "bridge", + CheckDuplicate = true + }).ConfigureAwait(false); + return response.ID; + } + + public async Task DeleteNetworkAsync(string networkId) + { + try + { + await _client.Networks.DeleteNetworkAsync(networkId).ConfigureAwait(false); + } + catch + { + // Ignore if already deleted + } + } + + public async Task CreateContainerAsync(ContainerCreateOptions options) + { + var createParams = new CreateContainerParameters + { + Image = $"{options.Image}:{options.Tag}", + Name = options.Name, + Hostname = options.Name, + Cmd = options.Command, + HostConfig = new HostConfig + { + NetworkMode = options.NetworkId, + Binds = options.Binds, + Links = options.Links + }, + Env = options.Environment?.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(), + Labels = options.Labels + }; + + if (options.ExposedPorts?.Any() == true) + { + createParams.ExposedPorts = options.ExposedPorts.ToDictionary( + p => $"{p}/tcp", + _ => new EmptyStruct() + ); + } + + var response = await _client.Containers.CreateContainerAsync(createParams).ConfigureAwait(false); + + return new ContainerInfo + { + Id = response.ID, + Name = options.Name, + Image = $"{options.Image}:{options.Tag}", + State = "Created" + }; + } + + public async Task StartContainerAsync(string containerId) + { + return await _client.Containers.StartContainerAsync(containerId, new ContainerStartParameters()).ConfigureAwait(false); + } + + public async Task StopContainerAsync(string containerId, uint waitSeconds = 1) + { + try + { + return await _client.Containers.StopContainerAsync(containerId, + new ContainerStopParameters { WaitBeforeKillSeconds = waitSeconds }).ConfigureAwait(false); + } + catch + { + return false; + } + } + + public async Task RemoveContainerAsync(string containerId, bool removeVolumes = true) + { + try + { + await _client.Containers.RemoveContainerAsync(containerId, + new ContainerRemoveParameters { Force = true, RemoveVolumes = removeVolumes }).ConfigureAwait(false); + } + catch + { + // Ignore if already deleted + } + } + + public async Task RestartContainerAsync(string containerId, uint waitSeconds = 1) + { + try + { + await _client.Containers.RestartContainerAsync(containerId, + new ContainerRestartParameters { WaitBeforeKillSeconds = waitSeconds }).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + public async Task InspectContainerAsync(string containerId) + { + var inspectionResponse = await _client.Containers.InspectContainerAsync(containerId).ConfigureAwait(false); + + return new ContainerInfo + { + Id = inspectionResponse.ID, + Name = inspectionResponse.Name.TrimStart('/'), + Image = inspectionResponse.Config.Image, + State = inspectionResponse.State.Running ? "Running" : "Stopped", + IpAddress = inspectionResponse.NetworkSettings.Networks.FirstOrDefault().Value?.IPAddress, + Labels = inspectionResponse.Config.Labels + }; + } + + public async Task> ListContainersAsync() + { + var containers = await _client.Containers.ListContainersAsync(new ContainersListParameters()).ConfigureAwait(false); + + return containers.Select(c => new ContainerInfo + { + Id = c.ID, + Name = c.Names.FirstOrDefault()?.TrimStart('/') ?? c.ID, + Image = c.Image, + State = c.State, + IpAddress = c.NetworkSettings?.Networks?.FirstOrDefault().Value?.IPAddress, + Labels = c.Labels + }).ToList(); + } + + public async Task ExtractBinaryFileAsync(string containerId, string filePath) + { + var tarResponse = await _client.Containers.GetArchiveFromContainerAsync( + containerId, + new GetArchiveFromContainerParameters { Path = filePath }, + false + ).ConfigureAwait(false); + + return GetBytesFromTar(tarResponse); + } + + public async Task ExtractTextFileAsync(string containerId, string filePath) + { + var tarResponse = await _client.Containers.GetArchiveFromContainerAsync( + containerId, + new GetArchiveFromContainerParameters { Path = filePath }, + false + ).ConfigureAwait(false); + + return GetStringFromTar(tarResponse); + } + + public async Task PutFileAsync(string containerId, string targetPath, Stream content) + { + await _client.Containers.ExtractArchiveToContainerAsync(containerId, + new ContainerPathStatParameters { Path = targetPath }, + content).ConfigureAwait(false); + } + + public async Task PullImageAsync(string image, string tag) + { + await _client.PullImageAndWaitForCompleted(image, tag).ConfigureAwait(false); + } + + public async Task ImageExistsAsync(string image, string tag) + { + try + { + var images = await _client.Images.ListImagesAsync(new ImagesListParameters + { + All = true + }).ConfigureAwait(false); + + var searchFor = $"{image}:{tag}"; + return images.Any(i => i.RepoTags?.Contains(searchFor) == true); + } + catch + { + return false; + } + } + + public void Dispose() + { + _client?.Dispose(); + } + + // Helper methods from existing LNUnitBuilder patterns + private static string GetStringFromTar(GetArchiveFromContainerResponse tarResponse) + { + using var stream = tarResponse.Stream; + var reader = ReaderFactory.Open(stream, new ReaderOptions { LookForHeader = true }); + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + using var entryStream = reader.OpenEntryStream(); + using var streamReader = new StreamReader(entryStream); + return streamReader.ReadToEnd(); + } + } + return string.Empty; + } + + private static byte[] GetBytesFromTar(GetArchiveFromContainerResponse tarResponse) + { + using var memStream = new MemoryStream(); + tarResponse.Stream.CopyTo(memStream); + memStream.Position = 0; + + var reader = ReaderFactory.Open(memStream, new ReaderOptions { LookForHeader = true }); + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + using var entryStream = reader.OpenEntryStream(); + using var resultStream = new MemoryStream(); + entryStream.CopyTo(resultStream); + return resultStream.ToArray(); + } + } + return Array.Empty(); + } +} diff --git a/LNUnit/Setup/IContainerOrchestrator.cs b/LNUnit/Setup/IContainerOrchestrator.cs new file mode 100644 index 0000000..cf97173 --- /dev/null +++ b/LNUnit/Setup/IContainerOrchestrator.cs @@ -0,0 +1,31 @@ +namespace LNUnit.Setup; + +/// +/// Abstract interface for container orchestration that works with both Docker and Kubernetes +/// +public interface IContainerOrchestrator : IDisposable +{ + // Network/Namespace management + Task CreateNetworkAsync(string networkName); + Task DeleteNetworkAsync(string networkId); + + // Container/Pod management + Task CreateContainerAsync(ContainerCreateOptions options); + Task StartContainerAsync(string containerId); + Task StopContainerAsync(string containerId, uint waitSeconds = 1); + Task RemoveContainerAsync(string containerId, bool removeVolumes = true); + Task RestartContainerAsync(string containerId, uint waitSeconds = 1); + + // Inspection + Task InspectContainerAsync(string containerId); + Task> ListContainersAsync(); + + // File operations + Task ExtractBinaryFileAsync(string containerId, string filePath); + Task ExtractTextFileAsync(string containerId, string filePath); + Task PutFileAsync(string containerId, string targetPath, Stream content); + + // Image management + Task PullImageAsync(string image, string tag); + Task ImageExistsAsync(string image, string tag); +} diff --git a/LNUnit/Setup/KubernetesHelper.cs b/LNUnit/Setup/KubernetesHelper.cs new file mode 100644 index 0000000..def0135 --- /dev/null +++ b/LNUnit/Setup/KubernetesHelper.cs @@ -0,0 +1,391 @@ +using System.Text; +using k8s; +using k8s.Models; + +namespace LNUnit.Setup; + +public static class KubernetesHelper +{ + private static readonly Random Random = new(); + + /// + /// Create a pod with specified options and wait for it to be running + /// + public static async Task CreatePodAndWaitForRunning( + this IKubernetes client, + string namespaceName, + string podName, + string image, + string tag = "latest", + List? command = null, + Dictionary? env = null, + List? volumeMounts = null, + List? volumes = null, + Dictionary? labels = null, + int timeoutSeconds = 60) + { + var pod = new V1Pod + { + Metadata = new V1ObjectMeta + { + Name = podName, + Labels = labels ?? new Dictionary() + }, + Spec = new V1PodSpec + { + Containers = new List + { + new V1Container + { + Name = podName, + Image = $"{image}:{tag}", + Command = command, + Env = env?.Select(kvp => new V1EnvVar{ Name = kvp.Key, Value = kvp.Value}).ToList(), + VolumeMounts = volumeMounts + } + }, + Volumes = volumes, + RestartPolicy = "Always" + } + }; + + await client.CoreV1.CreateNamespacedPodAsync(pod, namespaceName).ConfigureAwait(false); + return await client.WaitForPodRunning(namespaceName, podName, timeoutSeconds).ConfigureAwait(false); + } + + /// + /// Wait for a pod to reach Running state with all containers ready + /// + public static async Task WaitForPodRunning( + this IKubernetes client, + string namespaceName, + string podName, + int timeoutSeconds = 60) + { + var timeout = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (DateTime.UtcNow < timeout) + { + var pod = await client.CoreV1.ReadNamespacedPodAsync(podName, namespaceName).ConfigureAwait(false); + + if (pod.Status?.Phase == "Running" && !string.IsNullOrEmpty(pod.Status?.PodIP)) + { + // Also check container ready status + if (pod.Status.ContainerStatuses?.All(c => c.Ready) == true) + { + return pod; + } + } + + if (pod.Status?.Phase == "Failed" || pod.Status?.Phase == "Unknown") + { + throw new Exception($"Pod {podName} failed: {pod.Status.Reason} - {pod.Status.Message}"); + } + + await Task.Delay(500).ConfigureAwait(false); + } + + throw new TimeoutException($"Pod {podName} did not reach Running state within {timeoutSeconds}s"); + } + + /// + /// Extract a text file from a pod using cat command via exec API + /// + public static async Task ExecAndReadTextFile( + this IKubernetes client, + string namespaceName, + string podName, + string containerName, + string filePath) + { + var command = new[] { "cat", filePath }; + return await client.ExecInPod(namespaceName, podName, containerName, command).ConfigureAwait(false); + } + + /// + /// Extract a binary file from a pod using base64 encoding via exec API + /// + public static async Task ExecAndReadBinaryFile( + this IKubernetes client, + string namespaceName, + string podName, + string containerName, + string filePath) + { + var command = new[] { "sh", "-c", $"base64 -w 0 {filePath}" }; + var base64Result = await client.ExecInPod(namespaceName, podName, containerName, command).ConfigureAwait(false); + return Convert.FromBase64String(base64Result.Trim()); + } + + /// + /// Execute a command in a pod and return the stdout + /// + private static async Task ExecInPod( + this IKubernetes client, + string namespaceName, + string podName, + string containerName, + string[] command) + { + var output = new StringBuilder(); + var error = new StringBuilder(); + + var handler = new ExecAsyncCallback(async (stdIn, stdOut, stdError) => + { + var buffer = new byte[4096]; + + // Read stdout + var stdOutTask = Task.Run(async () => + { + while (true) + { + var bytesRead = await stdOut.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (bytesRead == 0) break; + output.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead)); + } + }); + + // Read stderr + var stdErrTask = Task.Run(async () => + { + while (true) + { + var bytesRead = await stdError.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (bytesRead == 0) break; + error.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead)); + } + }); + + await Task.WhenAll(stdOutTask, stdErrTask).ConfigureAwait(false); + }); + + await client.NamespacedPodExecAsync( + podName, + namespaceName, + containerName, + command, + false, + handler, + CancellationToken.None).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error.ToString())) + { + throw new Exception($"Error executing command in pod: {error}"); + } + + return output.ToString(); + } + + /// + /// Create a test namespace with random suffix + /// + public static async Task CreateTestNamespace( + this IKubernetes client, + string baseName = "unit-test") + { + var randomString = GetRandomHexString(); + var namespaceName = $"{baseName}-{randomString}"; + + var ns = new V1Namespace + { + Metadata = new V1ObjectMeta + { + Name = namespaceName, + Labels = new Dictionary + { + { "lnunit-test", "true" }, + { "created-by", "lnunit" } + } + } + }; + + await client.CoreV1.CreateNamespaceAsync(ns).ConfigureAwait(false); + return namespaceName; + } + + /// + /// Delete a namespace + /// + public static async Task DeleteNamespace( + this IKubernetes client, + string namespaceName, + int gracePeriodSeconds = 0) + { + try + { + await client.CoreV1.DeleteNamespaceAsync( + namespaceName, + gracePeriodSeconds: gracePeriodSeconds).ConfigureAwait(false); + + // Optionally wait for deletion (can be slow) + // await WaitForNamespaceDeletion(client, namespaceName, timeoutSeconds: 60); + } + catch (Exception) + { + // Ignore if already deleted + } + } + + /// + /// Create a ClusterIP service for a pod + /// + public static async Task CreateService( + this IKubernetes client, + string namespaceName, + string serviceName, + string targetPodLabel, + int port, + int targetPort, + string protocol = "TCP") + { + var service = new V1Service + { + Metadata = new V1ObjectMeta + { + Name = serviceName + }, + Spec = new V1ServiceSpec + { + Selector = new Dictionary + { + { "app", targetPodLabel } + }, + Ports = new List + { + new V1ServicePort + { + Port = port, + TargetPort = targetPort, + Protocol = protocol + } + }, + Type = "ClusterIP" + } + }; + + return await client.CoreV1.CreateNamespacedServiceAsync(service, namespaceName).ConfigureAwait(false); + } + + /// + /// Create a PersistentVolumeClaim + /// + public static async Task CreatePVC( + this IKubernetes client, + string namespaceName, + string pvcName, + string storageSize = "1Gi") + { + var pvc = new V1PersistentVolumeClaim + { + Metadata = new V1ObjectMeta + { + Name = pvcName + }, + Spec = new V1PersistentVolumeClaimSpec + { + AccessModes = new List { "ReadWriteOnce" }, + Resources = new V1VolumeResourceRequirements + { + Requests = new Dictionary + { + { "storage", new ResourceQuantity(storageSize) } + } + } + } + }; + + return await client.CoreV1.CreateNamespacedPersistentVolumeClaimAsync(pvc, namespaceName).ConfigureAwait(false); + } + + /// + /// Delete a pod + /// + public static async Task RemovePod( + this IKubernetes client, + string namespaceName, + string podName, + int gracePeriodSeconds = 0) + { + try + { + await client.CoreV1.DeleteNamespacedPodAsync( + podName, + namespaceName, + gracePeriodSeconds: gracePeriodSeconds).ConfigureAwait(false); + } + catch (Exception) + { + // Ignore if already deleted + } + } + + /// + /// Get random hex string for naming (lowercase for K8s compatibility) + /// + private static string GetRandomHexString(int size = 8) + { + var b = new byte[size]; + Random.NextBytes(b); + return Convert.ToHexString(b).ToLower(); + } + + /// + /// Wait for a file to exist in a pod (poll with retry) + /// + public static async Task WaitForFileAndRead( + this IKubernetes client, + string namespaceName, + string podName, + string containerName, + string filePath, + int timeoutSeconds = 30) + { + var timeout = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (DateTime.UtcNow < timeout) + { + try + { + return await client.ExecAndReadTextFile(namespaceName, podName, containerName, filePath).ConfigureAwait(false); + } + catch (Exception) + { + // File doesn't exist yet, wait and retry + } + + await Task.Delay(500).ConfigureAwait(false); + } + + throw new TimeoutException($"File {filePath} did not appear in pod {podName} within {timeoutSeconds}s"); + } + + /// + /// Wait for a binary file to exist in a pod (poll with retry) + /// + public static async Task WaitForBinaryFileAndRead( + this IKubernetes client, + string namespaceName, + string podName, + string containerName, + string filePath, + int timeoutSeconds = 30) + { + var timeout = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (DateTime.UtcNow < timeout) + { + try + { + return await client.ExecAndReadBinaryFile(namespaceName, podName, containerName, filePath).ConfigureAwait(false); + } + catch (Exception) + { + // File doesn't exist yet, wait and retry + } + + await Task.Delay(500).ConfigureAwait(false); + } + + throw new TimeoutException($"File {filePath} did not appear in pod {podName} within {timeoutSeconds}s"); + } +} diff --git a/LNUnit/Setup/KubernetesOrchestrator.cs b/LNUnit/Setup/KubernetesOrchestrator.cs new file mode 100644 index 0000000..71fd51c --- /dev/null +++ b/LNUnit/Setup/KubernetesOrchestrator.cs @@ -0,0 +1,408 @@ +using k8s; +using k8s.Models; + +namespace LNUnit.Setup; + +/// +/// Kubernetes implementation of IContainerOrchestrator +/// +public class KubernetesOrchestrator : IContainerOrchestrator +{ + private readonly IKubernetes _client; + private readonly Dictionary _networkToNamespaceMap = new(); + private readonly string _defaultNamespace; + + public KubernetesOrchestrator(string? defaultNamespace = null) + { + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + config.SkipTlsVerify = true; // For development environments + _client = new Kubernetes(config); + _defaultNamespace = defaultNamespace ?? "default"; + } + + public KubernetesOrchestrator(IKubernetes client, string? defaultNamespace = null) + { + _client = client; + _defaultNamespace = defaultNamespace ?? "default"; + } + + // Network/Namespace management + public async Task CreateNetworkAsync(string networkName) + { + // In Kubernetes, networks are namespaces + var namespaceName = await _client.CreateTestNamespace(networkName).ConfigureAwait(false); + _networkToNamespaceMap[networkName] = namespaceName; + return namespaceName; + } + + public async Task DeleteNetworkAsync(string networkId) + { + await _client.DeleteNamespace(networkId).ConfigureAwait(false); + + // Remove from map if it exists + var key = _networkToNamespaceMap.FirstOrDefault(x => x.Value == networkId).Key; + if (key != null) + { + _networkToNamespaceMap.Remove(key); + } + } + + // Container/Pod management + public async Task CreateContainerAsync(ContainerCreateOptions options) + { + var namespaceName = options.NetworkId ?? _defaultNamespace; + + // Convert Docker-style volume binds to Kubernetes volumes + List? volumes = null; + List? volumeMounts = null; + + if (options.Binds?.Any() == true) + { + volumes = new List(); + volumeMounts = new List(); + + foreach (var bind in options.Binds) + { + // Docker format: /host/path:/container/path or volumeName:/container/path + var parts = bind.Split(':'); + if (parts.Length >= 2) + { + var volumeName = $"{options.Name}-vol-{volumes.Count}"; + var mountPath = parts[1]; + + volumes.Add(new V1Volume + { + Name = volumeName, + EmptyDir = new V1EmptyDirVolumeSource() // Using emptyDir for simplicity + }); + + volumeMounts.Add(new V1VolumeMount + { + Name = volumeName, + MountPath = mountPath + }); + } + } + } + + var labels = options.Labels ?? new Dictionary { { "app", options.Name } }; + + var pod = await _client.CreatePodAndWaitForRunning( + namespaceName, + options.Name, + options.Image, + options.Tag, + command: options.Command, + env: options.Environment, + volumeMounts: volumeMounts, + volumes: volumes, + labels: labels, + timeoutSeconds: 60 + ).ConfigureAwait(false); + + // Create a headless service for DNS resolution (mimics Docker's container name resolution) + await CreateHeadlessServiceForPod(namespaceName, options.Name, labels).ConfigureAwait(false); + + return new ContainerInfo + { + Id = pod.Metadata.Uid, + Name = pod.Metadata.Name, + Image = $"{options.Image}:{options.Tag}", + State = pod.Status.Phase, + IpAddress = pod.Status.PodIP, + Labels = pod.Metadata.Labels + }; + } + + public async Task StartContainerAsync(string containerId) + { + // Kubernetes pods auto-start after creation + // This is a no-op, but we verify the pod exists + try + { + var pod = await FindPodByIdOrName(containerId).ConfigureAwait(false); + return pod != null; + } + catch + { + return false; + } + } + + public async Task StopContainerAsync(string containerId, uint waitSeconds = 1) + { + try + { + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + + // Delete the service first + try + { + await _client.CoreV1.DeleteNamespacedServiceAsync(podName, namespaceName).ConfigureAwait(false); + } + catch + { + // Ignore if service doesn't exist + } + + await _client.RemovePod(namespaceName, podName, gracePeriodSeconds: (int)waitSeconds).ConfigureAwait(false); + return true; + } + catch + { + return false; + } + } + + public async Task RemoveContainerAsync(string containerId, bool removeVolumes = true) + { + try + { + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + + // Delete the service first + try + { + await _client.CoreV1.DeleteNamespacedServiceAsync(podName, namespaceName).ConfigureAwait(false); + } + catch + { + // Ignore if service doesn't exist + } + + await _client.RemovePod(namespaceName, podName, gracePeriodSeconds: 0).ConfigureAwait(false); + } + catch + { + // Ignore if already deleted + } + } + + public async Task RestartContainerAsync(string containerId, uint waitSeconds = 1) + { + try + { + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + + // Get pod details before deletion + var pod = await _client.CoreV1.ReadNamespacedPodAsync(podName, namespaceName).ConfigureAwait(false); + + // Delete the pod + await _client.RemovePod(namespaceName, podName, gracePeriodSeconds: (int)waitSeconds).ConfigureAwait(false); + + // Wait for deletion + await Task.Delay(2000).ConfigureAwait(false); + + // Recreate pod with same spec (remove runtime fields) + pod.Metadata.ResourceVersion = null; + pod.Metadata.Uid = null; + pod.Status = null; + + await _client.CoreV1.CreateNamespacedPodAsync(pod, namespaceName).ConfigureAwait(false); + await _client.WaitForPodRunning(namespaceName, podName, timeoutSeconds: 60).ConfigureAwait(false); + + return true; + } + catch + { + return false; + } + } + + // Inspection + public async Task InspectContainerAsync(string containerId) + { + var pod = await FindPodByIdOrName(containerId).ConfigureAwait(false); + + if (pod == null) + { + throw new Exception($"Pod not found: {containerId}"); + } + + return new ContainerInfo + { + Id = pod.Metadata.Uid, + Name = pod.Metadata.Name, + Image = pod.Spec.Containers.FirstOrDefault()?.Image ?? "", + State = pod.Status.Phase, + IpAddress = pod.Status.PodIP, + Labels = pod.Metadata.Labels + }; + } + + public async Task> ListContainersAsync() + { + var allPods = new List(); + + // List pods from all tracked namespaces + foreach (var ns in _networkToNamespaceMap.Values.Distinct()) + { + var pods = await _client.CoreV1.ListNamespacedPodAsync(ns).ConfigureAwait(false); + allPods.AddRange(pods.Items); + } + + // Also check default namespace if not already included + if (!_networkToNamespaceMap.Values.Contains(_defaultNamespace)) + { + var defaultPods = await _client.CoreV1.ListNamespacedPodAsync(_defaultNamespace).ConfigureAwait(false); + allPods.AddRange(defaultPods.Items); + } + + return allPods.Select(pod => new ContainerInfo + { + Id = pod.Metadata.Uid, + Name = pod.Metadata.Name, + Image = pod.Spec.Containers.FirstOrDefault()?.Image ?? "", + State = pod.Status.Phase, + IpAddress = pod.Status.PodIP, + Labels = pod.Metadata.Labels + }).ToList(); + } + + // File operations + public async Task ExtractBinaryFileAsync(string containerId, string filePath) + { + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + var containerName = podName; // Assuming container name matches pod name + + return await _client.ExecAndReadBinaryFile(namespaceName, podName, containerName, filePath).ConfigureAwait(false); + } + + public async Task ExtractTextFileAsync(string containerId, string filePath) + { + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + var containerName = podName; // Assuming container name matches pod name + + return await _client.ExecAndReadTextFile(namespaceName, podName, containerName, filePath).ConfigureAwait(false); + } + + public async Task PutFileAsync(string containerId, string targetPath, Stream content) + { + // Kubernetes doesn't have a direct equivalent to Docker's copy-to-container + // We'll use kubectl cp equivalent via exec + var (namespaceName, podName) = await GetPodNamespaceAndName(containerId).ConfigureAwait(false); + + // Read content from stream + using var memoryStream = new MemoryStream(); + await content.CopyToAsync(memoryStream).ConfigureAwait(false); + var bytes = memoryStream.ToArray(); + var base64Content = Convert.ToBase64String(bytes); + + // Write file using base64 decode in pod + var command = new[] { "sh", "-c", $"echo '{base64Content}' | base64 -d > {targetPath}" }; + + // Execute command (we'd need to extend KubernetesHelper for this, or inline it here) + // For now, throw not implemented + throw new NotImplementedException("PutFileAsync requires extending KubernetesHelper with exec command support"); + } + + // Image management + public async Task PullImageAsync(string image, string tag) + { + // Kubernetes automatically pulls images when creating pods + // We can verify the image by creating a temporary pod + // For now, this is a no-op + await Task.CompletedTask; + } + + public async Task ImageExistsAsync(string image, string tag) + { + // Kubernetes doesn't expose image listings directly + // We'll assume the image exists and let pod creation fail if it doesn't + await Task.CompletedTask; + return true; + } + + public void Dispose() + { + _client?.Dispose(); + } + + // Helper methods + private async Task FindPodByIdOrName(string idOrName) + { + // Try to find by name in tracked namespaces first + foreach (var ns in _networkToNamespaceMap.Values.Distinct().Append(_defaultNamespace)) + { + try + { + var pod = await _client.CoreV1.ReadNamespacedPodAsync(idOrName, ns).ConfigureAwait(false); + if (pod != null) return pod; + } + catch + { + // Continue searching + } + } + + // Try to find by UID across all namespaces + foreach (var ns in _networkToNamespaceMap.Values.Distinct().Append(_defaultNamespace)) + { + try + { + var pods = await _client.CoreV1.ListNamespacedPodAsync(ns).ConfigureAwait(false); + var pod = pods.Items.FirstOrDefault(p => p.Metadata.Uid == idOrName); + if (pod != null) return pod; + } + catch + { + // Continue searching + } + } + + return null; + } + + private async Task<(string namespaceName, string podName)> GetPodNamespaceAndName(string idOrName) + { + var pod = await FindPodByIdOrName(idOrName).ConfigureAwait(false); + + if (pod == null) + { + throw new Exception($"Pod not found: {idOrName}"); + } + + return (pod.Metadata.NamespaceProperty, pod.Metadata.Name); + } + + /// + /// Creates a headless service for a pod to enable DNS resolution by pod name. + /// This mimics Docker's behavior where container names are automatically DNS-resolvable. + /// + private async Task CreateHeadlessServiceForPod(string namespaceName, string podName, Dictionary labels) + { + try + { + var service = new V1Service + { + Metadata = new V1ObjectMeta + { + Name = podName, + Labels = labels + }, + Spec = new V1ServiceSpec + { + ClusterIP = "None", // Headless service + Selector = labels, + Ports = new List + { + // Add a dummy port to satisfy Kubernetes requirements + new V1ServicePort + { + Name = "default", + Port = 1, + TargetPort = 1, + Protocol = "TCP" + } + } + } + }; + + await _client.CoreV1.CreateNamespacedServiceAsync(service, namespaceName).ConfigureAwait(false); + } + catch (Exception) + { + // Ignore if service already exists or creation fails + } + } +} diff --git a/LNUnit/Setup/LNUnitBuilder.cs b/LNUnit/Setup/LNUnitBuilder.cs index 3329cd9..7438d74 100644 --- a/LNUnit/Setup/LNUnitBuilder.cs +++ b/LNUnit/Setup/LNUnitBuilder.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; +using System.Security.Cryptography; using System.Text; using Docker.DotNet; using Docker.DotNet.Models; @@ -23,7 +24,7 @@ namespace LNUnit.Setup; public class LNUnitBuilder : IDisposable { - private readonly DockerClient _dockerClient = new DockerClientConfiguration().CreateClient(); + private readonly IContainerOrchestrator _orchestrator; private readonly ILogger? _logger; private readonly IServiceProvider? _serviceProvider; private bool _loopLNDReady; @@ -31,14 +32,28 @@ public class LNUnitBuilder : IDisposable public Dictionary InterceptorHandlers = new(); - public LNUnitBuilder(LNUnitNetworkDefinition c = null, ILogger? logger = null, - IServiceProvider serviceProvider = null) + // New primary constructor with orchestrator parameter + public LNUnitBuilder( + IContainerOrchestrator? orchestrator = null, + LNUnitNetworkDefinition? config = null, + ILogger? logger = null, + IServiceProvider? serviceProvider = null) { - Configuration = c ?? new LNUnitNetworkDefinition(); + _orchestrator = orchestrator ?? new DockerOrchestrator(); + Configuration = config ?? new LNUnitNetworkDefinition(); _logger = logger; _serviceProvider = serviceProvider; } + // Legacy constructor for backward compatibility + public LNUnitBuilder( + LNUnitNetworkDefinition? c = null, + ILogger? logger = null, + IServiceProvider? serviceProvider = null) + : this(null, c, logger, serviceProvider) + { + } + public int WaitForBitcoinNodeStartupTimeout { get; set; } = 30_000; //ms timeout public bool IsBuilt { get; internal set; } @@ -49,11 +64,17 @@ public LNUnitBuilder(LNUnitNetworkDefinition c = null, ILogger? l public void Dispose() { - _dockerClient.Dispose(); + _orchestrator?.Dispose(); if (LNDNodePool != null) LNDNodePool.Dispose(); } + // Helper method to generate network name with random suffix + private static string GenerateNetworkName(string baseName) + { + var randomHex = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLower(); + return $"{baseName}_{randomHex}"; + } public static async Task LoadConfigurationFile(string path) { @@ -78,25 +99,37 @@ public async Task Destroy(bool destoryNetwork = false) foreach (var n in Configuration.BTCNodes.Where(x => !x.DockerContainerId.IsEmpty())) { - var result = await _dockerClient.Containers.StopContainerAsync(n.DockerContainerId, - new ContainerStopParameters { WaitBeforeKillSeconds = 1 }).ConfigureAwait(false); - await _dockerClient.Containers.RemoveContainerAsync(n.DockerContainerId, - new ContainerRemoveParameters { Force = true, RemoveVolumes = true }).ConfigureAwait(false); + await _orchestrator.StopContainerAsync(n.DockerContainerId, waitSeconds: 1).ConfigureAwait(false); + await _orchestrator.RemoveContainerAsync(n.DockerContainerId, removeVolumes: true).ConfigureAwait(false); } foreach (var n in Configuration.LNDNodes.Where(x => !x.DockerContainerId.IsEmpty())) { - var result = await _dockerClient.Containers.StopContainerAsync(n.DockerContainerId, - new ContainerStopParameters { WaitBeforeKillSeconds = 1 }).ConfigureAwait(false); - await _dockerClient.Containers.RemoveContainerAsync(n.DockerContainerId, - new ContainerRemoveParameters { Force = true, RemoveVolumes = true }).ConfigureAwait(false); + await _orchestrator.StopContainerAsync(n.DockerContainerId, waitSeconds: 1).ConfigureAwait(false); + await _orchestrator.RemoveContainerAsync(n.DockerContainerId, removeVolumes: true).ConfigureAwait(false); } if (destoryNetwork) - await _dockerClient.Networks.DeleteNetworkAsync(Configuration.DockerNetworkId).ConfigureAwait(false); + await _orchestrator.DeleteNetworkAsync(Configuration.DockerNetworkId).ConfigureAwait(false); IsDestoryed = true; } + /// + /// Remove a container by name if it exists. Safe to call even if container doesn't exist. + /// Useful for cleaning up containers from previous test runs. + /// + public async Task RemoveContainerByNameIfExists(string containerName) + { + try + { + await _orchestrator.RemoveContainerAsync(containerName, removeVolumes: true).ConfigureAwait(false); + } + catch + { + // Ignore if container doesn't exist + } + } + public async Task Build(bool setupNetwork = false, string lndRoot = "/home/lnd/.lnd") { _logger?.LogInformation("Building LNUnit scenerio."); @@ -111,41 +144,46 @@ public async Task Build(bool setupNetwork = false, string lndRoot = "/home/lnd/. //Setup network if (setupNetwork) - Configuration.DockerNetworkId = - await _dockerClient.BuildTestingNetwork(Configuration.BaseName).ConfigureAwait(false); + { + var networkName = GenerateNetworkName(Configuration.BaseName); + Configuration.DockerNetworkId = await _orchestrator.CreateNetworkAsync(networkName).ConfigureAwait(false); + } //Setup BTC Nodes - ContainerListResponse bitcoinNode; foreach (var n in Configuration.BTCNodes) //TODO: can do multiple at once { - if (n.PullImage) await _dockerClient.PullImageAndWaitForCompleted(n.Image, n.Tag).ConfigureAwait(false); - var nodeContainer = await _dockerClient.Containers.CreateContainerAsync(new CreateContainerParameters + if (n.PullImage) await _orchestrator.PullImageAsync(n.Image, n.Tag).ConfigureAwait(false); + + var containerInfo = await _orchestrator.CreateContainerAsync(new ContainerCreateOptions { - Image = $"{n.Image}:{n.Tag}", - HostConfig = new HostConfig - { - NetworkMode = $"{Configuration.DockerNetworkId}" - }, Name = n.Name, - Hostname = n.Name, - Cmd = n.Cmd + Image = n.Image, + Tag = n.Tag, + NetworkId = Configuration.DockerNetworkId, + Command = n.Cmd, + Environment = null, + Binds = null, + Links = null, + Labels = new Dictionary { { "lnunit", "bitcoin" } } }).ConfigureAwait(false); - n.DockerContainerId = nodeContainer.ID; - var success = - await _dockerClient.Containers.StartContainerAsync(nodeContainer.ID, new ContainerStartParameters()) - .ConfigureAwait(false); + + n.DockerContainerId = containerInfo.Id; + await _orchestrator.StartContainerAsync(containerInfo.Id).ConfigureAwait(false); await Task.Delay(500).ConfigureAwait(false); + //Setup wallet and basic funds - var listContainers = await _dockerClient.Containers.ListContainersAsync(new ContainersListParameters()) - .ConfigureAwait(false); - bitcoinNode = listContainers.First(x => x.ID == nodeContainer.ID); - BitcoinRpcClient = new RPCClient("bitcoin:bitcoin", - bitcoinNode.NetworkSettings.Networks.First().Value.IPAddress, Bitcoin.Instance.Regtest); + var inspectedContainer = await _orchestrator.InspectContainerAsync(n.DockerContainerId).ConfigureAwait(false); + var ipAddress = inspectedContainer.IpAddress; + + BitcoinRpcClient = new RPCClient("bitcoin:bitcoin", ipAddress, Bitcoin.Instance.Regtest); WaitForBitcoinNodeStartupTimeout = 30000; BitcoinRpcClient.HttpClient = new HttpClient { Timeout = TimeSpan.FromMilliseconds(WaitForBitcoinNodeStartupTimeout) }; + // Wait for Bitcoin RPC to be ready (may take longer in Kubernetes) + await WaitForBitcoinRpcReady(BitcoinRpcClient, timeoutSeconds: 180).ConfigureAwait(false); + await BitcoinRpcClient.CreateWalletAsync("default", new CreateWalletOptions { LoadOnStartup = true }) .ConfigureAwait(false); var utxos = await BitcoinRpcClient.GenerateAsync(200).ConfigureAwait(false); @@ -153,31 +191,31 @@ public async Task Build(bool setupNetwork = false, string lndRoot = "/home/lnd/. var lndSettings = new List(); - CreateContainerResponse? loopServer = null; + string? loopServerId = null; if (Configuration.LoopServer != null) { var n = Configuration.LoopServer; - if (n.PullImage) await _dockerClient.PullImageAndWaitForCompleted(n.Image, n.Tag).ConfigureAwait(false); - var nodeContainer = await _dockerClient.Containers.CreateContainerAsync(new CreateContainerParameters + if (n.PullImage) await _orchestrator.PullImageAsync(n.Image, n.Tag).ConfigureAwait(false); + + var containerInfo = await _orchestrator.CreateContainerAsync(new ContainerCreateOptions { - Image = $"{n.Image}:{n.Tag}", - HostConfig = new HostConfig - { - NetworkMode = $"{Configuration.DockerNetworkId}", - Links = new List { n.BitcoinBackendName } - }, Name = n.Name, - Hostname = n.Name, - Cmd = n.Cmd + Image = n.Image, + Tag = n.Tag, + NetworkId = Configuration.DockerNetworkId, + Command = n.Cmd, + Environment = null, + Binds = null, + Links = new List { n.BitcoinBackendName }, + Labels = new Dictionary { { "lnunit", "loop-lnd" } } }).ConfigureAwait(false); - n.DockerContainerId = nodeContainer.ID; - var success = - await _dockerClient.Containers.StartContainerAsync(nodeContainer.ID, new ContainerStartParameters()) - .ConfigureAwait(false); - var inspectionResponse = await _dockerClient.Containers.InspectContainerAsync(n.DockerContainerId) - .ConfigureAwait(false); - var ipAddress = inspectionResponse.NetworkSettings.Networks.First().Value.IPAddress; + + n.DockerContainerId = containerInfo.Id; + await _orchestrator.StartContainerAsync(containerInfo.Id).ConfigureAwait(false); + + var inspectedContainer = await _orchestrator.InspectContainerAsync(n.DockerContainerId).ConfigureAwait(false); + var ipAddress = inspectedContainer.IpAddress; var txt = await GetStringFromFS(n.DockerContainerId, $"{lndRoot}/tls.cert").ConfigureAwait(false); var tlsCertBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(txt)); @@ -185,11 +223,6 @@ public async Task Build(bool setupNetwork = false, string lndRoot = "/home/lnd/. $"{lndRoot}/data/chain/bitcoin/regtest/admin.macaroon").ConfigureAwait(false); var adminMacaroonBase64String = Convert.ToBase64String(data); - - var adminMacaroonTar = - await GetTarStreamFromFS(n.DockerContainerId, - $"{lndRoot}/data/chain/bitcoin/regtest/admin.macaroon").ConfigureAwait(false); - var lndConfig = new LNDSettings { GrpcEndpoint = $"https://{ipAddress}:10009/", @@ -200,24 +233,27 @@ await GetTarStreamFromFS(n.DockerContainerId, foreach (var dependentContainer in n.DependentContainers) { if (dependentContainer.PullImage) - await _dockerClient.PullImageAndWaitForCompleted(dependentContainer.Image, dependentContainer.Tag) + await _orchestrator.PullImageAsync(dependentContainer.Image, dependentContainer.Tag) .ConfigureAwait(false); - loopServer = await _dockerClient.Containers.CreateContainerAsync(new CreateContainerParameters + var depContainerInfo = await _orchestrator.CreateContainerAsync(new ContainerCreateOptions { - Image = $"{dependentContainer.Image}:{dependentContainer.Tag}", - HostConfig = new HostConfig - { - NetworkMode = $"{Configuration.DockerNetworkId}", - Links = new List { n.BitcoinBackendName, "loopserver-lnd", "alice" }, - Binds = dependentContainer.Binds - }, Name = dependentContainer.Name, - Hostname = dependentContainer.Name, - Cmd = dependentContainer.Cmd, - ExposedPorts = dependentContainer.ExposedPorts + Image = dependentContainer.Image, + Tag = dependentContainer.Tag, + NetworkId = Configuration.DockerNetworkId, + Command = dependentContainer.Cmd, + Environment = null, + Binds = dependentContainer.Binds, + Links = new List { n.BitcoinBackendName, "loopserver-lnd", "alice" }, + Labels = new Dictionary { { "lnunit", "loopserver" } }, + ExposedPorts = dependentContainer.ExposedPorts?.Keys + .Select(portStr => int.Parse(portStr.Split('/')[0])) + .ToList() }).ConfigureAwait(false); - dependentContainer.DockerContainerId = nodeContainer.ID; + + loopServerId = depContainerInfo.Id; + dependentContainer.DockerContainerId = depContainerInfo.Id; var dataReadonly = await GetBytesFromFS(n.DockerContainerId, $"{lndRoot}/data/chain/bitcoin/regtest/readonly.macaroon"); var readonlyBase64String = Convert.ToBase64String(dataReadonly); @@ -247,42 +283,41 @@ await _dockerClient.PullImageAndWaitForCompleted(dependentContainer.Image, depen walletkit); File.WriteAllBytes("./loopserver-test/router.macaroon", router); - var success2 = - await _dockerClient.Containers.StartContainerAsync(loopServer?.ID, - new ContainerStartParameters()).ConfigureAwait(false); + + if (loopServerId != null) + await _orchestrator.StartContainerAsync(loopServerId).ConfigureAwait(false); } } //Setup LND Nodes foreach (var n in Configuration.LNDNodes) //TODO: can do multiple at once { - if (n.PullImage) await _dockerClient.PullImageAndWaitForCompleted(n.Image, n.Tag).ConfigureAwait(false); - var createContainerParameters = new CreateContainerParameters + if (n.PullImage) await _orchestrator.PullImageAsync(n.Image, n.Tag).ConfigureAwait(false); + + var containerInfo = await _orchestrator.CreateContainerAsync(new ContainerCreateOptions { - Image = $"{n.Image}:{n.Tag}", - HostConfig = new HostConfig - { - NetworkMode = $"{Configuration.DockerNetworkId}", - Links = new List { n.BitcoinBackendName } - }, Name = n.Name, - Hostname = n.Name, - Cmd = n.Cmd - }; - if (n.Binds.Any()) createContainerParameters.HostConfig.Binds = n.Binds; - var nodeContainer = await _dockerClient.Containers.CreateContainerAsync(createContainerParameters) - .ConfigureAwait(false); - n.DockerContainerId = nodeContainer.ID; - var success = - await _dockerClient.Containers.StartContainerAsync(nodeContainer.ID, new ContainerStartParameters()); - //Not always having IP yet. + Image = n.Image, + Tag = n.Tag, + NetworkId = Configuration.DockerNetworkId, + Command = n.Cmd, + Environment = null, + Binds = n.Binds.Any() ? n.Binds : null, + Links = new List { n.BitcoinBackendName }, + Labels = new Dictionary { { "lnunit", "lnd" } } + }).ConfigureAwait(false); + + n.DockerContainerId = containerInfo.Id; + await _orchestrator.StartContainerAsync(containerInfo.Id).ConfigureAwait(false); + + //Not always having IP yet - poll until available var ipAddress = string.Empty; - ContainerInspectResponse? inspectionResponse = null; while (ipAddress.IsEmpty()) { - inspectionResponse = await _dockerClient.Containers.InspectContainerAsync(n.DockerContainerId) - .ConfigureAwait(false); - ipAddress = inspectionResponse.NetworkSettings.Networks.First().Value.IPAddress; + var inspectedContainer = await _orchestrator.InspectContainerAsync(n.DockerContainerId).ConfigureAwait(false); + ipAddress = inspectedContainer.IpAddress ?? string.Empty; + if (ipAddress.IsEmpty()) + await Task.Delay(100).ConfigureAwait(false); } var basePath = @@ -433,31 +468,21 @@ public async Task WaitUntilSyncedToChain(string alias) public async Task GetLNDSettingsFromContainer(string containerId, string lndRoot = "/home/lnd/.lnd") { - var inspectionResponse = - await _dockerClient.Containers.InspectContainerAsync(containerId).ConfigureAwait(false); - while (!inspectionResponse.State.Running) + var containerInfo = await _orchestrator.InspectContainerAsync(containerId).ConfigureAwait(false); + while (containerInfo.State != "Running") { - await Task.Delay(250); - inspectionResponse = - await _dockerClient.Containers.InspectContainerAsync(containerId).ConfigureAwait(false); + await Task.Delay(250).ConfigureAwait(false); + containerInfo = await _orchestrator.InspectContainerAsync(containerId).ConfigureAwait(false); } - var ipAddress = inspectionResponse.NetworkSettings.Networks.First().Value.IPAddress; - var foundFile = false; + var ipAddress = containerInfo.IpAddress; - GetArchiveFromContainerResponse? archResponse = null; //Wait until LND actually has files started - - - var tlsTar = await GetTarStreamFromFS(containerId, $"{lndRoot}/tls.cert").ConfigureAwait(false); - var txt = GetStringFromTar(tlsTar); //GetStringFromFS(containerId, "/home/lnd/.lnd/tls.cert"); + var txt = await _orchestrator.ExtractTextFileAsync(containerId, $"{lndRoot}/tls.cert").ConfigureAwait(false); var tlsCertBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(txt)); - var adminMacaroonTar = - await GetTarStreamFromFS(containerId, $"{lndRoot}/data/chain/bitcoin/regtest/admin.macaroon") - .ConfigureAwait(false); - var data = GetBytesFromTar( - adminMacaroonTar); + var data = await _orchestrator.ExtractBinaryFileAsync(containerId, + $"{lndRoot}/data/chain/bitcoin/regtest/admin.macaroon").ConfigureAwait(false); var adminMacaroonBase64String = Convert.ToBase64String(data); return new LNDSettings @@ -468,11 +493,20 @@ await GetTarStreamFromFS(containerId, $"{lndRoot}/data/chain/bitcoin/regtest/adm }; } + /// + /// Gets file stats from container filesystem. + /// NOTE: Docker-specific implementation. Only works with DockerOrchestrator. + /// public async Task GetFileSize(string containerId, string filePath) { + if (_orchestrator is not DockerOrchestrator dockerOrchestrator) + { + throw new NotSupportedException("GetFileSize is only supported with DockerOrchestrator"); + } + try { - var archResponse = await _dockerClient.Containers.GetArchiveFromContainerAsync( + var archResponse = await dockerOrchestrator.Client.Containers.GetArchiveFromContainerAsync( containerId, new GetArchiveFromContainerParameters { @@ -487,14 +521,20 @@ await GetTarStreamFromFS(containerId, $"{lndRoot}/data/chain/bitcoin/regtest/adm return null; } + [Obsolete("Use orchestrator file methods instead")] private async Task GetTarStreamFromFS(string containerId, string filePath) { + if (_orchestrator is not DockerOrchestrator dockerOrchestrator) + { + throw new NotSupportedException("GetTarStreamFromFS is only supported with DockerOrchestrator"); + } + var foundFile = false; while (!foundFile) { try { - var archResponse = await _dockerClient.Containers.GetArchiveFromContainerAsync( + var archResponse = await dockerOrchestrator.Client.Containers.GetArchiveFromContainerAsync( containerId, new GetArchiveFromContainerParameters { @@ -514,23 +554,86 @@ await GetTarStreamFromFS(containerId, $"{lndRoot}/data/chain/bitcoin/regtest/adm private async Task PutFile(string containerId, string filePath, Stream stream) { - await _dockerClient.Containers.ExtractArchiveToContainerAsync(containerId, new ContainerPathStatParameters - { - // AllowOverwriteDirWithFile = true, - Path = filePath - }, stream).ConfigureAwait(false); - + await _orchestrator.PutFileAsync(containerId, filePath, stream) + .ConfigureAwait(false); return true; } private async Task GetBytesFromFS(string containerId, string filePath) { - return GetBytesFromTar(await GetTarStreamFromFS(containerId, filePath).ConfigureAwait(false)); + // Retry until file exists (container may be starting up) + while (true) + { + try + { + return await _orchestrator.ExtractBinaryFileAsync(containerId, filePath) + .ConfigureAwait(false); + } + catch (Exception) + { + await Task.Delay(100).ConfigureAwait(false); + } + } } private async Task GetStringFromFS(string containerId, string filePath) { - return GetStringFromTar(await GetTarStreamFromFS(containerId, filePath).ConfigureAwait(false)); + // Retry until file exists (container may be starting up) + while (true) + { + try + { + return await _orchestrator.ExtractTextFileAsync(containerId, filePath) + .ConfigureAwait(false); + } + catch (Exception) + { + await Task.Delay(100).ConfigureAwait(false); + } + } + } + + /// + /// Waits for Bitcoin RPC to be ready by polling getblockchaininfo. + /// This is especially important in Kubernetes where startup is slower. + /// + private async Task WaitForBitcoinRpcReady(RPCClient rpcClient, int timeoutSeconds = 120) + { + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + var attemptCount = 0; + Exception? lastException = null; + + _logger?.LogInformation($"Waiting for Bitcoin RPC at {rpcClient.Address} to be ready (timeout: {timeoutSeconds}s)..."); + + while (DateTime.UtcNow - startTime < timeout) + { + try + { + attemptCount++; + // Try a simple RPC call to check if bitcoind is ready + await rpcClient.GetBlockchainInfoAsync().ConfigureAwait(false); + var elapsed = DateTime.UtcNow - startTime; + _logger?.LogInformation($"Bitcoin RPC is ready after {elapsed.TotalSeconds:F1}s ({attemptCount} attempts)"); + return; + } + catch (Exception ex) + { + lastException = ex; + // Log every 10 seconds to show progress + if (attemptCount % 10 == 0) + { + var elapsed = DateTime.UtcNow - startTime; + _logger?.LogWarning($"Bitcoin RPC not ready yet after {elapsed.TotalSeconds:F1}s ({attemptCount} attempts). Last error: {ex.Message}"); + } + // Not ready yet, wait and retry + await Task.Delay(1000).ConfigureAwait(false); + } + } + + var totalElapsed = DateTime.UtcNow - startTime; + _logger?.LogError($"Bitcoin RPC did not become ready within {timeoutSeconds}s. Total attempts: {attemptCount}. Last error: {lastException?.Message}"); + throw new TimeoutException($"Bitcoin RPC at {rpcClient.Address} did not become ready within {timeoutSeconds} seconds after {attemptCount} attempts. Last error: {lastException?.Message}", lastException); } private async Task ConnectPeers(LNDNodeConnection node, LNDNodeConnection remoteNode) @@ -619,19 +722,13 @@ private static byte[] GetBytesFromTar(GetArchiveFromContainerResponse tlsCertRes public async Task ShutdownByAlias(string alias, uint waitBeforeKillSeconds = 1, bool isLND = false) { if (isLND) LNDNodePool?.RemoveNode(await GetNodeFromAlias(alias)); - return await _dockerClient.Containers.StopContainerAsync(alias, new ContainerStopParameters - { - WaitBeforeKillSeconds = waitBeforeKillSeconds - }).ConfigureAwait(false); + return await _orchestrator.StopContainerAsync(alias, waitSeconds: waitBeforeKillSeconds).ConfigureAwait(false); } public async Task RestartByAlias(string alias, uint waitBeforeKillSeconds = 1, bool isLND = false, bool resetChannels = true, string lndRoot = "/home/lnd/.lnd") { - await _dockerClient.Containers.RestartContainerAsync(alias, new ContainerRestartParameters - { - WaitBeforeKillSeconds = waitBeforeKillSeconds - }).ConfigureAwait(false); + await _orchestrator.RestartContainerAsync(alias, waitSeconds: waitBeforeKillSeconds).ConfigureAwait(false); if (isLND) {