2023-03-26 06:52:53 +00:00
|
|
|
|
using CodexDistTestCore.Config;
|
2023-04-10 12:00:12 +00:00
|
|
|
|
using CodexDistTestCore.Marketplace;
|
2023-04-10 07:05:27 +00:00
|
|
|
|
using CodexDistTestCore.Metrics;
|
2023-03-26 06:52:53 +00:00
|
|
|
|
using k8s;
|
2023-03-21 15:09:41 +00:00
|
|
|
|
using k8s.Models;
|
|
|
|
|
using NUnit.Framework;
|
2023-04-10 12:48:16 +00:00
|
|
|
|
using System.Numerics;
|
2023-03-21 15:09:41 +00:00
|
|
|
|
|
|
|
|
|
namespace CodexDistTestCore
|
|
|
|
|
{
|
|
|
|
|
public class K8sOperations
|
|
|
|
|
{
|
|
|
|
|
private readonly CodexDockerImage dockerImage = new CodexDockerImage();
|
2023-03-26 07:41:46 +00:00
|
|
|
|
private readonly K8sCluster k8sCluster = new K8sCluster();
|
2023-03-21 15:09:41 +00:00
|
|
|
|
private readonly Kubernetes client;
|
|
|
|
|
private readonly KnownK8sPods knownPods;
|
|
|
|
|
|
|
|
|
|
public K8sOperations(KnownK8sPods knownPods)
|
|
|
|
|
{
|
|
|
|
|
this.knownPods = knownPods;
|
|
|
|
|
|
2023-03-26 07:41:46 +00:00
|
|
|
|
client = new Kubernetes(k8sCluster.GetK8sClientConfig());
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-03-23 11:35:03 +00:00
|
|
|
|
public void Close()
|
|
|
|
|
{
|
|
|
|
|
client.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
public void BringOnline(CodexNodeGroup online, OfflineCodexNodes offline)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
EnsureTestNamespace();
|
|
|
|
|
|
|
|
|
|
CreateDeployment(online, offline);
|
|
|
|
|
CreateService(online);
|
|
|
|
|
|
|
|
|
|
WaitUntilOnline(online);
|
2023-03-22 13:49:01 +00:00
|
|
|
|
FetchPodInfo(online);
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
public void BringOffline(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
var deploymentName = online.Deployment.Name();
|
|
|
|
|
DeleteDeployment(online);
|
|
|
|
|
DeleteService(online);
|
|
|
|
|
WaitUntilOffline(deploymentName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void DeleteAllResources()
|
|
|
|
|
{
|
|
|
|
|
DeleteNamespace();
|
|
|
|
|
|
|
|
|
|
WaitUntilZeroPods();
|
|
|
|
|
WaitUntilNamespaceDeleted();
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-26 08:45:01 +00:00
|
|
|
|
public void FetchPodLog(OnlineCodexNode node, IPodLogHandler logHandler)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
2023-03-26 08:45:01 +00:00
|
|
|
|
var stream = client.ReadNamespacedPodLog(node.Group.PodInfo!.Name, K8sNamespace, node.Container.Name);
|
|
|
|
|
logHandler.Log(stream);
|
2023-03-22 12:52:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-04-10 08:09:41 +00:00
|
|
|
|
public string ExecuteCommand(PodInfo pod, string containerName, string command, params string[] arguments)
|
|
|
|
|
{
|
|
|
|
|
var runner = new CommandRunner(client, pod, containerName, command, arguments);
|
|
|
|
|
runner.Run();
|
|
|
|
|
return runner.GetStdOut();
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-27 14:24:04 +00:00
|
|
|
|
public PrometheusInfo BringOnlinePrometheus(K8sPrometheusSpecs spec)
|
2023-03-27 12:49:34 +00:00
|
|
|
|
{
|
|
|
|
|
EnsureTestNamespace();
|
|
|
|
|
|
|
|
|
|
CreatePrometheusDeployment(spec);
|
2023-03-27 14:24:04 +00:00
|
|
|
|
CreatePrometheusService(spec);
|
2023-03-27 12:49:34 +00:00
|
|
|
|
WaitUntilPrometheusOnline(spec);
|
|
|
|
|
|
2023-03-27 14:24:04 +00:00
|
|
|
|
return new PrometheusInfo(spec.ServicePort, FetchNewPod());
|
2023-03-27 12:49:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-04-10 12:48:16 +00:00
|
|
|
|
public GethInfo BringOnlineGethBootstrapNode(K8sGethBoostrapSpecs spec)
|
2023-04-10 08:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
EnsureTestNamespace();
|
|
|
|
|
|
2023-04-10 12:00:12 +00:00
|
|
|
|
CreateGethBootstrapDeployment(spec);
|
|
|
|
|
CreateGethBootstrapService(spec);
|
|
|
|
|
WaitUntilGethBootstrapOnline(spec);
|
2023-04-10 08:09:41 +00:00
|
|
|
|
|
2023-04-10 12:48:16 +00:00
|
|
|
|
return new GethInfo(spec, FetchNewPod());
|
2023-04-10 08:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 13:49:01 +00:00
|
|
|
|
private void FetchPodInfo(CodexNodeGroup online)
|
2023-03-27 12:49:34 +00:00
|
|
|
|
{
|
|
|
|
|
online.PodInfo = FetchNewPod();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private PodInfo FetchNewPod()
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
2023-03-22 13:49:01 +00:00
|
|
|
|
var pods = client.ListNamespacedPod(K8sNamespace).Items;
|
2023-03-22 12:52:01 +00:00
|
|
|
|
|
2023-03-22 13:49:01 +00:00
|
|
|
|
var newPods = pods.Where(p => !knownPods.Contains(p.Name())).ToArray();
|
|
|
|
|
Assert.That(newPods.Length, Is.EqualTo(1), "Expected only 1 pod to be created. Test infra failure.");
|
2023-03-22 12:52:01 +00:00
|
|
|
|
|
2023-03-22 13:49:01 +00:00
|
|
|
|
var newPod = newPods.Single();
|
2023-03-27 12:49:34 +00:00
|
|
|
|
var info = new PodInfo(newPod.Name(), newPod.Status.PodIP);
|
2023-03-22 13:49:01 +00:00
|
|
|
|
|
2023-03-27 12:49:34 +00:00
|
|
|
|
Assert.That(!string.IsNullOrEmpty(info.Name), "Invalid pod name received. Test infra failure.");
|
|
|
|
|
Assert.That(!string.IsNullOrEmpty(info.Ip), "Invalid pod IP received. Test infra failure.");
|
2023-03-22 13:49:01 +00:00
|
|
|
|
|
|
|
|
|
knownPods.Add(newPod.Name());
|
2023-03-27 12:49:34 +00:00
|
|
|
|
return info;
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region Waiting
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private void WaitUntilOnline(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
WaitUntil(() =>
|
|
|
|
|
{
|
|
|
|
|
online.Deployment = client.ReadNamespacedDeployment(online.Deployment.Name(), K8sNamespace);
|
|
|
|
|
return online.Deployment?.Status.AvailableReplicas != null && online.Deployment.Status.AvailableReplicas > 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void WaitUntilOffline(string deploymentName)
|
|
|
|
|
{
|
|
|
|
|
WaitUntil(() =>
|
|
|
|
|
{
|
|
|
|
|
var deployment = client.ReadNamespacedDeployment(deploymentName, K8sNamespace);
|
|
|
|
|
return deployment == null || deployment.Status.AvailableReplicas == 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void WaitUntilZeroPods()
|
|
|
|
|
{
|
|
|
|
|
WaitUntil(() => !client.ListNamespacedPod(K8sNamespace).Items.Any());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void WaitUntilNamespaceDeleted()
|
|
|
|
|
{
|
|
|
|
|
WaitUntil(() => !IsTestNamespaceOnline());
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-27 12:49:34 +00:00
|
|
|
|
private void WaitUntilPrometheusOnline(K8sPrometheusSpecs spec)
|
|
|
|
|
{
|
2023-04-10 12:00:12 +00:00
|
|
|
|
WaitUntilDeploymentOnline(spec.GetDeploymentName());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void WaitUntilGethBootstrapOnline(K8sGethBoostrapSpecs spec)
|
|
|
|
|
{
|
|
|
|
|
WaitUntilDeploymentOnline(spec.GetDeploymentName());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void WaitUntilDeploymentOnline(string deploymentName)
|
|
|
|
|
{
|
2023-03-27 12:49:34 +00:00
|
|
|
|
WaitUntil(() =>
|
|
|
|
|
{
|
|
|
|
|
var deployment = client.ReadNamespacedDeployment(deploymentName, K8sNamespace);
|
|
|
|
|
return deployment?.Status.AvailableReplicas != null && deployment.Status.AvailableReplicas > 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-21 15:09:41 +00:00
|
|
|
|
private void WaitUntil(Func<bool> predicate)
|
|
|
|
|
{
|
|
|
|
|
var start = DateTime.UtcNow;
|
|
|
|
|
var state = predicate();
|
|
|
|
|
while (!state)
|
|
|
|
|
{
|
|
|
|
|
if (DateTime.UtcNow - start > Timing.K8sOperationTimeout())
|
|
|
|
|
{
|
|
|
|
|
Assert.Fail("K8s operation timed out.");
|
|
|
|
|
throw new TimeoutException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Timing.WaitForK8sServiceDelay();
|
|
|
|
|
state = predicate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Service management
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private void CreateService(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
var serviceSpec = new V1Service
|
|
|
|
|
{
|
|
|
|
|
ApiVersion = "v1",
|
|
|
|
|
Metadata = online.GetServiceMetadata(),
|
|
|
|
|
Spec = new V1ServiceSpec
|
|
|
|
|
{
|
|
|
|
|
Type = "NodePort",
|
|
|
|
|
Selector = online.GetSelector(),
|
|
|
|
|
Ports = CreateServicePorts(online)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
online.Service = client.CreateNamespacedService(serviceSpec, K8sNamespace);
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private List<V1ServicePort> CreateServicePorts(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
var result = new List<V1ServicePort>();
|
|
|
|
|
var containers = online.GetContainers();
|
|
|
|
|
foreach (var container in containers)
|
|
|
|
|
{
|
|
|
|
|
result.Add(new V1ServicePort
|
|
|
|
|
{
|
2023-03-22 09:38:10 +00:00
|
|
|
|
Name = container.ServicePortName,
|
2023-03-21 15:09:41 +00:00
|
|
|
|
Protocol = "TCP",
|
2023-03-22 09:38:10 +00:00
|
|
|
|
Port = container.ApiPort,
|
2023-03-21 15:09:41 +00:00
|
|
|
|
TargetPort = container.ContainerPortName,
|
|
|
|
|
NodePort = container.ServicePort
|
|
|
|
|
});
|
2023-04-10 12:48:16 +00:00
|
|
|
|
|
|
|
|
|
if (container.GethCompanionNodeContainer != null)
|
|
|
|
|
{
|
|
|
|
|
result.Add(container.GethCompanionNodeContainer.CreateServicePort());
|
|
|
|
|
}
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private void DeleteService(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
if (online.Service == null) return;
|
|
|
|
|
client.DeleteNamespacedService(online.Service.Name(), K8sNamespace);
|
|
|
|
|
online.Service = null;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-27 14:24:04 +00:00
|
|
|
|
private void CreatePrometheusService(K8sPrometheusSpecs spec)
|
2023-03-27 12:49:34 +00:00
|
|
|
|
{
|
2023-03-27 14:24:04 +00:00
|
|
|
|
client.CreateNamespacedService(spec.CreatePrometheusService(), K8sNamespace);
|
2023-03-27 12:49:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-04-10 12:00:12 +00:00
|
|
|
|
private void CreateGethBootstrapService(K8sGethBoostrapSpecs spec)
|
|
|
|
|
{
|
|
|
|
|
client.CreateNamespacedService(spec.CreateGethBootstrapService(), K8sNamespace);
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-21 15:09:41 +00:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Deployment management
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private void CreateDeployment(CodexNodeGroup online, OfflineCodexNodes offline)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
var deploymentSpec = new V1Deployment
|
|
|
|
|
{
|
|
|
|
|
ApiVersion = "apps/v1",
|
|
|
|
|
Metadata = online.GetDeploymentMetadata(),
|
|
|
|
|
Spec = new V1DeploymentSpec
|
|
|
|
|
{
|
|
|
|
|
Replicas = 1,
|
|
|
|
|
Selector = new V1LabelSelector
|
|
|
|
|
{
|
|
|
|
|
MatchLabels = online.GetSelector()
|
|
|
|
|
},
|
|
|
|
|
Template = new V1PodTemplateSpec
|
|
|
|
|
{
|
|
|
|
|
Metadata = new V1ObjectMeta
|
|
|
|
|
{
|
|
|
|
|
Labels = online.GetSelector()
|
|
|
|
|
},
|
|
|
|
|
Spec = new V1PodSpec
|
|
|
|
|
{
|
2023-03-24 13:16:59 +00:00
|
|
|
|
NodeSelector = CreateNodeSelector(offline),
|
2023-03-21 15:09:41 +00:00
|
|
|
|
Containers = CreateDeploymentContainers(online, offline)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
online.Deployment = client.CreateNamespacedDeployment(deploymentSpec, K8sNamespace);
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-24 13:16:59 +00:00
|
|
|
|
private IDictionary<string, string> CreateNodeSelector(OfflineCodexNodes offline)
|
|
|
|
|
{
|
2023-03-26 06:52:53 +00:00
|
|
|
|
if (offline.Location == Location.Unspecified) return new Dictionary<string, string>();
|
2023-03-24 13:16:59 +00:00
|
|
|
|
|
2023-03-26 06:52:53 +00:00
|
|
|
|
return new Dictionary<string, string>
|
|
|
|
|
{
|
2023-03-26 07:41:46 +00:00
|
|
|
|
{ "codex-test-location", k8sCluster.GetNodeLabelForLocation(offline.Location) }
|
2023-03-26 06:52:53 +00:00
|
|
|
|
};
|
2023-03-24 13:16:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private List<V1Container> CreateDeploymentContainers(CodexNodeGroup online, OfflineCodexNodes offline)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
var result = new List<V1Container>();
|
|
|
|
|
var containers = online.GetContainers();
|
|
|
|
|
foreach (var container in containers)
|
|
|
|
|
{
|
|
|
|
|
result.Add(new V1Container
|
|
|
|
|
{
|
|
|
|
|
Name = container.Name,
|
|
|
|
|
Image = dockerImage.GetImageTag(),
|
|
|
|
|
Ports = new List<V1ContainerPort>
|
|
|
|
|
{
|
|
|
|
|
new V1ContainerPort
|
|
|
|
|
{
|
|
|
|
|
ContainerPort = container.ApiPort,
|
|
|
|
|
Name = container.ContainerPortName
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Env = dockerImage.CreateEnvironmentVariables(offline, container)
|
|
|
|
|
});
|
2023-04-10 12:48:16 +00:00
|
|
|
|
|
|
|
|
|
if (container.GethCompanionNodeContainer != null)
|
|
|
|
|
{
|
|
|
|
|
result.Add(container.GethCompanionNodeContainer.CreateDeploymentContainer(online.GethInfo!));
|
|
|
|
|
}
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
2023-03-27 12:49:34 +00:00
|
|
|
|
|
2023-03-21 15:09:41 +00:00
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-22 08:22:18 +00:00
|
|
|
|
private void DeleteDeployment(CodexNodeGroup online)
|
2023-03-21 15:09:41 +00:00
|
|
|
|
{
|
|
|
|
|
if (online.Deployment == null) return;
|
|
|
|
|
client.DeleteNamespacedDeployment(online.Deployment.Name(), K8sNamespace);
|
|
|
|
|
online.Deployment = null;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-27 12:49:34 +00:00
|
|
|
|
private void CreatePrometheusDeployment(K8sPrometheusSpecs spec)
|
|
|
|
|
{
|
|
|
|
|
client.CreateNamespacedDeployment(spec.CreatePrometheusDeployment(), K8sNamespace);
|
|
|
|
|
}
|
|
|
|
|
|
2023-04-10 12:00:12 +00:00
|
|
|
|
private void CreateGethBootstrapDeployment(K8sGethBoostrapSpecs spec)
|
|
|
|
|
{
|
|
|
|
|
client.CreateNamespacedDeployment(spec.CreateGethBootstrapDeployment(), K8sNamespace);
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-21 15:09:41 +00:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Namespace management
|
|
|
|
|
|
|
|
|
|
private void EnsureTestNamespace()
|
|
|
|
|
{
|
|
|
|
|
if (IsTestNamespaceOnline()) return;
|
|
|
|
|
|
|
|
|
|
var namespaceSpec = new V1Namespace
|
|
|
|
|
{
|
|
|
|
|
ApiVersion = "v1",
|
|
|
|
|
Metadata = new V1ObjectMeta
|
|
|
|
|
{
|
|
|
|
|
Name = K8sNamespace,
|
|
|
|
|
Labels = new Dictionary<string, string> { { "name", K8sNamespace } }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
client.CreateNamespace(namespaceSpec);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void DeleteNamespace()
|
|
|
|
|
{
|
|
|
|
|
if (IsTestNamespaceOnline())
|
|
|
|
|
{
|
|
|
|
|
client.DeleteNamespace(K8sNamespace, null, null, gracePeriodSeconds: 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-26 06:52:53 +00:00
|
|
|
|
private string K8sNamespace
|
|
|
|
|
{
|
|
|
|
|
get { return K8sCluster.K8sNamespace; }
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-21 15:09:41 +00:00
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
private bool IsTestNamespaceOnline()
|
|
|
|
|
{
|
|
|
|
|
return client.ListNamespace().Items.Any(n => n.Metadata.Name == K8sNamespace);
|
|
|
|
|
}
|
2023-04-10 08:09:41 +00:00
|
|
|
|
|
|
|
|
|
private class CommandRunner
|
|
|
|
|
{
|
|
|
|
|
private readonly Kubernetes client;
|
|
|
|
|
private readonly PodInfo pod;
|
|
|
|
|
private readonly string containerName;
|
|
|
|
|
private readonly string command;
|
|
|
|
|
private readonly string[] arguments;
|
|
|
|
|
private readonly List<string> lines = new List<string>();
|
|
|
|
|
|
|
|
|
|
public CommandRunner(Kubernetes client, PodInfo pod, string containerName, string command, string[] arguments)
|
|
|
|
|
{
|
|
|
|
|
this.client = client;
|
|
|
|
|
this.pod = pod;
|
|
|
|
|
this.containerName = containerName;
|
|
|
|
|
this.command = command;
|
|
|
|
|
this.arguments = arguments;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Run()
|
|
|
|
|
{
|
|
|
|
|
var input = new[] { command }.Concat(arguments).ToArray();
|
|
|
|
|
|
|
|
|
|
Utils.Wait(client.NamespacedPodExecAsync(
|
|
|
|
|
pod.Name, K8sCluster.K8sNamespace, containerName, input, false, Callback, new CancellationToken()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string GetStdOut()
|
|
|
|
|
{
|
|
|
|
|
return string.Join(Environment.NewLine, lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task Callback(Stream stdIn, Stream stdOut, Stream stdErr)
|
|
|
|
|
{
|
|
|
|
|
using var streamReader = new StreamReader(stdOut);
|
|
|
|
|
var line = streamReader.ReadLine();
|
|
|
|
|
while (line != null)
|
|
|
|
|
{
|
|
|
|
|
lines.Add(line);
|
|
|
|
|
line = streamReader.ReadLine();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-03-21 15:09:41 +00:00
|
|
|
|
}
|
|
|
|
|
}
|