Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,25 @@ You're good to go! You should be able to see the effect of the CRDs in your SQL

![Azure Data Studio](assets/ads-screenshot.png)

### Delete database when deleting the crd

By default, if you delete the CRD of you database, the database is not deleted.

If you want to delete the database when deleting the CRD, use the directive databaseReclaimPolicy with the value Delete, Retain being the default value if not specified.

```yaml
---
apiVersion: sql-server.dotkube.io/v1alpha1
kind: Database
metadata:
name: bar
namespace: sqlserver-example
spec:
instanceName: sqlserver-instance
databaseName: Bar
databaseReclaimPolicy: Delete
```

## Planned Features and Roadmap

Here are the planned features and milestones for KubeSQLServer Operator:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ spec:
databaseName:
description: The name of the database to be created.
type: string
databaseReclaimPolicy:
description: Policy that controls what happens to the physical database when the CR is deleted. Either 'Retain' or 'Delete'. Default 'Retain'.
type: string
type: object
type: object
served: true
Expand Down
3 changes: 3 additions & 0 deletions dev/dev-helm-chart/crds/databases_sql-server_dotkube_io.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ spec:
databaseName:
description: The name of the database to be created.
type: string
databaseReclaimPolicy:
description: Policy that controls what happens to the physical database when the CR is deleted. Either 'Retain' or 'Delete'. Default 'Retain'.
type: string
type: object
type: object
served: true
Expand Down
4 changes: 1 addition & 3 deletions src/OperatorTemplate.Operator/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ WORKDIR /operator
# Copy the entire solution
COPY . .

# Restore dependencies and build the operator
WORKDIR /operator/src/OperatorTemplate.Operator
RUN dotnet restore
RUN curl -L -o cfssl https://github.com/cloudflare/cfssl/releases/download/v1.5.0/cfssl_1.5.0_linux_amd64
RUN curl -L -o cfssljson https://github.com/cloudflare/cfssl/releases/download/v1.5.0/cfssljson_1.5.0_linux_amd64
Expand All @@ -22,7 +20,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN groupadd -r k8s-operator && useradd -r -g k8s-operator operator-user

WORKDIR /operator
COPY --from=build /operator/src/OperatorTemplate.Operator/out/ ./
COPY --from=build /operator/out/ ./
RUN chown operator-user:k8s-operator -R .

# Run as non-root user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,33 @@ public async Task<ReconciliationResult<V1Alpha1SQLServerDatabase>> ReconcileAsyn
}
}

public Task<ReconciliationResult<V1Alpha1SQLServerDatabase>> DeletedAsync(V1Alpha1SQLServerDatabase entity, CancellationToken cancellationToken)
public async Task<ReconciliationResult<V1Alpha1SQLServerDatabase>> DeletedAsync(V1Alpha1SQLServerDatabase entity, CancellationToken cancellationToken)
{
logger.LogInformation("Deleted SQLServerDatabase: {Name}", entity.Metadata.Name);
return Task.FromResult(ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity));

try
{
// Respect reclaim policy (default: Retain)
var reclaimPolicy = entity.Spec.DatabaseReclaimPolicy ?? "Retain";
if (!string.Equals(reclaimPolicy, "Delete", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Database reclaim policy is '{Policy}'; skipping physical database deletion for: {Name}", reclaimPolicy, entity.Metadata.Name);
return ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity);
}

var secretName = await DetermineSecretNameAsync(entity);
var (server, username, password) = await GetSqlServerCredentialsAsync(entity, secretName);

await EnsureDatabaseDeletedAsync(entity.Spec.DatabaseName, server, username, password);

logger.LogInformation("Database '{DatabaseName}' deletion attempted for SQLServerDatabase: {Name}", entity.Spec.DatabaseName, entity.Metadata.Name);
}
catch (Exception ex)
{
logger.LogError(ex, "Error while deleting database for SQLServerDatabase: {Name}", entity.Metadata.Name);
}

return ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity);
}

private async Task<string> DetermineSecretNameAsync(V1Alpha1SQLServerDatabase entity)
Expand Down Expand Up @@ -125,6 +148,40 @@ IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = @DatabaseName)
}
}

private async Task EnsureDatabaseDeletedAsync(string databaseName, string server, string username, string password)
{
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
UserID = username,
Password = password,
InitialCatalog = "master",
TrustServerCertificate = true,
Encrypt = false,
};

var connectionString = builder.ConnectionString;

logger.LogInformation("Ensuring database '{DatabaseName}' is deleted on server '{Server}'.", databaseName, server);

