version .1
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
Submodule .template-cache/2dc03e2b2b45fef3 updated: 292fbb4bfe...af33306def
@@ -33,7 +33,6 @@ public static class AppConstants
|
||||
public static string[] AllCustomerMysqlSecretNames(string abbrev)
|
||||
=> new[]
|
||||
{
|
||||
CustomerMysqlPasswordSecretName(abbrev),
|
||||
CustomerMysqlUserSecretName(abbrev),
|
||||
};
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public class ComposeRenderService
|
||||
.Replace("{{MYSQL_PORT}}", ctx.MySqlPort)
|
||||
.Replace("{{MYSQL_DATABASE}}", ctx.MySqlDatabase)
|
||||
.Replace("{{MYSQL_USER}}", ctx.MySqlUser)
|
||||
.Replace("{{MYSQL_PASSWORD}}", ctx.MySqlPassword)
|
||||
.Replace("{{SMTP_SERVER}}", ctx.SmtpServer)
|
||||
.Replace("{{SMTP_USERNAME}}", ctx.SmtpUsername)
|
||||
.Replace("{{SMTP_PASSWORD}}", ctx.SmtpPassword)
|
||||
@@ -168,7 +169,7 @@ public class ComposeRenderService
|
||||
MYSQL_PORT: "{{MYSQL_PORT}}"
|
||||
MYSQL_DATABASE: {{MYSQL_DATABASE}}
|
||||
MYSQL_USER: {{MYSQL_USER}}
|
||||
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
|
||||
MYSQL_PASSWORD: {{MYSQL_PASSWORD}}
|
||||
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
|
||||
CMS_SMTP_SERVER: {{SMTP_SERVER}}
|
||||
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
|
||||
@@ -182,7 +183,6 @@ public class ComposeRenderService
|
||||
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
|
||||
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
|
||||
secrets:
|
||||
- {{ABBREV}}-cms-db-password
|
||||
- {{ABBREV}}-cms-db-user
|
||||
- global_mysql_host
|
||||
- global_mysql_port
|
||||
@@ -280,8 +280,6 @@ public class ComposeRenderService
|
||||
o: "{{NFS_OPTS}}"
|
||||
|
||||
secrets:
|
||||
{{ABBREV}}-cms-db-password:
|
||||
external: true
|
||||
{{ABBREV}}-cms-db-user:
|
||||
external: true
|
||||
global_mysql_host:
|
||||
@@ -314,6 +312,7 @@ public class RenderContext
|
||||
public string MySqlPort { get; set; } = "3306";
|
||||
public string MySqlDatabase { get; set; } = "cms";
|
||||
public string MySqlUser { get; set; } = "cms";
|
||||
public string MySqlPassword { get; set; } = string.Empty;
|
||||
|
||||
// SMTP
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
|
||||
@@ -111,7 +111,6 @@ public class ComposeValidationService
|
||||
{
|
||||
var requiredSecrets = new[]
|
||||
{
|
||||
AppConstants.CustomerMysqlPasswordSecretName(customerAbbrev),
|
||||
AppConstants.CustomerMysqlUserSecretName(customerAbbrev),
|
||||
AppConstants.GlobalMysqlHostSecretName,
|
||||
AppConstants.GlobalMysqlPortSecretName,
|
||||
|
||||
@@ -101,18 +101,12 @@ public class InstanceService
|
||||
await _docker.RemoveStackAsync(stackName);
|
||||
await Task.Delay(2000);
|
||||
|
||||
// ── 2. Generate MySQL credentials → Docker Swarm secrets ────────
|
||||
// ── 2. Generate MySQL credentials ──────────────────────────────
|
||||
var mysqlPassword = GenerateRandomPassword(32);
|
||||
var mySqlUserName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var pwdSecretName = CustomerMysqlPasswordSecretName(abbrev);
|
||||
var userSecretName = CustomerMysqlUserSecretName(abbrev);
|
||||
|
||||
var (pwdOk, _) = await _secrets.EnsureSecretAsync(pwdSecretName, mysqlPassword, rotate: true);
|
||||
if (!pwdOk)
|
||||
throw new InvalidOperationException($"Failed to create/rotate Docker secret '{pwdSecretName}'. Is a stale stack still referencing it?");
|
||||
_logger.LogInformation("Docker secret created/rotated: {SecretName}", pwdSecretName);
|
||||
|
||||
var (userOk, _) = await _secrets.EnsureSecretAsync(userSecretName, mySqlUserName, rotate: true);
|
||||
if (!userOk)
|
||||
throw new InvalidOperationException($"Failed to create/rotate Docker secret '{userSecretName}'.");
|
||||
@@ -132,7 +126,12 @@ public class InstanceService
|
||||
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
|
||||
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
|
||||
|
||||
mysqlPassword = string.Empty;
|
||||
// ── 2c. Persist password (encrypted) for future redeploys ────────
|
||||
await _settings.SetAsync(
|
||||
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
|
||||
SettingsService.CatInstance, isSensitive: true);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev);
|
||||
|
||||
// ── 3. Read settings ────────────────────────────────────────────
|
||||
var mySqlHost = mySqlHostValue;
|
||||
@@ -185,6 +184,7 @@ public class InstanceService
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
MySqlPassword = mysqlPassword,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
@@ -244,6 +244,8 @@ public class InstanceService
|
||||
if (!deployResult.Success)
|
||||
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
|
||||
|
||||
mysqlPassword = string.Empty;
|
||||
|
||||
sw.Stop();
|
||||
opLog.Status = OperationStatus.Success;
|
||||
opLog.Message = $"Instance deployed: {stackName}";
|
||||
@@ -296,6 +298,14 @@ public class InstanceService
|
||||
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
// Retrieve the stored MySQL password (encrypted in settings since creation)
|
||||
var mySqlPassword = await _settings.GetAsync(SettingsService.InstanceMySqlPassword(abbrev));
|
||||
if (string.IsNullOrEmpty(mySqlPassword))
|
||||
throw new InvalidOperationException(
|
||||
$"No stored MySQL password found for instance '{abbrev}'. " +
|
||||
"The instance may have been created before password persistence was added. " +
|
||||
"Use 'Rotate Password' to generate and store a new one.");
|
||||
|
||||
// Ensure MySQL Docker secrets exist (idempotent)
|
||||
var userSecretName = CustomerMysqlUserSecretName(abbrev);
|
||||
await _secrets.EnsureSecretAsync(userSecretName, mySqlUser);
|
||||
@@ -357,6 +367,7 @@ public class InstanceService
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
MySqlPassword = mySqlPassword,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
@@ -470,6 +481,12 @@ public class InstanceService
|
||||
|
||||
foreach (var secretName in AllCustomerMysqlSecretNames(abbrev))
|
||||
await _secrets.DeleteSecretAsync(secretName);
|
||||
|
||||
// Remove the stored password from local settings
|
||||
await _settings.SetAsync(
|
||||
SettingsService.InstanceMySqlPassword(abbrev), null,
|
||||
SettingsService.CatInstance, isSensitive: false);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
@@ -506,23 +523,11 @@ public class InstanceService
|
||||
public async Task<(bool Success, string Message)> RotateMySqlPasswordAsync(string stackName, string? userId = null)
|
||||
{
|
||||
var abbrev = ExtractAbbrev(stackName);
|
||||
var secretName = CustomerMysqlPasswordSecretName(abbrev);
|
||||
var tempSecret = $"{secretName}-rot";
|
||||
var webService = $"{stackName}_{abbrev}-web";
|
||||
var newPassword = GenerateRandomPassword(32);
|
||||
|
||||
_logger.LogInformation("Rotating MySQL password for instance {StackName}", stackName);
|
||||
|
||||
// ── Step 1: Create temp secret (new value, different name) ────────────
|
||||
// We cannot delete the live secret while the service is using it, so we
|
||||
// stage the new value under a temporary name first.
|
||||
var (tempCreated, _) = await _secrets.EnsureSecretAsync(tempSecret, newPassword, rotate: false);
|
||||
if (!tempCreated)
|
||||
return (false, $"Failed to create temporary rotation secret '{tempSecret}'.");
|
||||
|
||||
// ── Step 2: Update MySQL password via SSH ─────────────────────────────
|
||||
// Connect through the Docker SSH host so the desktop app doesn't need
|
||||
// direct TCP access to the MySQL server.
|
||||
// ── Step 1: Update MySQL password via SSH tunnel ─────────────────────
|
||||
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root");
|
||||
@@ -535,51 +540,30 @@ public class InstanceService
|
||||
mySqlHost, port, mySqlAdminUser, mySqlAdminPassword, mySqlUser, newPassword);
|
||||
|
||||
if (!mysqlOk)
|
||||
{
|
||||
// No service swap has happened yet — clean up temp and abort cleanly.
|
||||
await _secrets.DeleteSecretAsync(tempSecret);
|
||||
return (false, $"MySQL update failed (no service disruption): {mysqlErr}");
|
||||
}
|
||||
return (false, $"MySQL ALTER USER failed (no service disruption): {mysqlErr}");
|
||||
|
||||
_logger.LogInformation("MySQL password updated for user {User}", mySqlUser);
|
||||
|
||||
// ── Step 3: Swap service from old secret → temp (same /run/secrets/ path)
|
||||
// Triggers rolling restart; containers pick up the new password.
|
||||
if (!await _docker.ServiceSwapSecretAsync(webService, secretName, tempSecret, targetAlias: secretName))
|
||||
{
|
||||
// Rollback MySQL — try to restore old password. We don't know it, so
|
||||
// log a warning; the temp secret can be cleaned up manually.
|
||||
_logger.LogError(
|
||||
"Service swap failed. MySQL has new password but service still uses old secret. " +
|
||||
"Manual intervention may be required for {Service}", webService);
|
||||
return (false, $"Service swap failed for '{webService}'. MySQL password was updated but secret swap did not complete.");
|
||||
}
|
||||
// ── Step 2: Persist new password in settings (encrypted) ─────────────
|
||||
await _settings.SetAsync(
|
||||
SettingsService.InstanceMySqlPassword(abbrev), newPassword,
|
||||
SettingsService.CatInstance, isSensitive: true);
|
||||
await _db.SaveChangesAsync();
|
||||
_logger.LogInformation("New MySQL password stored in settings for instance {Abbrev}", abbrev);
|
||||
|
||||
_logger.LogInformation("Service {Service} rolling restart with temp secret {TempSecret}", webService, tempSecret);
|
||||
|
||||
// ── Step 4: Delete the old secret (now unreferenced by any service) ───
|
||||
await _secrets.DeleteSecretAsync(secretName);
|
||||
|
||||
// ── Step 5: Recreate permanent secret under the original name ─────────
|
||||
var (secretCreated, secretId) = await _secrets.EnsureSecretAsync(secretName, newPassword, rotate: false);
|
||||
if (!secretCreated)
|
||||
{
|
||||
_logger.LogError("Failed to recreate permanent secret {SecretName}; temp secret remains active", secretName);
|
||||
return (false, $"Rotation partially complete: temp secret '{tempSecret}' is active but '{secretName}' could not be recreated.");
|
||||
}
|
||||
|
||||
// ── Step 6: Swap service back to permanent name; second rolling restart ─
|
||||
if (!await _docker.ServiceSwapSecretAsync(webService, tempSecret, secretName))
|
||||
// ── Step 3: Force-update the web service to pick up new compose env ──
|
||||
// Redeploy the stack so the MYSQL_PASSWORD env var gets the new value.
|
||||
var webService = $"{stackName}_{abbrev}-web";
|
||||
if (!await _docker.ForceUpdateServiceAsync(webService))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Final swap to permanent secret failed for {Service}; temp secret '{TempSecret}' is still active. " +
|
||||
"Service is functional but secret name normalisation is pending.", webService, tempSecret);
|
||||
return (true, $"MySQL password rotated for '{stackName}' (temp secret active; manual final swap may be needed).");
|
||||
"Force-update of service {Service} failed. MySQL has the new password but " +
|
||||
"the container is still running with the old value. A manual redeploy may be needed.",
|
||||
webService);
|
||||
return (true, $"MySQL password rotated for '{stackName}' but service force-update failed. Redeploy to apply.");
|
||||
}
|
||||
|
||||
await _secrets.DeleteSecretAsync(tempSecret);
|
||||
|
||||
_logger.LogInformation("Docker secret fully rotated: {SecretName} (new id={SecretId})", secretName, secretId);
|
||||
_logger.LogInformation("Service {Service} force-updated with new password", webService);
|
||||
return (true, $"MySQL password rotated successfully for '{stackName}'.");
|
||||
}
|
||||
|
||||
@@ -750,8 +734,6 @@ public class InstanceService
|
||||
|
||||
private static string GenerateRandomPassword(int length)
|
||||
{
|
||||
// Only alphanumeric chars — special characters (! @ # $ % ^ & *) can be
|
||||
// mangled by shell escaping in the printf→docker-secret-create SSH pipeline.
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
return RandomNumberGenerator.GetString(chars, length);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ public class SettingsService
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
// Instance-specific (keyed by abbreviation)
|
||||
/// <summary>
|
||||
/// Builds a per-instance settings key for the MySQL password.
|
||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
||||
/// </summary>
|
||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||
public const string CatInstance = "Instance";
|
||||
|
||||
public SettingsService(
|
||||
XiboContext db,
|
||||
IDataProtectionProvider dataProtection,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
MYSQL_PORT: "{{MYSQL_PORT}}"
|
||||
MYSQL_DATABASE: {{MYSQL_DATABASE}}
|
||||
MYSQL_USER: {{MYSQL_USER}}
|
||||
MYSQL_PASSWORD_FILE: /run/secrets/{{ABBREV}}-cms-db-password
|
||||
MYSQL_PASSWORD: {{MYSQL_PASSWORD}}
|
||||
CMS_SERVER_NAME: {{CMS_SERVER_NAME}}
|
||||
CMS_SMTP_SERVER: {{SMTP_SERVER}}
|
||||
CMS_SMTP_USERNAME: {{SMTP_USERNAME}}
|
||||
@@ -26,7 +26,6 @@ services:
|
||||
CMS_PHP_UPLOAD_MAX_FILESIZE: {{PHP_UPLOAD_MAX_FILESIZE}}
|
||||
CMS_PHP_MAX_EXECUTION_TIME: "{{PHP_MAX_EXECUTION_TIME}}"
|
||||
secrets:
|
||||
- {{ABBREV}}-cms-db-password
|
||||
- {{ABBREV}}-cms-db-user
|
||||
- global_mysql_host
|
||||
- global_mysql_port
|
||||
@@ -124,8 +123,6 @@ volumes:
|
||||
o: "{{NFS_OPTS}}"
|
||||
|
||||
secrets:
|
||||
{{ABBREV}}-cms-db-password:
|
||||
external: true
|
||||
{{ABBREV}}-cms-db-user:
|
||||
external: true
|
||||
global_mysql_host:
|
||||
|
||||
Reference in New Issue
Block a user