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
101 changes: 101 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Globalization;
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 contentRoot = ResolveContentRoot();
if (contentRoot is null)
{
AnsiConsole.MarkupLine(
"[red]Could not locate the host project. Run `sm down` from within a SimpleModule solution.[/]"
);
return 1;
}

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

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 must be a valid ISO-8601 timestamp, got `{settings.Until}`[/]"
);
return 1;
}
until = parsed;
}

if (settings.RetryAfterSeconds < 0)
{
AnsiConsole.MarkupLine("[red]--retry must be non-negative.[/]");
return 1;
}

MaintenanceSentinel.Write(
contentRoot,
settings.Secret,
settings.Message,
settings.RetryAfterSeconds,
until
);

AnsiConsole.MarkupLine(
$"[green]Maintenance mode enabled.[/] Sentinel: [dim]{MaintenanceSentinel.PathFor(contentRoot)}[/]"
);

if (!string.IsNullOrEmpty(settings.Secret))
{
AnsiConsole.MarkupLine(
$"Bypass URL: [cyan]https://<your-host>/?sm_bypass={Markup.Escape(settings.Secret)}[/]"
);
AnsiConsole.MarkupLine(
"[yellow]Keep the secret out of shell history when sharing.[/]"
);
}

return 0;
}

private static int PrintStatus(string contentRoot)
{
if (!MaintenanceSentinel.Exists(contentRoot))
{
AnsiConsole.MarkupLine("[green]Live[/] — no maintenance sentinel present.");
return 0;
}

AnsiConsole.MarkupLine(
$"[yellow]Maintenance mode active[/]. Sentinel: [dim]{MaintenanceSentinel.PathFor(contentRoot)}[/]"
);
return 0;
}

private static string? ResolveContentRoot()
{
var solution = SolutionContext.Discover();
if (solution is null)
{
return null;
}
var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath);
return string.IsNullOrEmpty(hostDir) ? null : hostDir;
}
}
30 changes: 30 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.ComponentModel;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Maintenance;

public sealed class DownSettings : CommandSettings
{
[CommandOption("--secret <SECRET>")]
[Description("Bypass secret. Visitors with ?sm_bypass=<secret> are allowed through.")]
public string? Secret { get; set; }

[CommandOption("--message <MESSAGE>")]
[Description("Human-readable message shown on the 503 page.")]
public string? Message { get; set; }

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

[CommandOption("--until <ISO_TIMESTAMP>")]
[Description(
"Optional ISO-8601 timestamp shown to operators (informational; the sentinel still requires `sm up`)."
)]
public string? Until { get; set; }

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

namespace SimpleModule.Cli.Commands.Maintenance;

/// <summary>
/// File-based sentinel that mirrors what <c>MaintenanceModeMiddleware</c>
/// reads at request time. Kept in the CLI tree so the two sides stay
/// honest about the on-disk shape — the middleware loads via JSON
/// deserialization, and this writes the same JSON shape.
/// </summary>
public static class MaintenanceSentinel
{
public const string FileName = ".maintenance";

private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

public static string PathFor(string contentRoot) => Path.Combine(contentRoot, FileName);

public static void Write(
string contentRoot,
string? secret,
string? message,
int retryAfterSeconds,
DateTimeOffset? until
)
{
var payload = new
{
Until = until,
SecretHash = secret is null ? null : HashSecret(secret),
Message = message,
RetryAfterSeconds = retryAfterSeconds,
};

Directory.CreateDirectory(contentRoot);
File.WriteAllText(PathFor(contentRoot), JsonSerializer.Serialize(payload, JsonOptions));
}

public static bool Delete(string contentRoot)
{
var path = PathFor(contentRoot);
if (!File.Exists(path))
{
return false;
}
File.Delete(path);
return true;
}

public static bool Exists(string contentRoot) => File.Exists(PathFor(contentRoot));

public static string HashSecret(string secret)
{
var bytes = Encoding.UTF8.GetBytes(secret);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}
39 changes: 39 additions & 0 deletions cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using SimpleModule.Cli.Infrastructure;
using Spectre.Console;
using Spectre.Console.Cli;

namespace SimpleModule.Cli.Commands.Maintenance;

public sealed class UpCommand : Command
{
public override int Execute(CommandContext context)
{
var solution = SolutionContext.Discover();
if (solution is null)
{
AnsiConsole.MarkupLine(
"[red]Could not locate the host project. Run `sm up` from within a SimpleModule solution.[/]"
);
return 1;
}

var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath);
if (string.IsNullOrEmpty(hostDir))
{
AnsiConsole.MarkupLine("[red]Host project directory could not be resolved.[/]");
return 1;
}

var removed = MaintenanceSentinel.Delete(hostDir);
if (removed)
{
AnsiConsole.MarkupLine("[green]Maintenance mode disabled.[/] Sentinel removed.");
}
else
{
AnsiConsole.MarkupLine("[yellow]No active maintenance sentinel — nothing to do.[/]");
}

return 0;
}
}
11 changes: 11 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,16 @@
}
);

