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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ template/SimpleModule.Host/wwwroot/*
!template/SimpleModule.Host/wwwroot/favicon.svg
!template/SimpleModule.Host/wwwroot/index.html
!template/SimpleModule.Host/wwwroot/error.html
!template/SimpleModule.Host/wwwroot/maintenance.html

# Tailwind CSS module scan directory
template/SimpleModule.Host/Styles/_scan/
Expand Down
127 changes: 127 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using SimpleModule.Cli.Infrastructure;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Maintenance;

public sealed class DownCommand : Command<DownSettings>
{
public override int Execute(CommandContext context, DownSettings settings)
{
var solution = SolutionContext.Discover();
if (solution is null)
{
AnsiConsole.MarkupLine(
"[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]"
);
return 1;
}

var sentinelPath = MaintenanceSentinelFile.ResolvePath(solution);

if (settings.Status)
{
return PrintStatus(sentinelPath);
}

DateTimeOffset? until = null;
if (!string.IsNullOrWhiteSpace(settings.Until))
{
if (
!DateTimeOffset.TryParse(
settings.Until,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed
)
)
{
AnsiConsole.MarkupLine(
$"[red]--until value '{settings.Until}' is not a valid ISO-8601 timestamp.[/]"
);
return 1;
}
until = parsed;
}

var secret = settings.Secret ?? GenerateSecret();
var retryAfter = settings.RetryAfterSeconds is > 0 ? settings.RetryAfterSeconds.Value : 60;

var sentinel = new MaintenanceSentinel
{
SecretHash = MaintenanceSentinelFile.HashSecret(secret),
Message = settings.Message,
RetryAfterSeconds = retryAfter,
Until = until,
CreatedAt = DateTimeOffset.UtcNow,
};

var dir = Path.GetDirectoryName(sentinelPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}

File.WriteAllText(
sentinelPath,
JsonSerializer.Serialize(sentinel, MaintenanceSentinelFile.JsonOptions)
);

AnsiConsole.MarkupLine("[green]Application is now in maintenance mode.[/]");
AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/]");
AnsiConsole.MarkupLine($" Bypass URL: [yellow]/?sm_bypass={secret.EscapeMarkup()}[/]");
if (settings.Secret is null)
{
AnsiConsole.MarkupLine(
" [grey](no --secret provided; a fresh one was generated above)[/]"
);
}
if (until is { } u)
{
AnsiConsole.MarkupLine(
$" Auto-clears at: [grey]{u.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]"
);
}

return 0;
}

private static int PrintStatus(string sentinelPath)
{
var sentinel = MaintenanceSentinelFile.TryRead(sentinelPath);
if (sentinel is null)
{
AnsiConsole.MarkupLine("[green]Application is up.[/]");
AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/] (absent)");
return 0;
}

AnsiConsole.MarkupLine("[yellow]Application is in maintenance mode.[/]");
AnsiConsole.MarkupLine($" Sentinel: [grey]{sentinelPath.EscapeMarkup()}[/]");
AnsiConsole.MarkupLine(
$" Created at: [grey]{sentinel.CreatedAt.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]"
);
AnsiConsole.MarkupLine($" Retry-After: [grey]{sentinel.RetryAfterSeconds}s[/]");
if (!string.IsNullOrWhiteSpace(sentinel.Message))
{
AnsiConsole.MarkupLine($" Message: [grey]{sentinel.Message.EscapeMarkup()}[/]");
}
if (sentinel.Until is { } u)
{
AnsiConsole.MarkupLine(
$" Until: [grey]{u.ToString("u", CultureInfo.InvariantCulture).EscapeMarkup()}[/]"
);
}
return 0;
}

private static string GenerateSecret()
{
Span<byte> bytes = stackalloc byte[16];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexStringLower(bytes);
}
}
29 changes: 29 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.ComponentModel;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Maintenance;

public sealed class DownSettings : CommandSettings
{
[CommandOption("--secret <SECRET>")]
[Description(
"Shared secret. Visiting ?sm_bypass=<SECRET> sets a bypass cookie. Generated when omitted."
)]
public string? Secret { get; set; }

[CommandOption("-m|--message <MESSAGE>")]
[Description("Message shown on the maintenance page")]
public string? Message { get; set; }

[CommandOption("--retry <SECONDS>")]
[Description("Value sent in the Retry-After response header. Defaults to 60")]
public int? RetryAfterSeconds { get; set; }

[CommandOption("--until <ISO8601>")]
[Description("Optional ISO-8601 timestamp at which maintenance auto-clears (UTC if no offset)")]
public string? Until { get; set; }

[CommandOption("--status")]
[Description("Print the current maintenance state without modifying it")]
public bool Status { get; set; }
}
68 changes: 68 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using SimpleModule.Cli.Infrastructure;

namespace SimpleModule.Cli.Commands.Maintenance;

/// <summary>
/// File-format mirror of <c>SimpleModule.Hosting.Maintenance.MaintenanceModeState</c>.
/// Kept here so the CLI doesn't have to reference the hosting framework — the JSON
/// shape is the contract between them.
/// </summary>
public sealed record MaintenanceSentinel
{
public string? SecretHash { get; init; }
public string? Message { get; init; }
public int RetryAfterSeconds { get; init; } = 60;
public DateTimeOffset? Until { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}

public static class MaintenanceSentinelFile
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

public static string ResolvePath(SolutionContext solution)
{
var hostDir =
Path.GetDirectoryName(solution.ApiCsprojPath)
?? throw new InvalidOperationException(
"Host project directory could not be resolved from solution context."
);
return Path.Combine(hostDir, "App_Data", "maintenance.json");
}

public static string HashSecret(string secret)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret));
return Convert.ToHexStringLower(bytes);
}

public static MaintenanceSentinel? TryRead(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<MaintenanceSentinel>(json, JsonOptions);
}
catch (IOException)
{
return null;
}
catch (JsonException)
{
return null;
}
}
}
33 changes: 33 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using SimpleModule.Cli.Infrastructure;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Maintenance;

public sealed class UpCommand : Command<UpSettings>
{
public override int Execute(CommandContext context, UpSettings settings)
{
var solution = SolutionContext.Discover();
if (solution is null)
{
AnsiConsole.MarkupLine(
"[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]"
);
return 1;
}

var sentinelPath = MaintenanceSentinelFile.ResolvePath(solution);
if (!File.Exists(sentinelPath))
{
AnsiConsole.MarkupLine("[green]Application is already up.[/]");
return 0;
}

File.Delete(sentinelPath);
AnsiConsole.MarkupLine("[green]Maintenance mode cleared. Application is up.[/]");
return 0;
}
}

public sealed class UpSettings : Spectre.Console.Cli.CommandSettings { }
13 changes: 13 additions & 0 deletions cli/SimpleModule.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using SimpleModule.Cli.Commands.Doctor;
using SimpleModule.Cli.Commands.Install;
using SimpleModule.Cli.Commands.List;
using SimpleModule.Cli.Commands.Maintenance;
using SimpleModule.Cli.Commands.New;
using SimpleModule.Cli.Commands.Skill;
using SimpleModule.Cli.Commands.Version;
Expand Down Expand Up @@ -84,6 +85,18 @@
}
);

config
.AddCommand<DownCommand>("down")
.WithDescription(
"Put the application into maintenance mode (writes App_Data/maintenance.json)"
)
.WithExample("down", "--secret", "letmein", "--message", "Migrating database")
.WithExample("down", "--status");

config
.AddCommand<UpCommand>("up")
.WithDescription("Clear maintenance mode (deletes App_Data/maintenance.json)");

config.AddCommand<VersionCommand>("version").WithDescription("Print the sm CLI version");
});

Expand Down
Loading