Добавлен модуль Kubernetes MCP с DI и диагностикой ошибок
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="KubernetesClient" Version="19.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using LazyBear.MCP.Services.Kubernetes;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.ComponentModel;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<K8sClientProvider>();
|
||||||
|
|
||||||
builder.Services.AddMcpServer()
|
builder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
.WithToolsFromAssembly();
|
.WithToolsFromAssembly();
|
||||||
@@ -12,19 +14,3 @@ var app = builder.Build();
|
|||||||
app.MapMcp();
|
app.MapMcp();
|
||||||
|
|
||||||
app.Run("http://localhost:5000");
|
app.Run("http://localhost:5000");
|
||||||
|
|
||||||
[McpServerToolType]
|
|
||||||
public static class TradingTools
|
|
||||||
{
|
|
||||||
[McpServerTool, Description("Получить текущую цену актива")]
|
|
||||||
public static string GetCurrentPrice([Description("Тикер актива")] string ticker)
|
|
||||||
{
|
|
||||||
return $"Цена {ticker}: 50000 USD (фейковые данные)";
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description("Получить информацию о позиции")]
|
|
||||||
public static string GetPositionInfo([Description("ID позиции")] string positionId)
|
|
||||||
{
|
|
||||||
return $"Позиция {positionId}: Long BTC, PnL: +500 USD";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
33
LazyBear.MCP/Services/Kubernetes/K8sClientFactory.cs
Normal file
33
LazyBear.MCP/Services/Kubernetes/K8sClientFactory.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using k8s;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
public static class K8sClientFactory
|
||||||
|
{
|
||||||
|
public static IKubernetes CreateClient(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var kubeconfigPath = configuration["Kubernetes:KubeconfigPath"];
|
||||||
|
KubernetesClientConfiguration clientConfiguration;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(kubeconfigPath))
|
||||||
|
{
|
||||||
|
var expandedPath = Environment.ExpandEnvironmentVariables(kubeconfigPath);
|
||||||
|
var fullPath = Path.GetFullPath(expandedPath);
|
||||||
|
|
||||||
|
clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile(fullPath);
|
||||||
|
return new global::k8s.Kubernetes(clientConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
clientConfiguration = KubernetesClientConfiguration.InClusterConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new global::k8s.Kubernetes(clientConfiguration);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs
Normal file
23
LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using k8s;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
public sealed class K8sClientProvider
|
||||||
|
{
|
||||||
|
public IKubernetes? Client { get; }
|
||||||
|
|
||||||
|
public string? InitializationError { get; }
|
||||||
|
|
||||||
|
public K8sClientProvider(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Client = K8sClientFactory.CreateClient(configuration);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs
Normal file
205
LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using k8s;
|
||||||
|
using k8s.Autorest;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly IKubernetes? _client = clientProvider.Client;
|
||||||
|
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
||||||
|
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список ConfigMap в namespace")]
|
||||||
|
public async Task<string> ListConfigMaps(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (configMaps.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' configMap не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = configMaps.Items.Select(cm =>
|
||||||
|
{
|
||||||
|
var name = cm.Metadata?.Name ?? "unknown";
|
||||||
|
var keyCount = cm.Data?.Count ?? 0;
|
||||||
|
return $"{name}: keys={keyCount}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"ConfigMap namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_config_maps", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Получить данные ConfigMap")]
|
||||||
|
public async Task<string> GetConfigMapData(
|
||||||
|
[Description("Имя ConfigMap")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (configMap.Data is null || configMap.Data.Count == 0)
|
||||||
|
{
|
||||||
|
return $"ConfigMap '{name}' в namespace '{ns}' не содержит данных.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = configMap.Data.Select(item => $"{item.Key}={item.Value}");
|
||||||
|
return $"Данные ConfigMap '{name}' namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_config_map_data", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список Secret в namespace (без значений)")]
|
||||||
|
public async Task<string> ListSecrets(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secrets = await client.CoreV1.ListNamespacedSecretAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (secrets.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' secret не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = secrets.Items.Select(secret =>
|
||||||
|
{
|
||||||
|
var name = secret.Metadata?.Name ?? "unknown";
|
||||||
|
var type = secret.Type ?? "Opaque";
|
||||||
|
return $"{name}: type={type}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Secret namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_secrets", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Ключи Secret без значений")]
|
||||||
|
public async Task<string> GetSecretKeys(
|
||||||
|
[Description("Имя Secret")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secret = await client.CoreV1.ReadNamespacedSecretAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (secret.Data is not null)
|
||||||
|
{
|
||||||
|
foreach (var key in secret.Data.Keys)
|
||||||
|
{
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret.StringData is not null)
|
||||||
|
{
|
||||||
|
foreach (var key in secret.StringData.Keys)
|
||||||
|
{
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.Count == 0)
|
||||||
|
{
|
||||||
|
return $"Secret '{name}' в namespace '{ns}' не содержит ключей.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ключи Secret '{name}' namespace '{ns}': {string.Join(", ", keys.OrderBy(k => k))}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_secret_keys", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveNamespace(string? @namespace)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildClientInitializationError()
|
||||||
|
{
|
||||||
|
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetClient(out IKubernetes client, out string error)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
||||||
|
? string.Empty
|
||||||
|
: $" Детали: {_clientInitializationError}";
|
||||||
|
error = BuildClientInitializationError() + details;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = _client;
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
||||||
|
{
|
||||||
|
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
||||||
|
|
||||||
|
if (exception is HttpOperationException httpException)
|
||||||
|
{
|
||||||
|
var statusCode = httpException.Response?.StatusCode;
|
||||||
|
var reason = httpException.Response?.ReasonPhrase;
|
||||||
|
var body = string.IsNullOrWhiteSpace(httpException.Response?.Content)
|
||||||
|
? "-"
|
||||||
|
: httpException.Response!.Content;
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
199
LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs
Normal file
199
LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using k8s;
|
||||||
|
using k8s.Autorest;
|
||||||
|
using k8s.Models;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly IKubernetes? _client = clientProvider.Client;
|
||||||
|
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
||||||
|
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список deployment в namespace")]
|
||||||
|
public async Task<string> ListDeployments(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deployments = await client.AppsV1.ListNamespacedDeploymentAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (deployments.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' deployment не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = deployments.Items.Select(dep =>
|
||||||
|
{
|
||||||
|
var name = dep.Metadata?.Name ?? "unknown";
|
||||||
|
var desired = dep.Spec?.Replicas ?? 0;
|
||||||
|
var ready = dep.Status?.ReadyReplicas ?? 0;
|
||||||
|
var available = dep.Status?.AvailableReplicas ?? 0;
|
||||||
|
return $"{name}: desired={desired}, ready={ready}, available={available}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Deployment namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_deployments", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Масштабировать deployment")]
|
||||||
|
public async Task<string> ScaleDeployment(
|
||||||
|
[Description("Имя deployment")] string name,
|
||||||
|
[Description("Новое количество реплик")] int replicas,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
deployment.Spec ??= new V1DeploymentSpec();
|
||||||
|
deployment.Spec.Replicas = replicas;
|
||||||
|
|
||||||
|
await client.AppsV1.ReplaceNamespacedDeploymentAsync(deployment, name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return $"Deployment '{name}' в namespace '{ns}' масштабирован до {replicas} реплик.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("scale_deployment", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Статус rollout deployment")]
|
||||||
|
public async Task<string> GetRolloutStatus(
|
||||||
|
[Description("Имя deployment")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deployment = await client.AppsV1.ReadNamespacedDeploymentStatusAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var desired = deployment.Spec?.Replicas ?? 0;
|
||||||
|
var updated = deployment.Status?.UpdatedReplicas ?? 0;
|
||||||
|
var ready = deployment.Status?.ReadyReplicas ?? 0;
|
||||||
|
var available = deployment.Status?.AvailableReplicas ?? 0;
|
||||||
|
|
||||||
|
return $"Rollout '{name}' в namespace '{ns}': desired={desired}, updated={updated}, ready={ready}, available={available}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_rollout_status", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Перезапустить deployment (rolling restart)")]
|
||||||
|
public async Task<string> RestartDeployment(
|
||||||
|
[Description("Имя deployment")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var patchJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
spec = new
|
||||||
|
{
|
||||||
|
template = new
|
||||||
|
{
|
||||||
|
metadata = new
|
||||||
|
{
|
||||||
|
annotations = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["kubectl.kubernetes.io/restartedAt"] = DateTimeOffset.UtcNow.ToString("O")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var patch = new V1Patch(patchJson, V1Patch.PatchType.StrategicMergePatch);
|
||||||
|
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return $"Deployment '{name}' в namespace '{ns}' отправлен на rolling restart.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("restart_deployment", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveNamespace(string? @namespace)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildClientInitializationError()
|
||||||
|
{
|
||||||
|
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetClient(out IKubernetes client, out string error)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
||||||
|
? string.Empty
|
||||||
|
: $" Детали: {_clientInitializationError}";
|
||||||
|
error = BuildClientInitializationError() + details;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = _client;
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
||||||
|
{
|
||||||
|
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
||||||
|
|
||||||
|
if (exception is HttpOperationException httpException)
|
||||||
|
{
|
||||||
|
var statusCode = httpException.Response?.StatusCode;
|
||||||
|
var reason = httpException.Response?.ReasonPhrase;
|
||||||
|
var body = string.IsNullOrWhiteSpace(httpException.Response?.Content)
|
||||||
|
? "-"
|
||||||
|
: httpException.Response!.Content;
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
184
LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs
Normal file
184
LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using k8s;
|
||||||
|
using k8s.Autorest;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly IKubernetes? _client = clientProvider.Client;
|
||||||
|
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
||||||
|
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список service в namespace")]
|
||||||
|
public async Task<string> ListServices(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var services = await client.CoreV1.ListNamespacedServiceAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (services.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' service не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = services.Items.Select(svc =>
|
||||||
|
{
|
||||||
|
var name = svc.Metadata?.Name ?? "unknown";
|
||||||
|
var type = svc.Spec?.Type ?? "ClusterIP";
|
||||||
|
var clusterIp = svc.Spec?.ClusterIP ?? "-";
|
||||||
|
var ports = svc.Spec?.Ports is null
|
||||||
|
? "-"
|
||||||
|
: string.Join(",", svc.Spec.Ports.Select(p => $"{p.Port}/{p.Protocol}"));
|
||||||
|
|
||||||
|
return $"{name}: type={type}, clusterIp={clusterIp}, ports={ports}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Services namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_services", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Детали service")]
|
||||||
|
public async Task<string> GetServiceDetails(
|
||||||
|
[Description("Имя service")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = await client.CoreV1.ReadNamespacedServiceAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var type = service.Spec?.Type ?? "ClusterIP";
|
||||||
|
var clusterIp = service.Spec?.ClusterIP ?? "-";
|
||||||
|
var externalIps = service.Spec?.ExternalIPs is null ? "-" : string.Join(",", service.Spec.ExternalIPs);
|
||||||
|
var selector = service.Spec?.Selector is null
|
||||||
|
? "-"
|
||||||
|
: string.Join(",", service.Spec.Selector.Select(x => $"{x.Key}={x.Value}"));
|
||||||
|
var ports = service.Spec?.Ports is null
|
||||||
|
? "-"
|
||||||
|
: string.Join(",", service.Spec.Ports.Select(p => $"{p.Name ?? "port"}:{p.Port}->{p.TargetPort}"));
|
||||||
|
|
||||||
|
return $"Service '{name}' namespace '{ns}': type={type}, clusterIp={clusterIp}, externalIps={externalIps}, selector={selector}, ports={ports}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_service_details", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список ingress в namespace")]
|
||||||
|
public async Task<string> ListIngresses(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (ingresses.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' ingress не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = ingresses.Items.Select(ing =>
|
||||||
|
{
|
||||||
|
var name = ing.Metadata?.Name ?? "unknown";
|
||||||
|
var rules = ing.Spec?.Rules is null
|
||||||
|
? "-"
|
||||||
|
: string.Join(";", ing.Spec.Rules.Select(r =>
|
||||||
|
{
|
||||||
|
var host = string.IsNullOrWhiteSpace(r.Host) ? "*" : r.Host;
|
||||||
|
var paths = r.Http?.Paths is null
|
||||||
|
? "-"
|
||||||
|
: string.Join(",", r.Http.Paths.Select(p =>
|
||||||
|
{
|
||||||
|
var serviceName = p.Backend?.Service?.Name ?? "-";
|
||||||
|
var servicePort = p.Backend?.Service?.Port?.Number?.ToString() ?? p.Backend?.Service?.Port?.Name ?? "-";
|
||||||
|
return $"{p.Path ?? "/"}->{serviceName}:{servicePort}";
|
||||||
|
}));
|
||||||
|
|
||||||
|
return $"{host}:{paths}";
|
||||||
|
}));
|
||||||
|
|
||||||
|
return $"{name}: rules={rules}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Ingress namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_ingresses", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveNamespace(string? @namespace)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildClientInitializationError()
|
||||||
|
{
|
||||||
|
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetClient(out IKubernetes client, out string error)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
||||||
|
? string.Empty
|
||||||
|
: $" Детали: {_clientInitializationError}";
|
||||||
|
error = BuildClientInitializationError() + details;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = _client;
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
||||||
|
{
|
||||||
|
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
||||||
|
|
||||||
|
if (exception is HttpOperationException httpException)
|
||||||
|
{
|
||||||
|
var statusCode = httpException.Response?.StatusCode;
|
||||||
|
var reason = httpException.Response?.ReasonPhrase;
|
||||||
|
var body = string.IsNullOrWhiteSpace(httpException.Response?.Content)
|
||||||
|
? "-"
|
||||||
|
: httpException.Response!.Content;
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
166
LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs
Normal file
166
LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using k8s;
|
||||||
|
using k8s.Autorest;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly IKubernetes? _client = clientProvider.Client;
|
||||||
|
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
||||||
|
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
||||||
|
|
||||||
|
[McpServerTool, Description("Список подов в namespace")]
|
||||||
|
public async Task<string> ListPods(
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pods = await client.CoreV1.ListNamespacedPodAsync(ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (pods.Items.Count == 0)
|
||||||
|
{
|
||||||
|
return $"В namespace '{ns}' поды не найдены.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = pods.Items.Select(pod =>
|
||||||
|
{
|
||||||
|
var podName = pod.Metadata?.Name ?? "unknown";
|
||||||
|
var phase = pod.Status?.Phase ?? "Unknown";
|
||||||
|
var node = pod.Spec?.NodeName ?? "-";
|
||||||
|
var restarts = pod.Status?.ContainerStatuses?.Sum(s => s.RestartCount) ?? 0;
|
||||||
|
return $"{podName}: phase={phase}, restarts={restarts}, node={node}";
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"Поды namespace '{ns}':\n{string.Join('\n', lines)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("list_pods", ns, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Статус pod по имени")]
|
||||||
|
public async Task<string> GetPodStatus(
|
||||||
|
[Description("Имя pod")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pod = await client.CoreV1.ReadNamespacedPodAsync(name, ns, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var phase = pod.Status?.Phase ?? "Unknown";
|
||||||
|
var hostIp = pod.Status?.HostIP ?? "-";
|
||||||
|
var podIp = pod.Status?.PodIP ?? "-";
|
||||||
|
var conditions = pod.Status?.Conditions?.Select(c => $"{c.Type}={c.Status}") ?? [];
|
||||||
|
|
||||||
|
return $"Pod '{name}' в namespace '{ns}': phase={phase}, hostIp={hostIp}, podIp={podIp}, conditions=[{string.Join(", ", conditions)}]";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_pod_status", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Логи pod")]
|
||||||
|
public async Task<string> GetPodLogs(
|
||||||
|
[Description("Имя pod")] string name,
|
||||||
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
|
[Description("Имя контейнера")]
|
||||||
|
string? container = null,
|
||||||
|
[Description("Количество строк с конца")]
|
||||||
|
int? tailLines = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ns = ResolveNamespace(@namespace);
|
||||||
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
|
{
|
||||||
|
return clientError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var logStream = await client.CoreV1.ReadNamespacedPodLogAsync(
|
||||||
|
name,
|
||||||
|
ns,
|
||||||
|
container: container,
|
||||||
|
tailLines: tailLines,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(logStream);
|
||||||
|
var logs = await reader.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(logs))
|
||||||
|
{
|
||||||
|
return $"Логи pod '{name}' в namespace '{ns}' пустые.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return FormatError("get_pod_logs", ns, ex, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveNamespace(string? @namespace)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildClientInitializationError()
|
||||||
|
{
|
||||||
|
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetClient(out IKubernetes client, out string error)
|
||||||
|
{
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
||||||
|
? string.Empty
|
||||||
|
: $" Детали: {_clientInitializationError}";
|
||||||
|
error = BuildClientInitializationError() + details;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = _client;
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
||||||
|
{
|
||||||
|
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
||||||
|
|
||||||
|
if (exception is HttpOperationException httpException)
|
||||||
|
{
|
||||||
|
var statusCode = httpException.Response?.StatusCode;
|
||||||
|
var reason = httpException.Response?.ReasonPhrase;
|
||||||
|
var body = string.IsNullOrWhiteSpace(httpException.Response?.Content)
|
||||||
|
? "-"
|
||||||
|
: httpException.Response!.Content;
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"Kubernetes": {
|
||||||
|
"KubeconfigPath": "",
|
||||||
|
"DefaultNamespace": "default"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
Reference in New Issue
Block a user