91 lines
4.4 KiB
C#
91 lines
4.4 KiB
C#
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace MoShiro.Backend.Application;
|
||
|
||
/// <summary>
|
||
/// 镜像仓库里的签名清单:CI 每次构建都把签名清单放在 <c><命名空间>/moshiro-<组件>-manifest:<通道></c> 这个没有文件层的小镜像的标签里
|
||
/// (<c>org.moshiro.manifest</c> 是 base64 的清单,<c>org.moshiro.signature</c> 是签名)。面板没有登记过这个组件的清单时(清过库、CI 登记那一步网络失败),
|
||
/// 直接按 OCI 分发协议匿名读取它——只读两个小 JSON,不拉任何镜像层。取回来的只是候选:调用方要用 CI 的发布公钥验过签才采用,所以仓库被改也只会验签失败。
|
||
/// </summary>
|
||
public sealed partial class ReleaseFeed : IDisposable
|
||
{
|
||
private const int MaxBytes = 1 << 20;
|
||
|
||
private readonly BackendOptions _options;
|
||
private readonly HttpClient _http;
|
||
|
||
public ReleaseFeed(BackendOptions options)
|
||
{
|
||
_options = options;
|
||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||
}
|
||
|
||
public void Dispose() => _http.Dispose();
|
||
|
||
/// <summary>取不到(仓库不可读、没有这个标签、格式不对)返回 null;不抛异常。</summary>
|
||
public async ValueTask<(string Manifest, string Signature)?> FetchAsync(string component, string channel, CancellationToken ct)
|
||
{
|
||
string registry = (_options.NodeImageRegistry ?? "").Trim().TrimEnd('/');
|
||
if (registry.Length == 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
string baseUrl = registry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || registry.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? registry : "https://" + registry;
|
||
string repository = $"{(channel == "dev" ? "moshiro-dev" : "moshiro")}/moshiro-{component}-manifest";
|
||
try
|
||
{
|
||
string manifestJson = await GetAsync($"{baseUrl}/v2/{repository}/manifests/{channel}", "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json", ct);
|
||
using JsonDocument manifest = JsonDocument.Parse(manifestJson);
|
||
string? digest = manifest.RootElement.GetProperty("config").GetProperty("digest").GetString();
|
||
if (digest is null || !Digest().IsMatch(digest))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
string configJson = await GetAsync($"{baseUrl}/v2/{repository}/blobs/{digest}", "application/json", ct);
|
||
using JsonDocument config = JsonDocument.Parse(configJson);
|
||
JsonElement labels = config.RootElement.GetProperty("config").GetProperty("Labels");
|
||
string? encoded = labels.GetProperty("org.moshiro.manifest").GetString();
|
||
string? signature = labels.GetProperty("org.moshiro.signature").GetString();
|
||
return encoded is null || signature is null ? null : (Encoding.UTF8.GetString(Convert.FromBase64String(encoded)), signature);
|
||
}
|
||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async Task<string> GetAsync(string url, string accept, CancellationToken ct)
|
||
{
|
||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||
request.Headers.TryAddWithoutValidation("Accept", accept);
|
||
using HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||
response.EnsureSuccessStatusCode();
|
||
if (response.Content.Headers.ContentLength is > MaxBytes)
|
||
{
|
||
throw new InvalidOperationException("the registry response is too large");
|
||
}
|
||
|
||
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
|
||
using var limited = new MemoryStream();
|
||
byte[] buffer = new byte[8192];
|
||
int read;
|
||
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||
{
|
||
limited.Write(buffer, 0, read);
|
||
if (limited.Length > MaxBytes)
|
||
{
|
||
throw new InvalidOperationException("the registry response is too large");
|
||
}
|
||
}
|
||
|
||
return Encoding.UTF8.GetString(limited.ToArray());
|
||
}
|
||
|
||
[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
|
||
private static partial Regex Digest();
|
||
}
|