Mark subproject as dirty in CMKS - Infra

This commit is contained in:
Nils Blomgren
2026-05-18 08:37:09 +02:00
parent 328d494530
commit 8b94fb8826
635 changed files with 81145 additions and 100551 deletions

View File

@@ -0,0 +1,592 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.Runtime;
using EntKube.Web.Data;
namespace EntKube.Web.Services;
/// <summary>
/// Result of provisioning a Cleura S3 bucket via OpenStack.
/// Contains the S3 endpoint, access/secret keys, and bucket name.
/// </summary>
public class CleuraS3ProvisionResult
{
public required string BucketName { get; set; }
public required string Endpoint { get; set; }
public required string AccessKey { get; set; }
public required string SecretKey { get; set; }
public required string Region { get; set; }
}
/// <summary>
/// Information about an S3 bucket returned from ListBuckets.
/// </summary>
public class S3BucketInfo
{
public required string Name { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>
/// A CORS rule for an S3 bucket.
/// </summary>
public class S3CorsRule
{
public List<string> AllowedOrigins { get; set; } = [];
public List<string> AllowedMethods { get; set; } = [];
public List<string> AllowedHeaders { get; set; } = [];
public int MaxAgeSeconds { get; set; }
}
/// <summary>
/// Handles Cleura/City Cloud S3 bucket provisioning via OpenStack APIs:
/// 1. Authenticates against Keystone to obtain a scoped token
/// 2. Creates EC2 credentials (access/secret key pair) for S3 access
/// 3. Creates the bucket using the AWS S3 SDK against Cleura's S3 endpoint
///
/// The flow:
/// - User has an OpenStackConnection with username/password stored in vault
/// - We authenticate via Keystone v3 password auth
/// - We create EC2 credentials scoped to the project
/// - We use those credentials to call the S3 API and create the bucket
/// - We store the credentials in the vault and return them
/// </summary>
public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory httpClientFactory)
{
/// <summary>
/// Provisions a new S3 bucket on Cleura using the given OpenStack connection.
/// Authenticates via Keystone, creates EC2 credentials, then creates the bucket.
/// </summary>
public async Task<CleuraS3ProvisionResult> CreateBucketAsync(
Guid tenantId,
OpenStackConnection connection,
string bucketName,
CancellationToken ct = default)
{
// Step 1: Retrieve the stored password from the vault.
string? password = await vaultService.GetOpenStackSecretValueAsync(
tenantId, connection.Id, "OS_PASSWORD", ct);
if (string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException(
"OpenStack password not found in vault. Re-enter the password in the connection settings.");
}
// Step 2: Authenticate against Keystone to get a scoped token, user ID, and project ID.
(string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct);
// Step 3: Create EC2 credentials for S3 access.
(string accessKey, string secretKey) = await CreateEc2CredentialsAsync(
token, userId, projectId, connection.AuthUrl, ct);
// Step 4: Determine the S3 endpoint from the region.
string endpoint = GetS3Endpoint(connection.Region ?? "Kna1");
// Step 5: Create the bucket using the S3 API.
await CreateS3BucketAsync(endpoint, accessKey, secretKey, bucketName, connection.Region ?? "Kna1", ct);
// Step 6: Apply a default bucket policy that allows full CRUD on objects.
// Without this, the bucket exists but object operations may be denied.
string defaultPolicy = BuildDefaultBucketPolicy(bucketName);
using AmazonS3Client policyClient = CreateS3Client(endpoint, accessKey, secretKey, connection.Region ?? "Kna1");
await policyClient.PutBucketPolicyAsync(new PutBucketPolicyRequest
{
BucketName = bucketName,
Policy = defaultPolicy
}, ct);
return new CleuraS3ProvisionResult
{
BucketName = bucketName,
Endpoint = endpoint,
AccessKey = accessKey,
SecretKey = secretKey,
Region = connection.Region ?? "Kna1"
};
}
/// <summary>
/// Authenticates against OpenStack Keystone v3 using password auth.
/// Returns the scoped auth token and the user ID (needed for EC2 credential creation).
/// </summary>
private async Task<(string Token, string UserId, string ProjectId)> AuthenticateKeystoneAsync(
OpenStackConnection connection, string password, CancellationToken ct)
{
// Build the Keystone v3 auth request body.
// We request a project-scoped token so the EC2 credentials inherit the right scope.
object authBody = new
{
auth = new
{
identity = new
{
methods = new[] { "password" },
password = new
{
user = new
{
name = connection.Username,
password,
domain = new { name = connection.UserDomainName ?? "Default" }
}
}
},
scope = new
{
project = connection.ProjectId is not null
? (object)new { id = connection.ProjectId }
: new { name = connection.ProjectName, domain = new { name = connection.ProjectDomainName ?? "Default" } }
}
}
};
string json = JsonSerializer.Serialize(authBody);
using HttpClient client = httpClientFactory.CreateClient();
string authUrl = connection.AuthUrl.TrimEnd('/');
// Ensure the URL includes the Keystone v3 path segment.
// Users may store just the base (e.g. https://identity.example.com:5000)
// or the full path (https://identity.example.com:5000/v3).
if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase))
{
authUrl += "/v3";
}
using HttpRequestMessage request = new(HttpMethod.Post, $"{authUrl}/auth/tokens")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
using HttpResponseMessage response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException(
$"Keystone authentication failed ({response.StatusCode}): {errorBody}");
}
// The token is returned in the X-Subject-Token header.
string token = response.Headers.GetValues("X-Subject-Token").First();
// Extract the user ID and project ID from the response body.
// The project ID here is always a UUID, even if we authenticated by project name.
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
JsonElement tokenElement = doc.RootElement.GetProperty("token");
string userId = tokenElement
.GetProperty("user")
.GetProperty("id")
.GetString()!;
string projectId = tokenElement
.GetProperty("project")
.GetProperty("id")
.GetString()!;
return (token, userId, projectId);
}
/// <summary>
/// Creates EC2 credentials via the Keystone v3 credentials API.
/// EC2 credentials provide an access/secret key pair that can be used
/// with any S3-compatible client against the OpenStack Object Store.
/// </summary>
private async Task<(string AccessKey, string SecretKey)> CreateEc2CredentialsAsync(
string token, string userId, string projectId, string rawAuthUrl, CancellationToken ct)
{
// The EC2 credentials API lives at /v3/users/{userId}/credentials/OS-EC2
string authUrl = rawAuthUrl.TrimEnd('/');
if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase))
{
authUrl += "/v3";
}
string url = $"{authUrl}/users/{userId}/credentials/OS-EC2";
object body = new
{
tenant_id = projectId
};
string json = JsonSerializer.Serialize(body);
using HttpClient client = httpClientFactory.CreateClient();
using HttpRequestMessage request = new(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Auth-Token", token);
using HttpResponseMessage response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException(
$"Failed to create EC2 credentials ({response.StatusCode}): {errorBody}");
}
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
JsonElement credential = doc.RootElement.GetProperty("credential");
string accessKey = credential.GetProperty("access").GetString()!;
string secretKey = credential.GetProperty("secret").GetString()!;
return (accessKey, secretKey);
}
/// <summary>
/// Creates an S3 bucket using the AWS SDK against Cleura's S3-compatible endpoint.
/// </summary>
private static async Task CreateS3BucketAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
PutBucketRequest putRequest = new()
{
BucketName = bucketName
};
await s3Client.PutBucketAsync(putRequest, ct);
}
/// <summary>
/// Maps a Cleura region code to the S3 endpoint URL.
/// Cleura exposes S3-compatible object storage at region-specific endpoints.
/// </summary>
private static string GetS3Endpoint(string region)
{
// Cleura/City Cloud S3 endpoint pattern (standard HTTPS port).
// Known regions: Kna1, Sto2, Fra1, Lon1, etc.
return $"https://s3-{region.ToLowerInvariant()}.citycloud.com";
}
/// <summary>
/// Builds a default S3 bucket policy that grants the bucket owner full CRUD
/// access to all objects. This is applied immediately after bucket creation
/// so the EC2 credentials can read, write, list, and delete objects.
/// </summary>
private static string BuildDefaultBucketPolicy(string bucketName)
{
// Standard S3 bucket policy: allow all object operations for the bucket owner.
// The "*" principal with the specific bucket ARN scopes this to the
// credentials that created the bucket (EC2 credentials are project-scoped).
return $$"""
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowFullObjectAccess",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::{{bucketName}}",
"arn:aws:s3:::{{bucketName}}/*"
]
}
]
}
""";
}
// ──────── Bucket Management ────────
/// <summary>
/// Lists all buckets accessible with the stored EC2 credentials for a given storage link.
/// This allows the user to see what buckets exist under their project
/// without needing to open a separate tool.
/// </summary>
public async Task<List<S3BucketInfo>> ListBucketsAsync(
string endpoint, string accessKey, string secretKey, string region, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
ListBucketsResponse response = await s3Client.ListBucketsAsync(ct);
return response.Buckets
.Select(b => new S3BucketInfo { Name = b.BucketName, CreatedAt = b.CreationDate.GetValueOrDefault() })
.ToList();
}
/// <summary>
/// Deletes an S3 bucket. The bucket must be empty before deletion.
/// This removes the bucket from the object store — it does NOT remove
/// the StorageLink record (the caller handles that separately).
/// </summary>
public async Task DeleteBucketAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
// Empty the bucket first — S3 won't delete non-empty buckets.
// We page through all objects and delete them in batches of up to 1000.
string? continuationToken = null;
do
{
ListObjectsV2Request listRequest = new()
{
BucketName = bucketName,
MaxKeys = 1000,
ContinuationToken = continuationToken
};
ListObjectsV2Response listResponse = await s3Client.ListObjectsV2Async(listRequest, ct);
if (listResponse.S3Objects.Count > 0)
{
DeleteObjectsRequest deleteObjectsRequest = new()
{
BucketName = bucketName,
Objects = listResponse.S3Objects
.Select(o => new KeyVersion { Key = o.Key })
.ToList()
};
await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ct);
}
continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null;
}
while (continuationToken is not null);
// Remove the bucket policy before deletion. Ceph RADOS Gateway (used by Cleura)
// may reject DeleteBucket if a policy is still attached.
try
{
await s3Client.DeleteBucketPolicyAsync(new DeleteBucketPolicyRequest { BucketName = bucketName }, ct);
}
catch (AmazonS3Exception)
{
// No policy to remove — that's fine, proceed with deletion.
}
DeleteBucketRequest deleteRequest = new() { BucketName = bucketName };
await s3Client.DeleteBucketAsync(deleteRequest, ct);
}
/// <summary>
/// Gets the CORS configuration for a bucket.
/// Returns null if no CORS configuration is set.
/// </summary>
public async Task<List<S3CorsRule>?> GetBucketCorsAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
try
{
GetCORSConfigurationRequest request = new() { BucketName = bucketName };
GetCORSConfigurationResponse response = await s3Client.GetCORSConfigurationAsync(request, ct);
return response.Configuration.Rules
.Select(r => new S3CorsRule
{
AllowedOrigins = r.AllowedOrigins,
AllowedMethods = r.AllowedMethods,
AllowedHeaders = r.AllowedHeaders,
MaxAgeSeconds = r.MaxAgeSeconds ?? 0
})
.ToList();
}
catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchCORSConfiguration")
{
return null;
}
}
/// <summary>
/// Sets the CORS configuration for a bucket. Replaces any existing rules.
/// Pass an empty list to effectively disable CORS (removes the configuration).
/// </summary>
public async Task SetBucketCorsAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region,
List<S3CorsRule> rules, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
if (rules.Count == 0)
{
// Remove CORS configuration entirely.
DeleteCORSConfigurationRequest deleteRequest = new() { BucketName = bucketName };
await s3Client.DeleteCORSConfigurationAsync(deleteRequest, ct);
return;
}
CORSConfiguration corsConfig = new()
{
Rules = rules.Select(r => new CORSRule
{
AllowedOrigins = r.AllowedOrigins,
AllowedMethods = r.AllowedMethods,
AllowedHeaders = r.AllowedHeaders,
MaxAgeSeconds = r.MaxAgeSeconds
}).ToList()
};
PutCORSConfigurationRequest putRequest = new()
{
BucketName = bucketName,
Configuration = corsConfig
};
await s3Client.PutCORSConfigurationAsync(putRequest, ct);
}
/// <summary>
/// Gets the bucket policy as a JSON string. Returns null if no policy is set.
/// </summary>
public async Task<string?> GetBucketPolicyAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
try
{
GetBucketPolicyRequest request = new() { BucketName = bucketName };
GetBucketPolicyResponse response = await s3Client.GetBucketPolicyAsync(request, ct);
return response.Policy;
}
catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucketPolicy")
{
return null;
}
}
/// <summary>
/// Sets the bucket policy from a JSON string. Pass null to remove the policy.
/// The policy JSON must be a valid S3 bucket policy document.
/// </summary>
public async Task SetBucketPolicyAsync(
string endpoint, string accessKey, string secretKey, string bucketName, string region,
string? policyJson, CancellationToken ct = default)
{
using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region);
if (policyJson is null)
{
DeleteBucketPolicyRequest deleteRequest = new() { BucketName = bucketName };
await s3Client.DeleteBucketPolicyAsync(deleteRequest, ct);
return;
}
PutBucketPolicyRequest putRequest = new()
{
BucketName = bucketName,
Policy = policyJson
};
await s3Client.PutBucketPolicyAsync(putRequest, ct);
}
// ──────── EC2 Credential Rotation ────────
/// <summary>
/// Rotates the EC2 credentials for a Cleura S3 storage link.
/// This creates new credentials via Keystone, updates the vault,
/// and deletes the old credentials. The bucket itself is not affected —
/// only the access/secret key pair changes.
///
/// Flow:
/// 1. Authenticate with Keystone using the stored password
/// 2. Create new EC2 credentials
/// 3. Verify the new credentials work (list bucket)
/// 4. Return the new credentials (caller updates vault)
/// </summary>
public async Task<(string AccessKey, string SecretKey)> RotateCredentialsAsync(
Guid tenantId, OpenStackConnection connection, string bucketName, CancellationToken ct = default)
{
// Retrieve the OpenStack password from vault.
string? password = await vaultService.GetOpenStackSecretValueAsync(
tenantId, connection.Id, "OS_PASSWORD", ct);
if (string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException(
"OpenStack password not found in vault. Re-enter the password in the connection settings.");
}
// Authenticate and create fresh EC2 credentials.
(string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct);
(string newAccessKey, string newSecretKey) = await CreateEc2CredentialsAsync(token, userId, projectId, connection.AuthUrl, ct);
// Verify the new credentials work by listing the bucket.
string endpoint = GetS3Endpoint(connection.Region ?? "Kna1");
using AmazonS3Client s3Client = CreateS3Client(endpoint, newAccessKey, newSecretKey, connection.Region ?? "Kna1");
try
{
ListObjectsV2Request listRequest = new() { BucketName = bucketName, MaxKeys = 1 };
await s3Client.ListObjectsV2Async(listRequest, ct);
}
catch (AmazonS3Exception ex)
{
throw new InvalidOperationException(
$"New credentials failed verification against bucket '{bucketName}': {ex.Message}", ex);
}
return (newAccessKey, newSecretKey);
}
// ──────── Helpers ────────
/// <summary>
/// Creates a configured AmazonS3Client for Cleura's S3-compatible endpoint.
/// </summary>
private static AmazonS3Client CreateS3Client(string endpoint, string accessKey, string secretKey, string region)
{
AmazonS3Config config = new()
{
ServiceURL = endpoint,
ForcePathStyle = true,
AuthenticationRegion = region,
// Disable automatic region detection — we know the endpoint.
// Without this, the SDK may hang trying to resolve the bucket region.
UseHttp = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase),
Timeout = TimeSpan.FromSeconds(30),
MaxErrorRetry = 1
};
BasicAWSCredentials credentials = new(accessKey, secretKey);
return new AmazonS3Client(credentials, config);
}
}