From 57a659d3d70b5b761e1a71110e50f2f91ccb0f13 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 11 Feb 2026 21:14:47 +0800 Subject: [PATCH 1/2] feat: add third-party HTTP container provider Add new provider type for HTTP-based external container management with: - Generic protocol abstraction (single-parameter design) - V1 protocol with create/destroy operations - Bearer token authentication and SSL verification toggle - Support for both direct and proxy port mapping modes --- src/GZCTF/Models/Internal/Configs.cs | 19 ++- .../Container/ContainerServiceExtension.cs | 4 + .../Container/Manager/ThirdPartyManager.cs | 125 ++++++++++++++++++ .../Container/Provider/ThirdPartyProvider.cs | 64 +++++++++ .../Container/ThirdParty/IThirdPartyClient.cs | 25 ++++ .../Container/ThirdParty/ThirdPartyClient.cs | 75 +++++++++++ .../ThirdParty/ThirdPartyProtocol.cs | 68 ++++++++++ .../ThirdParty/ThirdPartyRequestException.cs | 16 +++ src/GZCTF/Services/Container/ThirdParty/V1.cs | 84 ++++++++++++ 9 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs create mode 100644 src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs create mode 100644 src/GZCTF/Services/Container/ThirdParty/IThirdPartyClient.cs create mode 100644 src/GZCTF/Services/Container/ThirdParty/ThirdPartyClient.cs create mode 100644 src/GZCTF/Services/Container/ThirdParty/ThirdPartyProtocol.cs create mode 100644 src/GZCTF/Services/Container/ThirdParty/ThirdPartyRequestException.cs create mode 100644 src/GZCTF/Services/Container/ThirdParty/V1.cs diff --git a/src/GZCTF/Models/Internal/Configs.cs b/src/GZCTF/Models/Internal/Configs.cs index df5b90b7b..d7a78ffc8 100644 --- a/src/GZCTF/Models/Internal/Configs.cs +++ b/src/GZCTF/Models/Internal/Configs.cs @@ -400,7 +400,8 @@ public class EmailConfig public enum ContainerProviderType { Docker, - Kubernetes + Kubernetes, + ThirdParty } [JsonConverter(typeof(JsonStringEnumConverter))] @@ -421,6 +422,7 @@ public class ContainerProvider public string PublicEntry { get; set; } = string.Empty; public KubernetesConfig? KubernetesConfig { get; set; } public DockerConfig? DockerConfig { get; set; } + public ThirdPartyConfig? ThirdPartyConfig { get; set; } } public class DockerConfig @@ -439,6 +441,21 @@ public class KubernetesConfig public string[]? Dns { get; set; } } +public class ThirdPartyConfig +{ + public string BaseUrl { get; set; } = string.Empty; + public string? ApiToken { get; set; } + public ThirdPartyApiVersion ApiVersion { get; set; } = ThirdPartyApiVersion.V1; + public int Timeout { get; set; } = 30; + public bool VerifyTls { get; set; } = true; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ThirdPartyApiVersion +{ + V1 +} + public class RegistrySet : Dictionary where T : class { diff --git a/src/GZCTF/Services/Container/ContainerServiceExtension.cs b/src/GZCTF/Services/Container/ContainerServiceExtension.cs index 0d4de83a6..7f81d9ef3 100644 --- a/src/GZCTF/Services/Container/ContainerServiceExtension.cs +++ b/src/GZCTF/Services/Container/ContainerServiceExtension.cs @@ -2,6 +2,7 @@ using GZCTF.Models.Internal; using GZCTF.Services.Container.Manager; using GZCTF.Services.Container.Provider; +using GZCTF.Services.Container.ThirdParty; using k8s; namespace GZCTF.Services.Container; @@ -44,6 +45,8 @@ private IServiceCollection AddProvider(ContainerProvider config) => .AddSingleton, DockerProvider>(), ContainerProviderType.Kubernetes => services .AddSingleton, KubernetesProvider>(), + ContainerProviderType.ThirdParty => services + .AddSingleton, ThirdPartyProvider>(), _ => services }; @@ -51,6 +54,7 @@ private IServiceCollection AddManager(ContainerProvider config) => config.Type switch { ContainerProviderType.Kubernetes => services.AddSingleton(), + ContainerProviderType.ThirdParty => services.AddSingleton(), _ => services.AddSingleton() }; } diff --git a/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs b/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs new file mode 100644 index 000000000..f5c0485cc --- /dev/null +++ b/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs @@ -0,0 +1,125 @@ +using GZCTF.Models.Internal; +using GZCTF.Services.Container.Provider; +using GZCTF.Services.Container.ThirdParty; +using ContainerStatus = GZCTF.Utils.ContainerStatus; + +namespace GZCTF.Services.Container.Manager; + +public class ThirdPartyManager : IContainerManager +{ + private readonly IThirdPartyClient _client; + private readonly ILogger _logger; + private readonly ThirdPartyMetadata _meta; + + public ThirdPartyManager(IContainerProvider provider, + ILogger logger) + { + _logger = logger; + _meta = provider.GetMetadata(); + _client = provider.GetProvider(); + + _logger.SystemLog("Third-party container manager enabled.", TaskStatus.Success, LogLevel.Debug); + } + + public async Task CreateContainerAsync(ContainerConfig config, + CancellationToken token = default) + { + var requestId = Guid.NewGuid().ToString("N"); + try + { + var payload = await _client.CreateAsync(config, requestId, token); + return BuildContainerFromResponse(config, payload); + } + catch (ThirdPartyRequestException e) + { + _logger.LogCreationFailedWithHttpContext(config.Image, e.StatusCode, e.Body); + return null; + } + catch (Exception e) + { + _logger.LogErrorMessage(e, + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerCreationFailed), config.Image]); + return null; + } + } + + public async Task DestroyContainerAsync(Models.Data.Container container, CancellationToken token = default) + { + try + { + var destroyed = await _client.DestroyAsync(container.ContainerId, token); + if (destroyed) + container.Status = ContainerStatus.Destroyed; + return; + } + catch (ThirdPartyRequestException e) + { + _logger.LogDeletionFailedWithHttpContext(container.LogId, e.StatusCode, e.Body); + return; + } + catch (Exception e) + { + _logger.LogErrorMessage(e, + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerDeletionFailed), + container.LogId]); + return; + } + } + + private Models.Data.Container? BuildContainerFromResponse(ContainerConfig config, + IThirdPartyCreateResponse? payload) + { + if (payload is null || string.IsNullOrWhiteSpace(payload.Id)) + { + _logger.SystemLog( + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerCreationFailed), config.Image], + TaskStatus.Failed, LogLevel.Warning); + return null; + } + + if (payload.State == ThirdPartyContainerState.Failed) + { + _logger.SystemLog( + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerCreationFailed), config.Image], + TaskStatus.Failed, LogLevel.Warning); + return null; + } + + if (payload.InternalAddress is null || string.IsNullOrWhiteSpace(payload.InternalAddress.Ip)) + { + _logger.SystemLog( + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerCreationFailed), config.Image], + TaskStatus.Failed, LogLevel.Warning); + return null; + } + + if (_meta.ExposePort && payload.ExternalAddress is null) + { + _logger.SystemLog( + StaticLocalizer[nameof(Resources.Program.ContainerManager_ContainerPortNotExposed), config.Image], + TaskStatus.Failed, LogLevel.Warning); + return null; + } + + var now = DateTimeOffset.UtcNow; + var container = new Models.Data.Container + { + ContainerId = payload.Id, + Image = config.Image, + IP = payload.InternalAddress.Ip, + Port = payload.InternalAddress.Port, + IsProxy = !_meta.ExposePort, + Status = ContainerStatus.Running, + StartedAt = payload.StartedAt ?? now, + ExpectStopAt = payload.ExpectStopAt ?? now + TimeSpan.FromHours(2) + }; + + if (payload.ExternalAddress is not null) + { + container.PublicIP = payload.ExternalAddress.Ip; + container.PublicPort = payload.ExternalAddress.Port; + } + + return container; + } +} diff --git a/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs b/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs new file mode 100644 index 000000000..1fa8e0ae2 --- /dev/null +++ b/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Headers; +using GZCTF.Models.Internal; +using GZCTF.Services.Container.ThirdParty; +using Microsoft.Extensions.Options; + +namespace GZCTF.Services.Container.Provider; + +public class ThirdPartyMetadata : ContainerProviderMetadata +{ + public ThirdPartyConfig Config { get; set; } = new(); +} + +public class ThirdPartyProvider : IContainerProvider +{ + private readonly HttpClient _client; + private readonly IThirdPartyClient _thirdPartyClient; + private readonly ThirdPartyMetadata _meta; + + public ThirdPartyProvider(IOptions options, ILogger logger) + { + var config = options.Value.ThirdPartyConfig ?? new(); + + if (string.IsNullOrWhiteSpace(config.BaseUrl)) + { + logger.SystemLog("Third-party container provider base URL is not configured.", + TaskStatus.Failed, LogLevel.Error); + throw new InvalidOperationException("Third-party container provider base URL is not configured."); + } + + _meta = new ThirdPartyMetadata + { + Config = config, + PortMappingType = options.Value.PortMappingType, + PublicEntry = options.Value.PublicEntry + }; + + var handler = new HttpClientHandler(); + if (!_meta.Config.VerifyTls) + handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + + _client = new HttpClient(handler) + { + BaseAddress = new Uri(_meta.Config.BaseUrl), + Timeout = TimeSpan.FromSeconds(Math.Max(1, _meta.Config.Timeout)) + }; + + if (!string.IsNullOrWhiteSpace(_meta.Config.ApiToken)) + _client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", _meta.Config.ApiToken); + + var apiVersion = _meta.Config.ApiVersion; + _thirdPartyClient = apiVersion switch + { + ThirdPartyApiVersion.V1 => new ThirdPartyClient(_client, _meta), + _ => new ThirdPartyClient(_client, _meta) + }; + + logger.SystemLog("Third-party container provider initialized.", TaskStatus.Success, LogLevel.Debug); + } + + public IThirdPartyClient GetProvider() => _thirdPartyClient; + + public ThirdPartyMetadata GetMetadata() => _meta; +} diff --git a/src/GZCTF/Services/Container/ThirdParty/IThirdPartyClient.cs b/src/GZCTF/Services/Container/ThirdParty/IThirdPartyClient.cs new file mode 100644 index 000000000..95c4b869b --- /dev/null +++ b/src/GZCTF/Services/Container/ThirdParty/IThirdPartyClient.cs @@ -0,0 +1,25 @@ +using GZCTF.Models.Internal; + +namespace GZCTF.Services.Container.ThirdParty; + +/// +/// Provides a normalized client for third-party container APIs. +/// +public interface IThirdPartyClient +{ + /// + /// Gets the API version in use. + /// + ThirdPartyApiVersion ApiVersion { get; } + + /// + /// Creates a container through the third-party API. + /// + Task CreateAsync(ContainerConfig config, string requestId, + CancellationToken token = default); + + /// + /// Destroys a container by id. + /// + Task DestroyAsync(string containerId, CancellationToken token = default); +} diff --git a/src/GZCTF/Services/Container/ThirdParty/ThirdPartyClient.cs b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyClient.cs new file mode 100644 index 000000000..1cb87836a --- /dev/null +++ b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyClient.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GZCTF.Models.Internal; +using GZCTF.Services.Container.Provider; + +namespace GZCTF.Services.Container.ThirdParty; + +public class ThirdPartyClient : IThirdPartyClient + where TProtocol : IThirdPartyProtocol, new() +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + private readonly HttpClient _client; + private readonly ThirdPartyMetadata _meta; + private readonly TProtocol _protocol; + + public ThirdPartyClient(HttpClient client, ThirdPartyMetadata meta) + { + _meta = meta; + _client = client; + _protocol = new TProtocol(); + + if (_meta.Config.ApiVersion != _protocol.Version) + throw new InvalidOperationException( + $"Third-party protocol version mismatch: config={_meta.Config.ApiVersion}, " + + $"protocol={_protocol.Version}."); + } + + public ThirdPartyApiVersion ApiVersion => _protocol.Version; + + public async Task CreateAsync(ContainerConfig config, string requestId, + CancellationToken token = default) + { + var request = _protocol.BuildCreateRequest(config); + var content = JsonSerializer.Serialize(request, JsonOptions); + + using var message = new HttpRequestMessage(HttpMethod.Post, _protocol.CreatePath) + { + Content = new StringContent(content, Encoding.UTF8, "application/json") + }; + + message.Headers.TryAddWithoutValidation(_protocol.RequestIdHeader, requestId); + + var response = await _client.SendAsync(message, token); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(token); + throw new ThirdPartyRequestException(response.StatusCode, body); + } + + var responseBody = await response.Content.ReadAsStringAsync(token); + return _protocol.DeserializeCreateResponse(responseBody, JsonOptions); + } + + public async Task DestroyAsync(string containerId, CancellationToken token = default) + { + var path = _protocol.DestroyPath.Replace("{id}", Uri.EscapeDataString(containerId)); + using var message = new HttpRequestMessage(HttpMethod.Delete, path); + + var response = await _client.SendAsync(message, token); + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound) + return true; + + var body = await response.Content.ReadAsStringAsync(token); + throw new ThirdPartyRequestException(response.StatusCode, body); + } + +} diff --git a/src/GZCTF/Services/Container/ThirdParty/ThirdPartyProtocol.cs b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyProtocol.cs new file mode 100644 index 000000000..32ec66581 --- /dev/null +++ b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyProtocol.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GZCTF.Models.Internal; + +namespace GZCTF.Services.Container.ThirdParty; + +/// +/// Defines a third-party API contract for a specific version. +/// +public interface IThirdPartyProtocol +{ + /// + /// Gets the API version implemented by this protocol. + /// + ThirdPartyApiVersion Version { get; } + + /// + /// Gets the idempotency request id header name. + /// + string RequestIdHeader { get; } + + /// + /// Gets the API path for creating a container. + /// + string CreatePath { get; } + + /// + /// Gets the API path for destroying a container. + /// + string DestroyPath { get; } + + /// + /// Builds a create request payload for the protocol version. + /// + object BuildCreateRequest(ContainerConfig config); + + /// + /// Parses the create response payload to a normalized interface. + /// + IThirdPartyCreateResponse? DeserializeCreateResponse(string json, JsonSerializerOptions options); +} + +/// +/// Normalized create response shape returned by third-party providers. +/// +public interface IThirdPartyCreateResponse +{ + string? Id { get; } + ThirdPartyContainerState? State { get; } + ThirdPartyAddress? InternalAddress { get; } + ThirdPartyAddress? ExternalAddress { get; } + DateTimeOffset? StartedAt { get; } + DateTimeOffset? ExpectStopAt { get; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ThirdPartyContainerState +{ + Unknown, + Running, + Failed +} + +public sealed class ThirdPartyAddress +{ + public string Ip { get; set; } = string.Empty; + public int Port { get; set; } +} diff --git a/src/GZCTF/Services/Container/ThirdParty/ThirdPartyRequestException.cs b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyRequestException.cs new file mode 100644 index 000000000..eb065fdc8 --- /dev/null +++ b/src/GZCTF/Services/Container/ThirdParty/ThirdPartyRequestException.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace GZCTF.Services.Container.ThirdParty; + +public sealed class ThirdPartyRequestException : Exception +{ + public ThirdPartyRequestException(HttpStatusCode statusCode, string body) + : base($"Third-party request failed with status {(int)statusCode}.") + { + StatusCode = statusCode; + Body = body; + } + + public HttpStatusCode StatusCode { get; } + public string Body { get; } +} diff --git a/src/GZCTF/Services/Container/ThirdParty/V1.cs b/src/GZCTF/Services/Container/ThirdParty/V1.cs new file mode 100644 index 000000000..b6d38c80f --- /dev/null +++ b/src/GZCTF/Services/Container/ThirdParty/V1.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using GZCTF.Models.Internal; + +namespace GZCTF.Services.Container.ThirdParty; + +public static class ThirdPartyV1 +{ + public const string RequestIdHeader = "X-Request-Id"; + public const string HealthPath = "/v1/healthz"; + public const string CreatePath = "/v1/containers"; + public const string DestroyPath = "/v1/containers/{id}"; + + public sealed class CreateRequest + { + public string Image { get; set; } = string.Empty; + public int ExposedPort { get; set; } + public Resources Resources { get; set; } = new(); + public NetworkMode NetworkMode { get; set; } = NetworkMode.Open; + public Labels Labels { get; set; } = new(); + public Dictionary Env { get; set; } = new(); + } + + public sealed class Resources + { + public double Cpu { get; set; } + public int Memory { get; set; } + public int Storage { get; set; } + } + + public sealed class Labels + { + public string TeamId { get; set; } = string.Empty; + public Guid UserId { get; set; } + public int ChallengeId { get; set; } + } + + public sealed class CreateResponse : IThirdPartyCreateResponse + { + public string? Id { get; set; } + public ThirdPartyContainerState? State { get; set; } + public ThirdPartyAddress? InternalAddress { get; set; } + public ThirdPartyAddress? ExternalAddress { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public DateTimeOffset? ExpectStopAt { get; set; } + } + + public sealed class Protocol : IThirdPartyProtocol + { + public ThirdPartyApiVersion Version => ThirdPartyApiVersion.V1; + public string RequestIdHeader => ThirdPartyV1.RequestIdHeader; + public string CreatePath => ThirdPartyV1.CreatePath; + public string DestroyPath => ThirdPartyV1.DestroyPath; + + public object BuildCreateRequest(ContainerConfig config) + { + var env = new Dictionary { ["GZCTF_TEAM_ID"] = config.TeamId }; + if (!string.IsNullOrWhiteSpace(config.Flag)) + env["GZCTF_FLAG"] = config.Flag; + + return new CreateRequest + { + Image = config.Image, + ExposedPort = config.ExposedPort, + Resources = new Resources + { + Cpu = config.CPUCount / 10.0, + Memory = config.MemoryLimit, + Storage = config.StorageLimit + }, + NetworkMode = config.NetworkMode, + Labels = new Labels + { + TeamId = config.TeamId, + UserId = config.UserId, + ChallengeId = config.ChallengeId + }, + Env = env + }; + } + + public IThirdPartyCreateResponse? DeserializeCreateResponse(string json, JsonSerializerOptions options) + => JsonSerializer.Deserialize(json, options); + } +} From 61dea38040351359fa1991105c091fcf71e640e1 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 11 Feb 2026 21:40:05 +0800 Subject: [PATCH 2/2] feat(container): add ThirdParty provider localization and tests - Standardize ThirdParty provider logging with i18n (10 languages) - Add 11 unit tests with MockV1Server using dynamic ports - Remove deprecated Docker Swarm resource strings - Update documentation to reflect current container providers --- .github/copilot-instructions.md | 8 +- .../Tests/Api/ScoreboardCalculationTests.cs | 4 +- .../Services/ThirdPartyProviderTests.cs | 558 ++++++++++++++++++ src/GZCTF.Test/UnitTests/Utils/CodecTests.cs | 2 +- src/GZCTF/Resources/Program.de-DE.resx | 9 + src/GZCTF/Resources/Program.es-ES.resx | 9 + src/GZCTF/Resources/Program.fr-FR.resx | 9 + src/GZCTF/Resources/Program.id-ID.resx | 9 + src/GZCTF/Resources/Program.ja-JP.resx | 9 + src/GZCTF/Resources/Program.ko-KR.resx | 9 + src/GZCTF/Resources/Program.resx | 12 +- src/GZCTF/Resources/Program.ru-RU.resx | 9 + src/GZCTF/Resources/Program.vi-VN.resx | 9 + src/GZCTF/Resources/Program.zh-CN.resx | 12 +- src/GZCTF/Resources/Program.zh-TW.resx | 9 + .../Container/Manager/ThirdPartyManager.cs | 4 +- .../Container/Provider/ThirdPartyProvider.cs | 27 +- 17 files changed, 687 insertions(+), 21 deletions(-) create mode 100644 src/GZCTF.Test/UnitTests/Services/ThirdPartyProviderTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 45191f9cd..68e826b30 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,7 +23,7 @@ GZ::CTF is a full‑stack, production‑ready CTF platform for competitions and - Rate limiting: Global sliding window + named policies (`Middlewares/RateLimiter.cs`), disabled by `DisableRateLimit=true`. - i18n: `Resources/` with `IStringLocalizer`; invalid model state returns JSON via `InvalidModelStateHandler`. - SignalR patterns: Strongly-typed hubs (`AdminHub`, `MonitorHub`, `UserHub`) with client interfaces (`IAdminClient`, `IMonitorClient`, `IUserClient`) for real-time notifications. Each hub validates permissions and groups clients by game ID. - - Container proxy: `ProxyController` handles TCP-over-WebSocket for challenge access when `ContainerPortMappingType.PlatformProxy` is set. Supports Docker Swarm and Kubernetes with traffic capture capabilities. + - Container proxy: `ProxyController` handles TCP-over-WebSocket for challenge access when `ContainerPortMappingType.PlatformProxy` is set. Supports Kubernetes with traffic capture capabilities. - Frontend `src/GZCTF/ClientApp` (React + Mantine + Vite): - Dev server on `63000` with proxy to backend; configure backend URL via `VITE_BACKEND_URL` (defaults to `http://localhost:8080`) in `vite.config.mts`. - Build outputs to `ClientApp/build` and is copied to backend `wwwroot` during `dotnet publish`. @@ -74,7 +74,7 @@ Design notes (flexibility vs performance) - Storage connection string prefixes: `disk://` (forced default), `aws.s3://`, `minio.s3://`, `azure.blobs://`. Self-maintenance storage ensures blob cleanup and consistency. - Environment configuration prefix: `GZCTF_` (env vars override config). - Role hierarchy: `Admin` (3) > `Monitor` (1) > `User` (0) > `Banned` (-1). Frontend `WithRole` component uses `RoleMap` for access control. -- Container management: Docker Swarm (`SwarmManager`) and Kubernetes (`KubernetesManager`) support with K3s/MinIO integration. Use `IContainerManager` interface for consistency. +- Container management: Docker (`DockerManager`) and Kubernetes (`KubernetesManager`) support with K3s/MinIO integration. Use `IContainerManager` interface for consistency. - Task Status: Enum includes `Success`, `Failed`, `Pending`, `Running`, `Unhealthy`, `Degraded` statuses for container/service health. - Divisions API: Endpoints for game-scoped division management with challenge configs. Division affects challenge visibility, scoring, and deadline enforcement. - FirstSolves tracking: Separate table for first-blood metadata (team, user, timestamp); used for bonus scoring and statistics. @@ -100,7 +100,7 @@ Design notes (flexibility vs performance) - `dotnet test src/GZCTF.Integration.Test/GZCTF.Integration.Test.csproj -v minimal /p:CollectCoverage=true` (requires Docker; uses Testcontainers for K3s, MinIO, PostgreSQL) - Testing framework: - Unit tests: xUnit with `IRepository` and service mocking; examples in `GZCTF.Test/UnitTests/`. - - Integration tests: Testcontainers for Docker Swarm/Kubernetes, MinIO S3, PostgreSQL, Redis; examples in `GZCTF.Integration.Test/Tests/`. Covers dynamic container challenges, flag retrieval, storage operations, and repository data validation. + - Integration tests: Testcontainers for Docker/Kubernetes, MinIO S3, PostgreSQL, Redis; examples in `GZCTF.Integration.Test/Tests/`. Covers dynamic container challenges, flag retrieval, storage operations, and repository data validation. - EF Core migrations (PostgreSQL): - `dotnet ef migrations add --project src/GZCTF/GZCTF.csproj --startup-project src/GZCTF/GZCTF.csproj` - `dotnet ef database update` @@ -120,7 +120,7 @@ Design notes (flexibility vs performance) ## Container management - Port mapping types: `Default` (random host ports) vs `PlatformProxy` (TCP-over-WebSocket). -- Container providers: Docker Swarm (`SwarmManager`) and Kubernetes (`KubernetesManager`) with `IContainerManager` abstraction. +- Container providers: Docker (`DockerManager`) and Kubernetes (`KubernetesManager`) with `IContainerManager` abstraction. - Traffic capture: Optional recording of container network traffic to storage when `EnableTrafficCapture=true`. - Challenge types: Static/Dynamic containers with environment variable flag injection. - Resource management: Automatic cleanup and scaling based on team participation. diff --git a/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs b/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs index fb55b86cc..ee8b83d33 100644 --- a/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs +++ b/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs @@ -1409,8 +1409,8 @@ private ScoreboardSnapshot( BloodBonusValue = bloodBonusValue; ChallengeCount = challengeCount; Teams = teams; - this._teamMap = teamMap; - this._challengeMap = challengeMap; + _teamMap = teamMap; + _challengeMap = challengeMap; Divisions = divisions; Timelines = timelines; } diff --git a/src/GZCTF.Test/UnitTests/Services/ThirdPartyProviderTests.cs b/src/GZCTF.Test/UnitTests/Services/ThirdPartyProviderTests.cs new file mode 100644 index 000000000..3df34c047 --- /dev/null +++ b/src/GZCTF.Test/UnitTests/Services/ThirdPartyProviderTests.cs @@ -0,0 +1,558 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading.Tasks; +using GZCTF.Models.Internal; +using GZCTF.Services.Container.Manager; +using GZCTF.Services.Container.Provider; +using GZCTF.Services.Container.ThirdParty; +using GZCTF.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; +using Xunit.Abstractions; + +namespace GZCTF.Test.UnitTests.Services; + +/// +/// Tests for ThirdParty container provider and manager +/// +public class ThirdPartyProviderTests : IDisposable +{ + private readonly ITestOutputHelper _output; + private readonly MockV1Server _mockServer; + private readonly HttpClient _httpClient; + private readonly int _serverPort; + + public ThirdPartyProviderTests(ITestOutputHelper output) + { + _output = output; + _mockServer = new MockV1Server(); + _serverPort = _mockServer.Start(); + _httpClient = new HttpClient { BaseAddress = new Uri($"http://localhost:{_serverPort}") }; + } + + public void Dispose() + { + _mockServer.Dispose(); + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void ThirdPartyProvider_WithMissingBaseUrl_ThrowsException() + { + // Arrange + var options = Options.Create(new ContainerProvider + { + Type = ContainerProviderType.ThirdParty, + ThirdPartyConfig = new ThirdPartyConfig { BaseUrl = "" } + }); + + var logger = new TestLogger(); + + // Act & Assert + var exception = Assert.Throws(() => + new ThirdPartyProvider(options, logger)); + + Assert.Contains("base URL", exception.Message); + } + + [Fact] + public void ThirdPartyProvider_WithValidConfig_InitializesSuccessfully() + { + // Arrange + var options = Options.Create(new ContainerProvider + { + Type = ContainerProviderType.ThirdParty, + PortMappingType = ContainerPortMappingType.Default, + PublicEntry = "test.example.com", + ThirdPartyConfig = new ThirdPartyConfig + { + BaseUrl = $"http://localhost:{_serverPort}", + ApiVersion = ThirdPartyApiVersion.V1, + Timeout = 30, + VerifyTls = false + } + }); + + var logger = new TestLogger(); + + // Act + var provider = new ThirdPartyProvider(options, logger); + var metadata = provider.GetMetadata(); + var client = provider.GetProvider(); + + // Assert + Assert.NotNull(metadata); + Assert.NotNull(client); + Assert.Equal(ThirdPartyApiVersion.V1, client.ApiVersion); + Assert.Equal(ContainerPortMappingType.Default, metadata.PortMappingType); + Assert.Equal("test.example.com", metadata.PublicEntry); + } + + [Fact] + public async Task ThirdPartyClient_CreateContainer_SuccessfullyCreatesContainer() + { + // Arrange + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-123", + UserId = Guid.NewGuid(), + ChallengeId = 42, + Flag = "flag{test_flag_12345}", + NetworkMode = NetworkMode.Open + }; + + // Act + var container = await manager.CreateContainerAsync(config); + + // Assert + Assert.NotNull(container); + Assert.NotNull(container.ContainerId); + Assert.Equal(config.Image, container.Image); + Assert.Equal("192.168.1.100", container.IP); + Assert.Equal(8080, container.Port); + Assert.True(container.IsProxy); + Assert.Equal(ContainerStatus.Running, container.Status); + + _output.WriteLine($"Container created: {container.ContainerId}"); + _output.WriteLine($"Container IP: {container.IP}:{container.Port}"); + } + + [Fact] + public async Task ThirdPartyClient_CreateContainer_WithExposedPort_ReturnsPublicAddress() + { + // Arrange + var options = CreateDefaultOptions(exposePort: true); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-456", + UserId = Guid.NewGuid(), + ChallengeId = 43, + Flag = "flag{test_flag_67890}", + NetworkMode = NetworkMode.Open + }; + + // Act + var container = await manager.CreateContainerAsync(config); + + // Assert + Assert.NotNull(container); + Assert.NotNull(container.ContainerId); + Assert.Equal("203.0.113.10", container.PublicIP); + Assert.Equal(30080, container.PublicPort); + Assert.False(container.IsProxy); + + _output.WriteLine($"Container public address: {container.PublicIP}:{container.PublicPort}"); + } + + [Fact] + public async Task ThirdPartyClient_CreateContainer_WithoutExternalAddress_WhenExposePort_ReturnsNull() + { + // Arrange + _mockServer.SetExternalAddressEnabled(false); // Disable external address in mock server + + var options = CreateDefaultOptions(exposePort: true); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-789", + UserId = Guid.NewGuid(), + ChallengeId = 44, + NetworkMode = NetworkMode.Open + }; + + // Act + var container = await manager.CreateContainerAsync(config); + + // Assert + Assert.Null(container); + + _mockServer.SetExternalAddressEnabled(true); // Re-enable for other tests + } + + [Fact] + public async Task ThirdPartyClient_CreateContainer_WithFailedState_ReturnsNull() + { + // Arrange + _mockServer.SetContainerState(ThirdPartyContainerState.Failed); + + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-error", + UserId = Guid.NewGuid(), + ChallengeId = 45, + NetworkMode = NetworkMode.Open + }; + + // Act + var container = await manager.CreateContainerAsync(config); + + // Assert + Assert.Null(container); + + _mockServer.SetContainerState(ThirdPartyContainerState.Running); // Reset for other tests + } + + [Fact] + public async Task ThirdPartyClient_CreateContainer_WithHttpError_ReturnsNull() + { + // Arrange + _mockServer.SetHttpErrorCode(HttpStatusCode.InternalServerError); + + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-http-error", + UserId = Guid.NewGuid(), + ChallengeId = 46, + NetworkMode = NetworkMode.Open + }; + + // Act + var container = await manager.CreateContainerAsync(config); + + // Assert + Assert.Null(container); + + _mockServer.SetHttpErrorCode(null); // Reset for other tests + } + + [Fact] + public async Task ThirdPartyClient_DestroyContainer_SuccessfullyDestroysContainer() + { + // Arrange + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var container = new GZCTF.Models.Data.Container + { + ContainerId = "test-container-id", + Image = "test-image:latest", + IP = "192.168.1.100", + Port = 8080, + Status = ContainerStatus.Running + }; + + // Act + await manager.DestroyContainerAsync(container); + + // Assert + Assert.Equal(ContainerStatus.Destroyed, container.Status); + + _output.WriteLine($"Container destroyed: {container.ContainerId}"); + } + + [Fact] + public async Task ThirdPartyClient_DestroyContainer_WithNonExistentContainer_SetsDestroyedStatus() + { + // Arrange + _mockServer.SetHttpErrorCode(HttpStatusCode.NotFound); + + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + var managerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var manager = new ThirdPartyManager(provider, managerLogger); + + var container = new GZCTF.Models.Data.Container + { + ContainerId = "non-existent-container", + Image = "test-image:latest", + IP = "192.168.1.100", + Port = 8080, + Status = ContainerStatus.Running + }; + + // Act + await manager.DestroyContainerAsync(container); + + // Assert + Assert.Equal(ContainerStatus.Destroyed, container.Status); + + _mockServer.SetHttpErrorCode(null); // Reset for other tests + } + + [Fact] + public async Task ThirdPartyClient_RequestId_IsSentToServer() + { + // Arrange + var options = CreateDefaultOptions(exposePort: false); + var providerLogger = new TestLogger(); + + var provider = new ThirdPartyProvider(options, providerLogger); + var client = provider.GetProvider(); + + var config = new ContainerConfig + { + Image = "test-image:latest", + ExposedPort = 8080, + CPUCount = 1, + MemoryLimit = 512, + StorageLimit = 1024, + TeamId = "team-request-id", + UserId = Guid.NewGuid(), + ChallengeId = 47, + NetworkMode = NetworkMode.Open + }; + + var requestId = "custom-request-id-12345"; + + // Act + var response = await client.CreateAsync(config, requestId); + + // Assert + Assert.NotNull(response); + Assert.Equal(requestId, _mockServer.LastRequestId); + + _output.WriteLine($"Request ID sent: {requestId}"); + } + + [Fact] + public void ThirdPartyClient_WithMismatchedApiVersion_ThrowsException() + { + // Arrange + var options = Options.Create(new ContainerProvider + { + Type = ContainerProviderType.ThirdParty, + ThirdPartyConfig = new ThirdPartyConfig + { + BaseUrl = $"http://localhost:{_serverPort}", + ApiVersion = (ThirdPartyApiVersion)999, // Invalid version + Timeout = 30, + VerifyTls = false + } + }); + + var logger = new TestLogger(); + + // Act & Assert + // The provider should throw during initialization when creating the client + var exception = Assert.Throws(() => + new ThirdPartyProvider(options, logger)); + + Assert.Contains("version", exception.Message.ToLower()); + } + + private IOptions CreateDefaultOptions(bool exposePort) => + Options.Create(new ContainerProvider + { + Type = ContainerProviderType.ThirdParty, + PortMappingType = exposePort ? ContainerPortMappingType.Default : ContainerPortMappingType.PlatformProxy, + PublicEntry = "203.0.113.10", + ThirdPartyConfig = new ThirdPartyConfig + { + BaseUrl = $"http://localhost:{_serverPort}", + ApiVersion = ThirdPartyApiVersion.V1, + Timeout = 30, + VerifyTls = false + } + }); +} + +/// +/// Mock V1 Server for testing ThirdParty API +/// +internal class MockV1Server : IDisposable +{ + private readonly HttpListener _listener = new(); + private int _port; + private bool _externalAddressEnabled = true; + private ThirdPartyContainerState _containerState = ThirdPartyContainerState.Running; + private HttpStatusCode? _httpErrorCode; + + public string? LastRequestId { get; private set; } + + public int Start() + { + // Find an available port by letting the OS assign one + using var tempListener = new TcpListener(IPAddress.Loopback, 0); + tempListener.Start(); + _port = ((IPEndPoint)tempListener.LocalEndpoint).Port; + tempListener.Stop(); + + _listener.Prefixes.Add($"http://localhost:{_port}/"); + _listener.Start(); + + Task.Run(HandleRequests); + return _port; + } + + public void SetExternalAddressEnabled(bool enabled) => _externalAddressEnabled = enabled; + public void SetContainerState(ThirdPartyContainerState state) => _containerState = state; + public void SetHttpErrorCode(HttpStatusCode? code) => _httpErrorCode = code; + + private async Task HandleRequests() + { + while (_listener.IsListening) + { + try + { + var context = await _listener.GetContextAsync(); + await ProcessRequest(context); + } + catch (Exception) + { + // Listener stopped + break; + } + } + } + + private async Task ProcessRequest(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + // Extract request ID from header + LastRequestId = request.Headers["X-Request-Id"]; + + // Return error if configured + if (_httpErrorCode.HasValue) + { + response.StatusCode = (int)_httpErrorCode.Value; + response.Close(); + return; + } + + // Handle different endpoints + if (request.Url?.AbsolutePath == "/v1/containers" && request.HttpMethod == "POST") + { + await HandleCreateContainer(request, response); + } + else if (request.Url?.AbsolutePath.StartsWith("/v1/containers/") == true && + request.HttpMethod == "DELETE") + { + await HandleDestroyContainer(response); + } + else + { + response.StatusCode = 404; + response.Close(); + } + } + + private async Task HandleCreateContainer(HttpListenerRequest request, HttpListenerResponse response) + { + using var reader = new StreamReader(request.InputStream, request.ContentEncoding); + await reader.ReadToEndAsync(); + + var createResponse = new ThirdPartyV1.CreateResponse + { + Id = $"container-{Guid.NewGuid():N}", + State = _containerState, + InternalAddress = new ThirdPartyAddress { Ip = "192.168.1.100", Port = 8080 }, + ExternalAddress = _externalAddressEnabled + ? new ThirdPartyAddress { Ip = "203.0.113.10", Port = 30080 } + : null, + StartedAt = DateTimeOffset.UtcNow, + ExpectStopAt = DateTimeOffset.UtcNow.AddHours(2) + }; + + var json = JsonSerializer.Serialize(createResponse, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + response.StatusCode = 200; + response.ContentType = "application/json"; + await using var writer = new StreamWriter(response.OutputStream); + await writer.WriteAsync(json); + } + + private async Task HandleDestroyContainer(HttpListenerResponse response) + { + response.StatusCode = 200; + await Task.CompletedTask; + response.Close(); + } + + public void Dispose() + { + _listener.Stop(); + _listener.Close(); + GC.SuppressFinalize(this); + } +} + +/// +/// Simple test logger for capturing log messages in tests +/// +internal class TestLogger : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + // In a real implementation, this could store log messages for assertion + // For now, we just ensure the logger doesn't throw + } +} diff --git a/src/GZCTF.Test/UnitTests/Utils/CodecTests.cs b/src/GZCTF.Test/UnitTests/Utils/CodecTests.cs index d58c69a52..6c46e41f6 100644 --- a/src/GZCTF.Test/UnitTests/Utils/CodecTests.cs +++ b/src/GZCTF.Test/UnitTests/Utils/CodecTests.cs @@ -79,7 +79,7 @@ public void FileHashRegex_RejectsInvalidHash(string invalidHash) public void LeetEntropy_CalculatesCorrectly(string flag, bool expectedZero, bool expectedPositive) { // Act - var entropy = new GZCTF.Utils.DynamicFlagGenerator(flag).CalculateEntropy(); + var entropy = new DynamicFlagGenerator(flag).CalculateEntropy(); // Assert if (expectedZero) diff --git a/src/GZCTF/Resources/Program.de-DE.resx b/src/GZCTF/Resources/Program.de-DE.resx index c0ab9a8aa..eb6589f9d 100644 --- a/src/GZCTF/Resources/Program.de-DE.resx +++ b/src/GZCTF/Resources/Program.de-DE.resx @@ -392,6 +392,15 @@ ({0}) + + Basis-URL des Drittanbieter-Container-Providers ist nicht konfiguriert + + + Drittanbieter-Container-Provider erfolgreich initialisiert ({0}) + + + Container-Verwaltungsmodus: Drittanbieter-Container-Steuerung + Spiel #{0} steht kurz vor dem Start, fügt die Punktetafel-Cache zur Cache-Warteschlange hinzu diff --git a/src/GZCTF/Resources/Program.es-ES.resx b/src/GZCTF/Resources/Program.es-ES.resx index 4541549cc..bea0fb277 100644 --- a/src/GZCTF/Resources/Program.es-ES.resx +++ b/src/GZCTF/Resources/Program.es-ES.resx @@ -388,6 +388,15 @@ La inicialización de K8s falló, verifique que la configuración sea correcta ({0}) + + La URL base del proveedor de contenedores de terceros no está configurada + + + Inicialización del proveedor de contenedores de terceros exitosa ({0}) + + + Modo de gestión de contenedores: Control de contenedores de terceros + El juego n.º {0} está a punto de comenzar, agregando la caché del marcador a la cola de caché diff --git a/src/GZCTF/Resources/Program.fr-FR.resx b/src/GZCTF/Resources/Program.fr-FR.resx index 59d496dce..a8fdadd21 100644 --- a/src/GZCTF/Resources/Program.fr-FR.resx +++ b/src/GZCTF/Resources/Program.fr-FR.resx @@ -387,6 +387,15 @@ Échec de l'initialisation de K8s, veuillez vérifier si la configuration est correcte ({0}) + + L'URL de base du fournisseur de conteneurs tiers n'est pas configurée + + + Initialisation du fournisseur de conteneurs tiers réussie ({0}) + + + Mode de gestion des conteneurs : Contrôle des conteneurs tiers + Le jeu n°{0} est sur le point de commencer, ajout de la cache du tableau de scores à la file diff --git a/src/GZCTF/Resources/Program.id-ID.resx b/src/GZCTF/Resources/Program.id-ID.resx index aace9c892..bf78818b7 100644 --- a/src/GZCTF/Resources/Program.id-ID.resx +++ b/src/GZCTF/Resources/Program.id-ID.resx @@ -383,6 +383,15 @@ Inisialisasi K8s gagal, silakan periksa apakah konfigurasi sudah benar ({0}) + + URL dasar penyedia container pihak ketiga belum dikonfigurasi + + + Inisialisasi penyedia container pihak ketiga berhasil ({0}) + + + Mode manajemen container: Kontrol container pihak ketiga + Permainan #{0} akan segera dimulai, menambahkan cache papan skor ke antrean cache diff --git a/src/GZCTF/Resources/Program.ja-JP.resx b/src/GZCTF/Resources/Program.ja-JP.resx index a1481abe3..17a94481e 100644 --- a/src/GZCTF/Resources/Program.ja-JP.resx +++ b/src/GZCTF/Resources/Program.ja-JP.resx @@ -383,6 +383,15 @@ K8s の初期化に失敗しました、構成が正しいかどうかを確認してください ({0}) + + サードパーティコンテナ provider の base URL が設定されていません + + + サードパーティコンテナ provider が正常に初期化されました ({0}) + + + コンテナ管理モード: サードパーティコンテナ制御 + ゲーム #{0} が間もなく開始されます、ランキング キャッシュがキャッシュ キューに追加されました diff --git a/src/GZCTF/Resources/Program.ko-KR.resx b/src/GZCTF/Resources/Program.ko-KR.resx index fd38b2c31..db905929e 100644 --- a/src/GZCTF/Resources/Program.ko-KR.resx +++ b/src/GZCTF/Resources/Program.ko-KR.resx @@ -383,6 +383,15 @@ K8s 초기화 실패. 설정이 올바른지 확인하십시오. ({0}) + + 타사 컨테이너 provider base URL이 구성되지 않았습니다 + + + 타사 컨테이너 provider 초기화 성공 ({0}) + + + 컨테이너 관리 모드: 타사 컨테이너 제어 + #{0} 시작됨. 캐시 큐에 스코어보드 캐시를 추가합니다. diff --git a/src/GZCTF/Resources/Program.resx b/src/GZCTF/Resources/Program.resx index 90e7dc641..44b114fb9 100644 --- a/src/GZCTF/Resources/Program.resx +++ b/src/GZCTF/Resources/Program.resx @@ -365,9 +365,6 @@ The service {0} failed to create, status: {1} - - Container mode:Docker Swarm Cluster Container Control - Could not parse image name '{0}' @@ -383,6 +380,15 @@ K8s initialization failed, please check if the configuration is correct ({0}) + + Third-party container provider base URL is not configured. + + + Third-party container provider initialized ({0}) + + + Container management mode: Third-party container control + Game #{0} is about to start, adding scoreboard cache to the cache queue diff --git a/src/GZCTF/Resources/Program.ru-RU.resx b/src/GZCTF/Resources/Program.ru-RU.resx index 719c9d857..78b5eaff2 100644 --- a/src/GZCTF/Resources/Program.ru-RU.resx +++ b/src/GZCTF/Resources/Program.ru-RU.resx @@ -383,6 +383,15 @@ Ошибка инициализации K8s, проверьте правильность конфигурации ({0}) + + Базовый URL стороннего провайдера контейнеров не настроен + + + Успешная инициализация стороннего провайдера контейнеров ({0}) + + + Режим управления контейнерами: Управление сторонними контейнерами + Игра #{0} скоро начнётся, добавление кэша рейтинговой таблицы в очередь кэша diff --git a/src/GZCTF/Resources/Program.vi-VN.resx b/src/GZCTF/Resources/Program.vi-VN.resx index a5feec8ae..1968c57d7 100644 --- a/src/GZCTF/Resources/Program.vi-VN.resx +++ b/src/GZCTF/Resources/Program.vi-VN.resx @@ -383,6 +383,15 @@ Kubernetes không thể khởi tạo, vui lòng kiểm tra cấu hình ({0}). + + URL cơ sở của nhà cung cấp container bên thứ ba chưa được cấu hình + + + Nhà cung cấp container bên thứ ba đã khởi tạo thành công ({0}) + + + Chế độ quản lý container: Điều khiển container bên thứ ba + Trò chơi #{0} sắp bắt đầu, thêm cache bảng xếp hạng vào hàng chờ. diff --git a/src/GZCTF/Resources/Program.zh-CN.resx b/src/GZCTF/Resources/Program.zh-CN.resx index 4668d52de..e51dabc93 100644 --- a/src/GZCTF/Resources/Program.zh-CN.resx +++ b/src/GZCTF/Resources/Program.zh-CN.resx @@ -365,9 +365,6 @@ 服务 {0} 创建失败, 状态:{1} - - 容器管理模式:Docker Swarm 集群容器控制 - 无法解析镜像名称 '{0}' @@ -383,6 +380,15 @@ K8s 初始化失败,请检查相关配置是否正确 ({0}) + + 第三方容器 provider base URL 未配置 + + + 第三方容器 provider 初始化成功 ({0}) + + + 容器管理模式:第三方容器控制 + 比赛 #{0} 即将开始,积分榜缓存已加入缓存队列 diff --git a/src/GZCTF/Resources/Program.zh-TW.resx b/src/GZCTF/Resources/Program.zh-TW.resx index 6af429767..7f872b17b 100644 --- a/src/GZCTF/Resources/Program.zh-TW.resx +++ b/src/GZCTF/Resources/Program.zh-TW.resx @@ -383,6 +383,15 @@ K8s 初始化失敗,請檢查相關配置是否正確 ({0}) + + 第三方容器 provider base URL 未配置 + + + 第三方容器 provider 初始化成功 ({0}) + + + 容器管理模式:第三方容器控制 + 比賽 #{0} 即將開始,積分榜緩存已加入緩存隊列 diff --git a/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs b/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs index f5c0485cc..d0b64bdd7 100644 --- a/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs +++ b/src/GZCTF/Services/Container/Manager/ThirdPartyManager.cs @@ -18,7 +18,9 @@ public ThirdPartyManager(IContainerProvider CreateContainerAsync(ContainerConfig config, diff --git a/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs b/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs index 1fa8e0ae2..08207cc2d 100644 --- a/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs +++ b/src/GZCTF/Services/Container/Provider/ThirdPartyProvider.cs @@ -22,9 +22,11 @@ public ThirdPartyProvider(IOptions options, ILogger options, ILogger new ThirdPartyClient(_client, _meta), - _ => new ThirdPartyClient(_client, _meta) - }; + _thirdPartyClient = apiVersion switch + { + ThirdPartyApiVersion.V1 => new ThirdPartyClient(_client, _meta), + _ => new ThirdPartyClient(_client, _meta) + }; + } + catch (InvalidOperationException ex) + { + logger.SystemLog(ex.Message, TaskStatus.Failed, LogLevel.Error); + throw; + } - logger.SystemLog("Third-party container provider initialized.", TaskStatus.Success, LogLevel.Debug); + logger.SystemLog( + StaticLocalizer[nameof(Resources.Program.ContainerProvider_ThirdPartyInited), apiVersion], + TaskStatus.Success, LogLevel.Debug); } public IThirdPartyClient GetProvider() => _thirdPartyClient;