try
{
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();

var commandText = $"IF EXISTS (SELECT name FROM sys.databases WHERE name = @DatabaseName) DROP DATABASE [{databaseName}]";
using var command = new SqlCommand(commandText, connection);
command.Parameters.AddWithValue("@DatabaseName", databaseName);

await command.ExecuteNonQueryAsync();
logger.LogInformation("Database '{DatabaseName}' dropped on server '{Server}'.", databaseName, server);
}
catch (Exception ex)
{
throw new Exception($"Failed to delete database '{databaseName}': {ex.Message}", ex);
}
}

private async Task UpdateStatusAsync(V1Alpha1SQLServerDatabase entity, string state, string message, DateTime? lastChecked)
{
entity.Status ??= new V1Alpha1SQLServerDatabase.V1Alpha1SQLServerDatabaseStatus();
Expand Down
3 changes: 3 additions & 0 deletions src/OperatorTemplate.Operator/Entities/V1Alpha1/Database.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public class V1Alpha1SQLServerDatabaseSpec

[Description("The name of the database to be created.")]
public string DatabaseName { get; set; } = string.Empty;

[Description("Policy that controls what happens to the physical database when the CR is deleted. Either 'Retain' or 'Delete'. Default: 'Retain'.")]
public string DatabaseReclaimPolicy { get; set; } = "Retain";
}

[Description("Status of the SQL Server database.")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using k8s.Models;
using KubeOps.Abstractions.Reconciliation;
using KubeOps.Abstractions.Reconciliation.Finalizer;
using KubeOps.KubernetesClient;
using Microsoft.Data.SqlClient;
using SqlServerOperator.Controllers.Services;
using SqlServerOperator.Entities;
using System.Text;

namespace SqlServerOperator.Finalizers;

public class SqlServerDatabaseFinalizer(
ILogger<SqlServerDatabaseFinalizer> logger,
IKubernetesClient kubernetesClient,
SqlServerEndpointService sqlServerEndpointService
) : IEntityFinalizer<V1Alpha1SQLServerDatabase>
{
public async Task<ReconciliationResult<V1Alpha1SQLServerDatabase>> FinalizeAsync(V1Alpha1SQLServerDatabase entity, CancellationToken cancellationToken)
{
logger.LogInformation("Finalizing SQLServerDatabase: {Name}", entity.Metadata.Name);

try
{
// Respect reclaim policy (default: Retain)
var reclaimPolicy = entity.Spec.DatabaseReclaimPolicy ?? "Retain";
if (!string.Equals(reclaimPolicy, "Delete", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Database reclaim policy is '{Policy}'; skipping physical database deletion for: {Name}", reclaimPolicy, entity.Metadata.Name);
return ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity);
}

var sqlServer = await kubernetesClient.GetAsync<V1Alpha1SQLServer>(entity.Spec.InstanceName, entity.Metadata.NamespaceProperty);
if (sqlServer is null)
{
logger.LogWarning("SQLServer instance '{SqlServerName}' not found. Skipping finalization.", entity.Spec.InstanceName);
return ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity);
}

var server = await sqlServerEndpointService.GetSqlServerEndpointAsync(sqlServer.Metadata.Name, sqlServer.Metadata.NamespaceProperty);
var secretName = sqlServer.Spec.SecretName ?? $"{sqlServer.Metadata.Name}-secret";
var (username, password) = await GetSqlServerCredentialsAsync(secretName, entity.Metadata.NamespaceProperty);
await DeleteDatabaseAsync(entity.Spec.DatabaseName, server, username, password);

logger.LogInformation("Finalization complete for SQLServerDatabase: {Name}", entity.Metadata.Name);
return ReconciliationResult<V1Alpha1SQLServerDatabase>.Success(entity);
}
catch (Exception ex)
{
logger.LogError(ex, "Error during finalization of SQLServerDatabase: {Name}", entity.Metadata.Name);
return ReconciliationResult<V1Alpha1SQLServerDatabase>.Failure(entity, ex.Message, ex);
}
}

private async Task<(string username, string password)> GetSqlServerCredentialsAsync(string secretName, string namespaceName)
{
var secret = await kubernetesClient.GetAsync<V1Secret>(secretName, namespaceName);
if (secret?.Data is null || !secret.Data.ContainsKey("password"))
{
throw new Exception($"Secret '{secretName}' does not contain the expected 'password' key.");
}

var password = Encoding.UTF8.GetString(secret.Data["password"]);
var username = "sa";

return (username, password);
}

private async Task DeleteDatabaseAsync(string databaseName, string server, string username, string password)
{
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
UserID = username,
Password = password,
InitialCatalog = "master",
TrustServerCertificate = true,
Encrypt = false,
};

using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

var commandText = $"IF EXISTS (SELECT name FROM sys.databases WHERE name = @DatabaseName) DROP DATABASE [{databaseName}]";

using var command = new SqlCommand(commandText, connection);
command.Parameters.AddWithValue("@DatabaseName", databaseName);
await command.ExecuteNonQueryAsync();
}
}