test fixes and new product advisories work
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// JFrog Artifactory container registry connector.
|
||||
/// Supports both Cloud and self-hosted Artifactory with API Key, Bearer token, and Basic auth.
|
||||
/// </summary>
|
||||
public sealed class JfrogArtifactoryConnector : IRegistryConnectorCapability, IDisposable
|
||||
{
|
||||
private HttpClient? _httpClient;
|
||||
private string _artifactoryUrl = string.Empty;
|
||||
private string _artifactoryHost = string.Empty;
|
||||
private string? _username;
|
||||
private string? _password;
|
||||
private string? _apiKey;
|
||||
private string? _accessToken;
|
||||
private string? _repository;
|
||||
private string? _repositoryType;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectorCategory Category => ConnectorCategory.Registry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ConnectorType => "jfrog-artifactory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "JFrog Artifactory";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetSupportedOperations() =>
|
||||
["list_repos", "list_tags", "resolve_tag", "get_manifest", "pull_credentials", "aql_query"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConfigValidationResult> ValidateConfigAsync(
|
||||
JsonElement config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate artifactoryUrl (required)
|
||||
var hasUrl = config.TryGetProperty("artifactoryUrl", out var url) &&
|
||||
url.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(url.GetString());
|
||||
|
||||
if (!hasUrl)
|
||||
{
|
||||
errors.Add("'artifactoryUrl' is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
var urlStr = url.GetString();
|
||||
if (!Uri.TryCreate(urlStr, UriKind.Absolute, out _))
|
||||
{
|
||||
errors.Add("Invalid 'artifactoryUrl' format");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for authentication: API Key OR Access Token OR username/password
|
||||
var hasApiKey = config.TryGetProperty("apiKey", out var apiKey) &&
|
||||
apiKey.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(apiKey.GetString());
|
||||
|
||||
var hasApiKeyRef = config.TryGetProperty("apiKeySecretRef", out var apiKeyRef) &&
|
||||
apiKeyRef.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(apiKeyRef.GetString());
|
||||
|
||||
var hasAccessToken = config.TryGetProperty("accessToken", out var accessToken) &&
|
||||
accessToken.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(accessToken.GetString());
|
||||
|
||||
var hasAccessTokenRef = config.TryGetProperty("accessTokenSecretRef", out var accessTokenRef) &&
|
||||
accessTokenRef.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(accessTokenRef.GetString());
|
||||
|
||||
var hasUsername = config.TryGetProperty("username", out var username) &&
|
||||
username.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(username.GetString());
|
||||
|
||||
var hasPassword = config.TryGetProperty("password", out var password) &&
|
||||
password.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(password.GetString());
|
||||
|
||||
var hasPasswordRef = config.TryGetProperty("passwordSecretRef", out var passwordRef) &&
|
||||
passwordRef.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(passwordRef.GetString());
|
||||
|
||||
// Require at least one auth method
|
||||
var hasApiKeyAuth = hasApiKey || hasApiKeyRef;
|
||||
var hasTokenAuth = hasAccessToken || hasAccessTokenRef;
|
||||
var hasBasicAuth = hasUsername && (hasPassword || hasPasswordRef);
|
||||
|
||||
if (!hasApiKeyAuth && !hasTokenAuth && !hasBasicAuth)
|
||||
{
|
||||
errors.Add("Authentication required: provide 'apiKey'/'apiKeySecretRef', 'accessToken'/'accessTokenSecretRef', or 'username' with 'password'/'passwordSecretRef'");
|
||||
}
|
||||
|
||||
// Validate repository type if provided
|
||||
if (config.TryGetProperty("repositoryType", out var repoType) &&
|
||||
repoType.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var type = repoType.GetString();
|
||||
if (!string.IsNullOrEmpty(type) &&
|
||||
type != "local" && type != "remote" && type != "virtual")
|
||||
{
|
||||
errors.Add("'repositoryType' must be 'local', 'remote', or 'virtual'");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(errors.Count == 0
|
||||
? ConfigValidationResult.Success()
|
||||
: ConfigValidationResult.Failure([.. errors]));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
ConnectorContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
// Artifactory API: GET /artifactory/api/system/ping
|
||||
using var response = await client.GetAsync("artifactory/api/system/ping", ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return ConnectionTestResult.Failure("Authentication failed: Invalid credentials or API key");
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return ConnectionTestResult.Failure($"Artifactory returned: {response.StatusCode}");
|
||||
}
|
||||
|
||||
// Try to get version info
|
||||
string versionInfo = "unknown";
|
||||
try
|
||||
{
|
||||
using var versionResponse = await client.GetAsync("artifactory/api/system/version", ct);
|
||||
if (versionResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var version = await versionResponse.Content.ReadFromJsonAsync<ArtifactoryVersion>(ct);
|
||||
versionInfo = version?.Version ?? "unknown";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Version fetch is optional
|
||||
}
|
||||
|
||||
return ConnectionTestResult.Success(
|
||||
$"Connected to JFrog Artifactory {versionInfo} at {_artifactoryHost}",
|
||||
sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConnectionTestResult.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
|
||||
ConnectorContext context,
|
||||
string? prefix = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
var repos = new List<RegistryRepository>();
|
||||
|
||||
// If specific repository is configured, only return Docker repos from it
|
||||
if (!string.IsNullOrEmpty(_repository))
|
||||
{
|
||||
return await ListDockerImagesInRepositoryAsync(client, _repository, prefix, ct);
|
||||
}
|
||||
|
||||
// List all Docker repositories
|
||||
var url = "artifactory/api/repositories?type=local&packageType=docker";
|
||||
|
||||
using var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return repos;
|
||||
|
||||
var repositories = await response.Content.ReadFromJsonAsync<ArtifactoryRepository[]>(ct);
|
||||
if (repositories is null)
|
||||
return repos;
|
||||
|
||||
foreach (var repo in repositories)
|
||||
{
|
||||
// Get images within each Docker repository
|
||||
var images = await ListDockerImagesInRepositoryAsync(client, repo.Key, prefix, ct);
|
||||
repos.AddRange(images);
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<RegistryRepository>> ListDockerImagesInRepositoryAsync(
|
||||
HttpClient client,
|
||||
string repoKey,
|
||||
string? prefix,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var repos = new List<RegistryRepository>();
|
||||
|
||||
// Use AQL to find Docker manifests
|
||||
var aqlQuery = $@"items.find({{
|
||||
""repo"": ""{repoKey}"",
|
||||
""name"": ""manifest.json"",
|
||||
""path"": {{""$ne"": "".""}}
|
||||
}}).include(""path"", ""created"", ""modified"")";
|
||||
|
||||
var aqlContent = new StringContent(aqlQuery, Encoding.UTF8, "text/plain");
|
||||
|
||||
using var response = await client.PostAsync("artifactory/api/search/aql", aqlContent, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return repos;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AqlResult>(ct);
|
||||
if (result?.Results is null)
|
||||
return repos;
|
||||
|
||||
// Extract unique image paths (directories containing manifest.json)
|
||||
var imagePaths = result.Results
|
||||
.Select(r => r.Path)
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.Select(p =>
|
||||
{
|
||||
// Path is like "myimage/tag" - extract image name
|
||||
var parts = p!.Split('/');
|
||||
return parts.Length > 0 ? parts[0] : p;
|
||||
})
|
||||
.Distinct()
|
||||
.Where(p => string.IsNullOrEmpty(prefix) ||
|
||||
p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var imagePath in imagePaths)
|
||||
{
|
||||
// Count tags for this image
|
||||
var tagCount = result.Results
|
||||
.Count(r => r.Path?.StartsWith(imagePath + "/") == true ||
|
||||
r.Path == imagePath);
|
||||
|
||||
var lastModified = result.Results
|
||||
.Where(r => r.Path?.StartsWith(imagePath) == true)
|
||||
.Max(r => r.Modified);
|
||||
|
||||
repos.Add(new RegistryRepository(
|
||||
Name: imagePath,
|
||||
FullName: $"{_artifactoryHost}/{repoKey}/{imagePath}",
|
||||
TagCount: tagCount,
|
||||
LastPushed: lastModified));
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
var tags = new List<ImageTag>();
|
||||
|
||||
// Parse repo/image from repository name
|
||||
var parts = repository.Split('/', 2);
|
||||
var repoKey = parts.Length > 1 ? parts[0] : (_repository ?? "docker-local");
|
||||
var imagePath = parts.Length > 1 ? parts[1] : parts[0];
|
||||
|
||||
// Use AQL to find all manifest.json files for this image
|
||||
var aqlQuery = $@"items.find({{
|
||||
""repo"": ""{repoKey}"",
|
||||
""path"": {{""$match"": ""{imagePath}/*""}},
|
||||
""name"": ""manifest.json""
|
||||
}}).include(""path"", ""created"", ""modified"", ""size"", ""sha256"")";
|
||||
|
||||
var aqlContent = new StringContent(aqlQuery, Encoding.UTF8, "text/plain");
|
||||
|
||||
using var response = await client.PostAsync("artifactory/api/search/aql", aqlContent, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return tags;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AqlResult>(ct);
|
||||
if (result?.Results is null)
|
||||
return tags;
|
||||
|
||||
foreach (var item in result.Results)
|
||||
{
|
||||
if (string.IsNullOrEmpty(item.Path))
|
||||
continue;
|
||||
|
||||
// Extract tag from path (path is like "imagename/tagname")
|
||||
var pathParts = item.Path.Split('/');
|
||||
var tagName = pathParts.Length > 1 ? pathParts[^1] : item.Path;
|
||||
|
||||
tags.Add(new ImageTag(
|
||||
Name: tagName,
|
||||
Digest: !string.IsNullOrEmpty(item.Sha256) ? $"sha256:{item.Sha256}" : string.Empty,
|
||||
CreatedAt: item.Created ?? item.Modified ?? DateTimeOffset.MinValue,
|
||||
SizeBytes: item.Size));
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImageDigest?> ResolveTagAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
// Use OCI endpoint for manifest head
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Head,
|
||||
$"v2/{repository}/manifests/{tag}");
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.v2+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.index.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json"));
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
return Plugin.Models.ImageDigest.Parse(digest);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImageManifest?> GetManifestAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
string reference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"v2/{repository}/manifests/{reference}");
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.v2+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.index.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json"));
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var layers = ExtractLayersFromManifest(content, mediaType);
|
||||
|
||||
return new ImageManifest(
|
||||
Digest: digest,
|
||||
MediaType: mediaType,
|
||||
Platform: null,
|
||||
SizeBytes: response.Content.Headers.ContentLength ?? content.Length,
|
||||
Layers: layers,
|
||||
CreatedAt: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PullCredentials> GetPullCredentialsAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Priority: Access Token > API Key > Basic Auth
|
||||
string username;
|
||||
string password;
|
||||
|
||||
if (!string.IsNullOrEmpty(_accessToken))
|
||||
{
|
||||
// For access tokens, use empty username with token as password
|
||||
username = string.Empty;
|
||||
password = _accessToken;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_apiKey))
|
||||
{
|
||||
// For API key, use the username with API key as password
|
||||
username = _username ?? string.Empty;
|
||||
password = _apiKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
username = _username ?? string.Empty;
|
||||
password = _password ?? string.Empty;
|
||||
}
|
||||
|
||||
return Task.FromResult(new PullCredentials(
|
||||
Registry: _artifactoryHost,
|
||||
Username: username,
|
||||
Password: password,
|
||||
ExpiresAt: null));
|
||||
}
|
||||
|
||||
private async Task<HttpClient> GetClientAsync(
|
||||
ConnectorContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_httpClient is not null)
|
||||
return _httpClient;
|
||||
|
||||
var config = context.Configuration;
|
||||
|
||||
if (!config.TryGetProperty("artifactoryUrl", out var urlProp) ||
|
||||
urlProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Artifactory URL not configured");
|
||||
}
|
||||
|
||||
_artifactoryUrl = urlProp.GetString()!.TrimEnd('/');
|
||||
_artifactoryHost = new Uri(_artifactoryUrl).Host;
|
||||
|
||||
// Extract repository config
|
||||
if (config.TryGetProperty("repository", out var repoProp) &&
|
||||
repoProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_repository = repoProp.GetString();
|
||||
}
|
||||
|
||||
if (config.TryGetProperty("repositoryType", out var repoTypeProp) &&
|
||||
repoTypeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_repositoryType = repoTypeProp.GetString();
|
||||
}
|
||||
|
||||
// Extract auth credentials - API Key
|
||||
if (config.TryGetProperty("apiKey", out var apiKeyProp) &&
|
||||
apiKeyProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_apiKey = apiKeyProp.GetString();
|
||||
}
|
||||
else if (config.TryGetProperty("apiKeySecretRef", out var apiKeyRef) &&
|
||||
apiKeyRef.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var secretPath = apiKeyRef.GetString();
|
||||
if (!string.IsNullOrEmpty(secretPath))
|
||||
{
|
||||
_apiKey = await context.SecretResolver.ResolveAsync(secretPath, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract auth credentials - Access Token
|
||||
if (config.TryGetProperty("accessToken", out var accessTokenProp) &&
|
||||
accessTokenProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_accessToken = accessTokenProp.GetString();
|
||||
}
|
||||
else if (config.TryGetProperty("accessTokenSecretRef", out var accessTokenRef) &&
|
||||
accessTokenRef.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var secretPath = accessTokenRef.GetString();
|
||||
if (!string.IsNullOrEmpty(secretPath))
|
||||
{
|
||||
_accessToken = await context.SecretResolver.ResolveAsync(secretPath, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract auth credentials - Username/Password
|
||||
if (config.TryGetProperty("username", out var userProp) &&
|
||||
userProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_username = userProp.GetString();
|
||||
}
|
||||
|
||||
if (config.TryGetProperty("password", out var passProp) &&
|
||||
passProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_password = passProp.GetString();
|
||||
}
|
||||
else if (config.TryGetProperty("passwordSecretRef", out var passRef) &&
|
||||
passRef.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var secretPath = passRef.GetString();
|
||||
if (!string.IsNullOrEmpty(secretPath))
|
||||
{
|
||||
_password = await context.SecretResolver.ResolveAsync(secretPath, ct);
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_artifactoryUrl + "/")
|
||||
};
|
||||
|
||||
// Set authorization header based on available auth
|
||||
if (!string.IsNullOrEmpty(_accessToken))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_apiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("X-JFrog-Art-Api", _apiKey);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_username))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
|
||||
return _httpClient;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractLayersFromManifest(string content, string mediaType)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("layers", out var layers))
|
||||
{
|
||||
return layers.EnumerateArray()
|
||||
.Where(l => l.TryGetProperty("digest", out _))
|
||||
.Select(l => l.GetProperty("digest").GetString()!)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_httpClient?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// JFrog Artifactory API response models
|
||||
internal sealed record ArtifactoryVersion(
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("revision")] string? Revision,
|
||||
[property: JsonPropertyName("license")] string? License);
|
||||
|
||||
internal sealed record ArtifactoryRepository(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("type")] string? Type,
|
||||
[property: JsonPropertyName("packageType")] string? PackageType,
|
||||
[property: JsonPropertyName("url")] string? Url);
|
||||
|
||||
internal sealed record AqlResult(
|
||||
[property: JsonPropertyName("results")] AqlResultItem[]? Results,
|
||||
[property: JsonPropertyName("range")] AqlRange? Range);
|
||||
|
||||
internal sealed record AqlResultItem(
|
||||
[property: JsonPropertyName("repo")] string? Repo,
|
||||
[property: JsonPropertyName("path")] string? Path,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("created")] DateTimeOffset? Created,
|
||||
[property: JsonPropertyName("modified")] DateTimeOffset? Modified,
|
||||
[property: JsonPropertyName("size")] long? Size,
|
||||
[property: JsonPropertyName("sha256")] string? Sha256);
|
||||
|
||||
internal sealed record AqlRange(
|
||||
[property: JsonPropertyName("start_pos")] int StartPos,
|
||||
[property: JsonPropertyName("end_pos")] int EndPos,
|
||||
[property: JsonPropertyName("total")] int Total);
|
||||
@@ -0,0 +1,501 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Quay container registry connector.
|
||||
/// Supports Quay.io and Red Hat Quay with OAuth2/robot account authentication and organization-based repositories.
|
||||
/// </summary>
|
||||
public sealed class QuayConnector : IRegistryConnectorCapability, IDisposable
|
||||
{
|
||||
private HttpClient? _httpClient;
|
||||
private string _quayUrl = string.Empty;
|
||||
private string _quayHost = string.Empty;
|
||||
private string? _username;
|
||||
private string? _password;
|
||||
private string? _oauth2Token;
|
||||
private string? _organizationName;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectorCategory Category => ConnectorCategory.Registry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ConnectorType => "quay";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Quay Registry";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetSupportedOperations() =>
|
||||
["list_repos", "list_tags", "resolve_tag", "get_manifest", "pull_credentials"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConfigValidationResult> ValidateConfigAsync(
|
||||
JsonElement config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Validate quayUrl (required)
|
||||
var hasUrl = config.TryGetProperty("quayUrl", out var url) &&
|
||||
url.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(url.GetString());
|
||||
|
||||
if (!hasUrl)
|
||||
{
|
||||
errors.Add("'quayUrl' is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
var urlStr = url.GetString();
|
||||
if (!Uri.TryCreate(urlStr, UriKind.Absolute, out _))
|
||||
{
|
||||
errors.Add("Invalid 'quayUrl' format");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for authentication: OAuth2 token OR username/password
|
||||
var hasOAuth2Token = config.TryGetProperty("oauth2Token", out var oauth2Token) &&
|
||||
oauth2Token.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(oauth2Token.GetString());
|
||||
|
||||
var hasOAuth2TokenRef = config.TryGetProperty("oauth2TokenSecretRef", out var oauth2TokenRef) &&
|
||||
oauth2TokenRef.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(oauth2TokenRef.GetString());
|
||||
|
||||
var hasUsername = config.TryGetProperty("username", out var username) &&
|
||||
username.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(username.GetString());
|
||||
|
||||
var hasPassword = config.TryGetProperty("password", out var password) &&
|
||||
password.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(password.GetString());
|
||||
|
||||
var hasPasswordRef = config.TryGetProperty("passwordSecretRef", out var passwordRef) &&
|
||||
passwordRef.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(passwordRef.GetString());
|
||||
|
||||
// Require either OAuth2 token OR username with password
|
||||
var hasOAuth2 = hasOAuth2Token || hasOAuth2TokenRef;
|
||||
var hasBasicAuth = hasUsername && (hasPassword || hasPasswordRef);
|
||||
|
||||
if (!hasOAuth2 && !hasBasicAuth)
|
||||
{
|
||||
errors.Add("Either 'oauth2Token'/'oauth2TokenSecretRef' OR 'username' with 'password'/'passwordSecretRef' is required");
|
||||
}
|
||||
|
||||
return Task.FromResult(errors.Count == 0
|
||||
? ConfigValidationResult.Success()
|
||||
: ConfigValidationResult.Failure([.. errors]));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ConnectionTestResult> TestConnectionAsync(
|
||||
ConnectorContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
// Quay API: GET /api/v1/discovery to test connectivity
|
||||
using var response = await client.GetAsync("api/v1/discovery", ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return ConnectionTestResult.Failure("Authentication failed: Invalid credentials or token");
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return ConnectionTestResult.Failure($"Quay returned: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return ConnectionTestResult.Success(
|
||||
$"Connected to Quay at {_quayHost}",
|
||||
sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConnectionTestResult.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RegistryRepository>> ListRepositoriesAsync(
|
||||
ConnectorContext context,
|
||||
string? prefix = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
var repos = new List<RegistryRepository>();
|
||||
string? nextPage = null;
|
||||
|
||||
// Use organization endpoint if organization is configured, otherwise user repos
|
||||
var baseUrl = !string.IsNullOrEmpty(_organizationName)
|
||||
? $"api/v1/repository?namespace={Uri.EscapeDataString(_organizationName)}"
|
||||
: "api/v1/repository?public=false";
|
||||
|
||||
if (!string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
baseUrl += $"&filter={Uri.EscapeDataString(prefix)}";
|
||||
}
|
||||
|
||||
var url = baseUrl;
|
||||
|
||||
while (true)
|
||||
{
|
||||
using var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
// Return empty list on auth failure for list operations
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
break;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuayRepositoryList>(ct);
|
||||
if (result?.Repositories is null || result.Repositories.Length == 0)
|
||||
break;
|
||||
|
||||
foreach (var repo in result.Repositories)
|
||||
{
|
||||
repos.Add(new RegistryRepository(
|
||||
Name: repo.Name,
|
||||
FullName: $"{_quayHost}/{repo.Namespace}/{repo.Name}",
|
||||
TagCount: repo.TagCount ?? 0,
|
||||
LastPushed: repo.LastModified));
|
||||
}
|
||||
|
||||
// Handle pagination
|
||||
if (string.IsNullOrEmpty(result.NextPage))
|
||||
break;
|
||||
|
||||
nextPage = result.NextPage;
|
||||
url = $"{baseUrl}&next_page={Uri.EscapeDataString(nextPage)}";
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ImageTag>> ListTagsAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
var tags = new List<ImageTag>();
|
||||
|
||||
// Parse namespace/repo from repository name
|
||||
var parts = repository.Split('/', 2);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var ns = parts[0];
|
||||
var repo = parts[1];
|
||||
var page = 1;
|
||||
const int limit = 100;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"api/v1/repository/{Uri.EscapeDataString(ns)}/{Uri.EscapeDataString(repo)}/tag/?page={page}&limit={limit}";
|
||||
|
||||
using var response = await client.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return [];
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
break;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuayTagList>(ct);
|
||||
if (result?.Tags is null || result.Tags.Length == 0)
|
||||
break;
|
||||
|
||||
foreach (var tag in result.Tags)
|
||||
{
|
||||
tags.Add(new ImageTag(
|
||||
Name: tag.Name,
|
||||
Digest: tag.ManifestDigest ?? string.Empty,
|
||||
CreatedAt: tag.LastModified ?? DateTimeOffset.MinValue,
|
||||
SizeBytes: tag.Size));
|
||||
}
|
||||
|
||||
if (!result.HasAdditional)
|
||||
break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImageDigest?> ResolveTagAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
// Use OCI endpoint for manifest head
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Head,
|
||||
$"v2/{repository}/manifests/{tag}");
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.v2+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.index.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json"));
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
return Plugin.Models.ImageDigest.Parse(digest);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImageManifest?> GetManifestAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
string reference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(context, ct);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"v2/{repository}/manifests/{reference}");
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.manifest.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.v2+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.oci.image.index.v1+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json"));
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var digest = response.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var layers = ExtractLayersFromManifest(content, mediaType);
|
||||
|
||||
return new ImageManifest(
|
||||
Digest: digest,
|
||||
MediaType: mediaType,
|
||||
Platform: null,
|
||||
SizeBytes: response.Content.Headers.ContentLength ?? content.Length,
|
||||
Layers: layers,
|
||||
CreatedAt: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PullCredentials> GetPullCredentialsAsync(
|
||||
ConnectorContext context,
|
||||
string repository,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// For OAuth2 token auth, use "oauth2accesstoken" as username convention
|
||||
var username = !string.IsNullOrEmpty(_oauth2Token)
|
||||
? "$oauthtoken"
|
||||
: _username ?? string.Empty;
|
||||
|
||||
var password = !string.IsNullOrEmpty(_oauth2Token)
|
||||
? _oauth2Token
|
||||
: _password ?? string.Empty;
|
||||
|
||||
return Task.FromResult(new PullCredentials(
|
||||
Registry: _quayHost,
|
||||
Username: username,
|
||||
Password: password,
|
||||
ExpiresAt: null));
|
||||
}
|
||||
|
||||
private async Task<HttpClient> GetClientAsync(
|
||||
ConnectorContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_httpClient is not null)
|
||||
return _httpClient;
|
||||
|
||||
var config = context.Configuration;
|
||||
|
||||
if (!config.TryGetProperty("quayUrl", out var urlProp) ||
|
||||
urlProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException("Quay URL not configured");
|
||||
}
|
||||
|
||||
_quayUrl = urlProp.GetString()!.TrimEnd('/');
|
||||
_quayHost = new Uri(_quayUrl).Host;
|
||||
|
||||
// Extract organization name if configured
|
||||
if (config.TryGetProperty("organizationName", out var orgProp) &&
|
||||
orgProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_organizationName = orgProp.GetString();
|
||||
}
|
||||
|
||||
// Try OAuth2 token first
|
||||
if (config.TryGetProperty("oauth2Token", out var oauth2TokenProp) &&
|
||||
oauth2TokenProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_oauth2Token = oauth2TokenProp.GetString();
|
||||
}
|
||||
else if (config.TryGetProperty("oauth2TokenSecretRef", out var oauth2TokenRef) &&
|
||||
oauth2TokenRef.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var secretPath = oauth2TokenRef.GetString();
|
||||
if (!string.IsNullOrEmpty(secretPath))
|
||||
{
|
||||
_oauth2Token = await context.SecretResolver.ResolveAsync(secretPath, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to username/password
|
||||
if (string.IsNullOrEmpty(_oauth2Token))
|
||||
{
|
||||
if (config.TryGetProperty("username", out var userProp) &&
|
||||
userProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_username = userProp.GetString();
|
||||
}
|
||||
|
||||
if (config.TryGetProperty("password", out var passProp) &&
|
||||
passProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
_password = passProp.GetString();
|
||||
}
|
||||
else if (config.TryGetProperty("passwordSecretRef", out var passRef) &&
|
||||
passRef.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var secretPath = passRef.GetString();
|
||||
if (!string.IsNullOrEmpty(secretPath))
|
||||
{
|
||||
_password = await context.SecretResolver.ResolveAsync(secretPath, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_quayUrl + "/")
|
||||
};
|
||||
|
||||
// Set authorization header
|
||||
if (!string.IsNullOrEmpty(_oauth2Token))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _oauth2Token);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_username))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_username}:{_password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
|
||||
return _httpClient;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractLayersFromManifest(string content, string mediaType)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("layers", out var layers))
|
||||
{
|
||||
return layers.EnumerateArray()
|
||||
.Where(l => l.TryGetProperty("digest", out _))
|
||||
.Select(l => l.GetProperty("digest").GetString()!)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_httpClient?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Quay API response models
|
||||
internal sealed record QuayRepositoryList(
|
||||
[property: JsonPropertyName("repositories")] QuayRepository[] Repositories,
|
||||
[property: JsonPropertyName("next_page")] string? NextPage);
|
||||
|
||||
internal sealed record QuayRepository(
|
||||
[property: JsonPropertyName("namespace")] string Namespace,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("is_public")] bool IsPublic,
|
||||
[property: JsonPropertyName("tag_count")] int? TagCount,
|
||||
[property: JsonPropertyName("last_modified")] DateTimeOffset? LastModified);
|
||||
|
||||
internal sealed record QuayTagList(
|
||||
[property: JsonPropertyName("tags")] QuayTag[] Tags,
|
||||
[property: JsonPropertyName("has_additional")] bool HasAdditional,
|
||||
[property: JsonPropertyName("page")] int Page);
|
||||
|
||||
internal sealed record QuayTag(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("manifest_digest")] string? ManifestDigest,
|
||||
[property: JsonPropertyName("size")] long? Size,
|
||||
[property: JsonPropertyName("last_modified")] DateTimeOffset? LastModified,
|
||||
[property: JsonPropertyName("expiration")] DateTimeOffset? Expiration);
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.Connectors.Registry;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class JfrogArtifactoryConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Category_ReturnsRegistry()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ConnectorCategory.Registry, connector.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectorType_ReturnsJfrogArtifactory()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("jfrog-artifactory", connector.ConnectorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_ReturnsJFrogArtifactory()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("JFrog Artifactory", connector.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedOperations_ReturnsExpectedOperations()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
|
||||
// Act
|
||||
var operations = connector.GetSupportedOperations();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("list_repos", operations);
|
||||
Assert.Contains("list_tags", operations);
|
||||
Assert.Contains("resolve_tag", operations);
|
||||
Assert.Contains("get_manifest", operations);
|
||||
Assert.Contains("pull_credentials", operations);
|
||||
Assert.Contains("aql_query", operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithApiKey_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"apiKey": "AKCp8myapikey123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithApiKeySecretRef_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"apiKeySecretRef": "vault://secrets/jfrog/apikey"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithAccessToken_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithAccessTokenSecretRef_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"accessTokenSecretRef": "vault://secrets/jfrog/token"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithUsernameAndPassword_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"username": "deploy-user",
|
||||
"password": "secretpassword123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithPasswordSecretRef_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"username": "deploy-user",
|
||||
"passwordSecretRef": "vault://secrets/jfrog/password"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithRepository_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"apiKey": "AKCp8myapikey123",
|
||||
"repository": "docker-local"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("local")]
|
||||
[InlineData("remote")]
|
||||
[InlineData("virtual")]
|
||||
public async Task ValidateConfigAsync_WithValidRepositoryType_ReturnsSuccess(string repoType)
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse($$"""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"apiKey": "AKCp8myapikey123",
|
||||
"repositoryType": "{{repoType}}"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithInvalidRepositoryType_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"apiKey": "AKCp8myapikey123",
|
||||
"repositoryType": "invalid-type"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("repositoryType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithNoArtifactoryUrl_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"apiKey": "AKCp8myapikey123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("artifactoryUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithInvalidArtifactoryUrl_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "not-a-url",
|
||||
"apiKey": "AKCp8myapikey123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("artifactoryUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithNoAuthentication_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("apiKey") || e.Contains("accessToken") || e.Contains("username"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithUsernameButNoPassword_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"artifactoryUrl": "https://mycompany.jfrog.io",
|
||||
"username": "deploy-user"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
// Should fail because username without password is incomplete
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithEmptyConfig_ReturnsMultipleErrors()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new JfrogArtifactoryConnector();
|
||||
var config = JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.Errors.Count >= 2); // Missing artifactoryUrl and authentication
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var connector = new JfrogArtifactoryConnector();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
connector.Dispose();
|
||||
connector.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.Registry;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.Connectors.Registry;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class QuayConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Category_ReturnsRegistry()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ConnectorCategory.Registry, connector.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectorType_ReturnsQuay()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("quay", connector.ConnectorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_ReturnsQuayRegistry()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Quay Registry", connector.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedOperations_ReturnsExpectedOperations()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
|
||||
// Act
|
||||
var operations = connector.GetSupportedOperations();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("list_repos", operations);
|
||||
Assert.Contains("list_tags", operations);
|
||||
Assert.Contains("resolve_tag", operations);
|
||||
Assert.Contains("get_manifest", operations);
|
||||
Assert.Contains("pull_credentials", operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithOAuth2Token_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"oauth2Token": "mytoken123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithOAuth2TokenSecretRef_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"oauth2TokenSecretRef": "vault://secrets/quay/token"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithUsernameAndPassword_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"username": "robot$myorg+deploy",
|
||||
"password": "robottoken123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithPasswordSecretRef_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"username": "robot$myorg+deploy",
|
||||
"passwordSecretRef": "vault://secrets/quay/password"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithOrganizationName_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"oauth2Token": "mytoken123",
|
||||
"organizationName": "myorg"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithNoQuayUrl_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"oauth2Token": "mytoken123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("quayUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithInvalidQuayUrl_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "not-a-url",
|
||||
"oauth2Token": "mytoken123"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("quayUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithNoAuthentication_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Contains("oauth2Token") || e.Contains("username"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithUsernameButNoPassword_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("""
|
||||
{
|
||||
"quayUrl": "https://quay.io",
|
||||
"username": "robot$myorg+deploy"
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
// Should fail because username without password is incomplete
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_WithEmptyConfig_ReturnsMultipleErrors()
|
||||
{
|
||||
// Arrange
|
||||
using var connector = new QuayConnector();
|
||||
var config = JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
// Act
|
||||
var result = await connector.ValidateConfigAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.Errors.Count >= 2); // Missing quayUrl and authentication
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var connector = new QuayConnector();
|
||||
|
||||
// Act & Assert - should not throw
|
||||
connector.Dispose();
|
||||
connector.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user