config
.AddCommand<DownCommand>("down")
.WithDescription("Enable maintenance mode (writes the .maintenance sentinel)")
.WithExample("down", "--secret", "let-me-in", "--retry", "60")
.WithExample("down", "--status");

config
.AddCommand<UpCommand>("up")
.WithDescription("Disable maintenance mode (removes the .maintenance sentinel)");

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

Expand Down
90 changes: 90 additions & 0 deletions docs/maintenance-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Maintenance mode

Put the running app into a maintenance state during deployments, database
migrations, or any change that must not interleave with live traffic. Visitors
see a branded 503 page; the operator running the deploy can keep verifying
the release through a bypass cookie.

## Quick start

```bash
# Drop the gate. Holders of ?sm_bypass=let-me-in pass through.
sm down --secret let-me-in --message "Deploying v1.4" --retry 90

# Verify state.
sm down --status

# Lift the gate.
sm up
```

`sm down` writes a JSON sentinel at `<host-content-root>/.maintenance`. The
running app polls that path once per second; the gate engages within a
second of the file appearing and lifts within a second of `sm up`.

The secret is hashed with SHA-256 before it touches disk — only the hash
sits in the sentinel, never the plaintext.

## How the middleware behaves

`MaintenanceModeMiddleware` runs after static-asset routing and before
authentication. Once the sentinel is active:

- **Health probes** (`/health/live`, `/health/ready`) pass through unchanged
so load balancers can still distinguish "host is down" from "deployment in
progress".
- **Bypass query** — `?sm_bypass=<secret>` redirects with an `sm_bypass`
cookie (HttpOnly, Secure when HTTPS, `SameSite=Lax`). The cookie's value
is the SHA-256 hash, not the secret itself, so leaking the cookie still
requires brute-forcing the hash to recover the secret.
- **Bypass cookie** — subsequent requests with a matching cookie pass
through. The cookie expires after 12 hours by default.
- **Inertia / API requests** — receive a JSON 503 with `Retry-After` and
`Cache-Control: no-store` so SPAs can show the maintenance page without
triggering a full reload.
- **Browser requests** — receive a minimal HTML 503 with the same headers,
styled inline so it renders without any JS bundle.

## Sentinel shape

```json
{
"Until": "2026-05-15T18:00:00+00:00",
"SecretHash": "f1b8c2…",
"Message": "Deploying v1.4",
"RetryAfterSeconds": 90
}
```

All fields are optional. An empty file (or `{}`) still engages maintenance
mode — the values just default to "no bypass possible, generic message,
retry in 60 seconds".

## Tuning

The default options live in `MaintenanceModeOptions` and are bound through
`IOptions<MaintenanceModeOptions>`:

| Option | Default | Notes |
| --- | --- | --- |
| `SentinelFileName` | `.maintenance` | Relative to the host content root. |
| `PollInterval` | 1 s | How often the middleware re-checks the file. |
| `BypassCookieName` | `sm_bypass` | Change if it collides with an app cookie. |
| `BypassCookieLifetime` | 12 h | How long a bypass stays valid. |

Override in `Program.cs`:

```csharp
builder.Services.Configure<MaintenanceModeOptions>(o =>
{
o.PollInterval = TimeSpan.FromMilliseconds(500);
});
```

## Not in scope

- **Per-tenant maintenance** — a follow-up issue. The current sentinel is
global to the host.
- **Distributed coordination** — each instance reads its own filesystem.
For containerized deploys, run `sm down` against a shared volume or wire
the sentinel into your deploy pipeline so every replica sees it.
Loading
Loading