Compare commits
10 Commits
25b64bee8a
...
5c5a572cfc
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c5a572cfc | |||
| 95646e1d53 | |||
| c1ff98ff4c | |||
| 834a775c9d | |||
| df9322f5c0 | |||
| 96816186cb | |||
| de35947244 | |||
| 8f6f2ba8d6 | |||
| 66b02bcab8 | |||
| ab53582c71 |
@@ -1,6 +1,18 @@
|
|||||||
# Copy this file to `.env` (same folder as docker-compose.yml) and fill in values.
|
# Copy this file to `.env` (same folder as docker-compose.yml) and fill in values.
|
||||||
#
|
#
|
||||||
# Used by docker-compose.yml
|
# Used by docker-compose.yml
|
||||||
|
#
|
||||||
|
# Database. deploy/deploy.sh REQUIRES DATABASE_PROVIDER to be set explicitly and
|
||||||
|
# refuses to deploy without it — it selects which backup to take, and guessing it
|
||||||
|
# wrong means backing up the wrong database. Use `mariadb` (or `mysql`) for a
|
||||||
|
# server deployment, `sqlite` for a single-file local one.
|
||||||
|
DATABASE_PROVIDER=sqlite
|
||||||
|
# Required when DATABASE_PROVIDER is mariadb/mysql. Ignored for sqlite, which
|
||||||
|
# stores its file in the jobtracker_data volume.
|
||||||
|
# The host resolves from INSIDE the backend container: 127.0.0.1 means the
|
||||||
|
# container, not the Docker host.
|
||||||
|
JOBTRACKER_CONNECTION_STRING=
|
||||||
|
|
||||||
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
AUTH_ADMIN_EMAIL=admin@example.com
|
AUTH_ADMIN_EMAIL=admin@example.com
|
||||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||||
|
|||||||
@@ -115,8 +115,11 @@ public sealed class ApplicationIntelligenceTests
|
|||||||
var (db, _, timeline) = New("user-1");
|
var (db, _, timeline) = New("user-1");
|
||||||
await using var _d = db;
|
await using var _d = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-3) });
|
// Anchor the two older events to a fixed time-of-day so they always land on the same
|
||||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-3).AddHours(2) });
|
// calendar day. Using DateTime.Now.AddDays(-3).AddHours(2) straddled midnight whenever the
|
||||||
|
// wall clock was within two hours of it, splitting one day into two and failing the test.
|
||||||
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Today.AddDays(-3).AddHours(9) });
|
||||||
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Today.AddDays(-3).AddHours(11) });
|
||||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now });
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// Pins the configuration KEY, not just the behaviour. /health previously read the
|
||||||
|
// APP_VERSION environment variable while docker-compose passed App__Version, so the
|
||||||
|
// endpoint silently reported "unknown" in every container. Nothing failed; the value
|
||||||
|
// was just wrong. These tests fail if the key drifts again.
|
||||||
|
public sealed class BuildMetadataTests
|
||||||
|
{
|
||||||
|
private static IConfiguration Config(params (string Key, string Value)[] values) =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(values.Select(v => new KeyValuePair<string, string?>(v.Key, v.Value)))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_the_App_Version_configuration_key()
|
||||||
|
{
|
||||||
|
var version = BuildMetadata.ResolveVersion(Config(("App:Version", "1.2.3")));
|
||||||
|
|
||||||
|
Assert.Equal("1.2.3", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void App__Version_environment_variable_binds_to_the_same_key()
|
||||||
|
{
|
||||||
|
// This is the exact path docker-compose uses: App__Version in the container
|
||||||
|
// environment must reach App:Version in configuration.
|
||||||
|
var cfg = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||||
|
.Add(new FakeEnvironmentSource(("App__Version", "42")))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.Equal("42", BuildMetadata.ResolveVersion(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Falls_back_to_the_assembly_version_when_unconfigured()
|
||||||
|
{
|
||||||
|
// Local development sets nothing. "unknown" would be a regression, not a default.
|
||||||
|
var version = BuildMetadata.ResolveVersion(Config());
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||||
|
Assert.NotEqual("unknown", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("$(git rev-parse --short HEAD)")]
|
||||||
|
[InlineData("${APP_COMMIT_SHA}")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void Unresolved_placeholders_and_blanks_are_rejected(string? raw)
|
||||||
|
{
|
||||||
|
Assert.Null(BuildMetadata.Normalize(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_real_value_survives_normalization_trimmed()
|
||||||
|
{
|
||||||
|
Assert.Equal("abc1234", BuildMetadata.Normalize(" abc1234 "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal stand-in for the environment-variable provider, so the test does not
|
||||||
|
// mutate the real process environment and race other tests.
|
||||||
|
private sealed class FakeEnvironmentSource : IConfigurationSource
|
||||||
|
{
|
||||||
|
private readonly (string Key, string Value)[] _values;
|
||||||
|
|
||||||
|
public FakeEnvironmentSource(params (string Key, string Value)[] values) => _values = values;
|
||||||
|
|
||||||
|
public IConfigurationProvider Build(IConfigurationBuilder builder) => new Provider(_values);
|
||||||
|
|
||||||
|
private sealed class Provider : ConfigurationProvider
|
||||||
|
{
|
||||||
|
private readonly (string Key, string Value)[] _values;
|
||||||
|
|
||||||
|
public Provider((string Key, string Value)[] values) => _values = values;
|
||||||
|
|
||||||
|
public override void Load()
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in _values)
|
||||||
|
{
|
||||||
|
// "__" is the environment-variable spelling of the ":" section separator.
|
||||||
|
Data[key.Replace("__", ":")] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// The catalog used to be built solely from CultureInfo.GetCultures, so which languages it
|
||||||
|
// recognised depended on the host's ICU data: 806 cultures on a normal machine, exactly 1
|
||||||
|
// under globalization-invariant mode. CV imports silently lost their Languages section on a
|
||||||
|
// container with trimmed or absent ICU data, and every existing test still passed locally.
|
||||||
|
//
|
||||||
|
// These pin the explicitly seeded catalog, so deleting the seed list fails here rather than
|
||||||
|
// in production on a machine nobody tested.
|
||||||
|
public sealed class HumanLanguageCatalogTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("English")]
|
||||||
|
[InlineData("Norwegian")]
|
||||||
|
[InlineData("French")]
|
||||||
|
[InlineData("Spanish")]
|
||||||
|
[InlineData("German")]
|
||||||
|
[InlineData("Arabic")]
|
||||||
|
[InlineData("Chinese")]
|
||||||
|
[InlineData("Polish")]
|
||||||
|
public void Common_languages_resolve_without_relying_on_host_culture_data(string language)
|
||||||
|
{
|
||||||
|
Assert.Equal(language, HumanLanguageCatalog.NormalizeLanguageName(language));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("norsk")]
|
||||||
|
[InlineData("bokmål")]
|
||||||
|
[InlineData("nynorsk")]
|
||||||
|
public void Norwegian_aliases_resolve_to_the_canonical_name(string alias)
|
||||||
|
{
|
||||||
|
Assert.Equal("Norwegian", HumanLanguageCatalog.NormalizeLanguageName(alias));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("C#")]
|
||||||
|
[InlineData("Leadership")]
|
||||||
|
[InlineData("Public speaking")]
|
||||||
|
[InlineData("Go")] // a programming language, not a human one
|
||||||
|
[InlineData("Java")] // Javanese is a language; Java is not
|
||||||
|
[InlineData("Swift")]
|
||||||
|
[InlineData("Rust")]
|
||||||
|
public void Technical_skills_are_not_treated_as_human_languages(string skill)
|
||||||
|
{
|
||||||
|
Assert.Null(HumanLanguageCatalog.NormalizeLanguageName(skill));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_language_embedded_in_a_phrase_is_extracted_with_its_level()
|
||||||
|
{
|
||||||
|
Assert.Equal("Norwegian", HumanLanguageCatalog.NormalizeLanguageName("Native Norwegian speaker"));
|
||||||
|
Assert.Equal("Native", HumanLanguageCatalog.ExtractLevel("Native Norwegian speaker"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
@@ -50,20 +49,8 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
AiServiceMetrics Ai
|
AiServiceMetrics Ai
|
||||||
);
|
);
|
||||||
|
|
||||||
private static string? NormalizeBuildMetadata(string? value)
|
// Shared with the /health endpoint so both report the same version from the same key.
|
||||||
{
|
private static string? NormalizeBuildMetadata(string? value) => BuildMetadata.Normalize(value);
|
||||||
var trimmed = (value ?? string.Empty).Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(trimmed)) return null;
|
|
||||||
|
|
||||||
// Ignore unresolved shell/compose placeholders that would otherwise leak
|
|
||||||
// directly into the admin UI, e.g. $(git rev-parse --short HEAD) or ${APP_COMMIT_SHA}.
|
|
||||||
if ((trimmed.StartsWith("$(") && trimmed.EndsWith(")")) || (trimmed.StartsWith("${") && trimmed.EndsWith("}")))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot()
|
private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot()
|
||||||
{
|
{
|
||||||
@@ -221,11 +208,7 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
LastError: ex.Message);
|
LastError: ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var version = NormalizeBuildMetadata(_cfg["App:Version"]);
|
var version = BuildMetadata.ResolveVersion(_cfg);
|
||||||
if (string.IsNullOrWhiteSpace(version))
|
|
||||||
{
|
|
||||||
version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
|
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
|
||||||
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]);
|
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]);
|
||||||
|
|||||||
@@ -525,10 +525,13 @@ app.MapControllers();
|
|||||||
// that queried MariaDB would restart a perfectly healthy backend whenever the database blipped, and
|
// that queried MariaDB would restart a perfectly healthy backend whenever the database blipped, and
|
||||||
// would also be a free unauthenticated way to probe database availability.
|
// would also be a free unauthenticated way to probe database availability.
|
||||||
// docs/production-readiness-review.md.
|
// docs/production-readiness-review.md.
|
||||||
app.MapGet("/health", () => Results.Ok(new
|
// Version comes from configuration (App:Version), which is how docker-compose supplies it as
|
||||||
|
// App__Version. Reading the APP_VERSION environment variable directly meant /health always
|
||||||
|
// reported "unknown" in a container. Falls back to the assembly version for local development.
|
||||||
|
app.MapGet("/health", (IConfiguration cfg) => Results.Ok(new
|
||||||
{
|
{
|
||||||
status = "ok",
|
status = "ok",
|
||||||
version = Environment.GetEnvironmentVariable("APP_VERSION") ?? "unknown",
|
version = BuildMetadata.ResolveVersion(cfg),
|
||||||
})).AllowAnonymous();
|
})).AllowAnonymous();
|
||||||
|
|
||||||
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
|
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
// Build metadata (version, commit, stamp) comes from configuration, populated by
|
||||||
|
// docker-compose as App__Version / App__CommitSha / App__BuildStamp.
|
||||||
|
//
|
||||||
|
// This lives here because two callers need the same answer: the admin system page and
|
||||||
|
// the anonymous /health endpoint. /health used to read the APP_VERSION *environment
|
||||||
|
// variable* directly, which compose never sets under that name — it passes App__Version,
|
||||||
|
// binding to the App:Version configuration key. So /health always reported "unknown".
|
||||||
|
// See docs/release-candidate-review.md (N2).
|
||||||
|
public static class BuildMetadata
|
||||||
|
{
|
||||||
|
// Ignore unresolved shell/compose placeholders that would otherwise leak into the
|
||||||
|
// admin UI or a health response, e.g. $(git rev-parse --short HEAD) or ${APP_COMMIT_SHA}.
|
||||||
|
public static string? Normalize(string? value)
|
||||||
|
{
|
||||||
|
var trimmed = (value ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed)) return null;
|
||||||
|
|
||||||
|
if ((trimmed.StartsWith("$(") && trimmed.EndsWith(")")) || (trimmed.StartsWith("${") && trimmed.EndsWith("}")))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured version, else the assembly version, else "unknown". The assembly fallback
|
||||||
|
// is what makes local development useful: nothing sets App:Version there.
|
||||||
|
public static string ResolveVersion(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
var version = Normalize(cfg["App:Version"]);
|
||||||
|
if (!string.IsNullOrWhiteSpace(version)) return version;
|
||||||
|
|
||||||
|
return Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1937,7 +1937,14 @@ public static class StartupInitializationExtensions
|
|||||||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
||||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
|
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
|
||||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`");
|
// FollowUpAt is `text` on MariaDB (the migration was scaffolded against SQLite, which
|
||||||
|
// stores DateTimeOffset as TEXT). A text column cannot be indexed without a prefix
|
||||||
|
// length, so without one this index ALWAYS failed the 3072-byte key check, was caught
|
||||||
|
// and skipped on every boot, and left the follow-up reminder query unindexed — while
|
||||||
|
// logging a "Specified key was too long" line the deploy runbook flags as a rollback
|
||||||
|
// signal. Prefix it like Status(50) below. ISO-8601 date strings sort lexicographically,
|
||||||
|
// so a 20-char prefix ("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
|
||||||
|
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`(20)");
|
||||||
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
|
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
|
||||||
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
|
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
|
||||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
|
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
|
||||||
|
|||||||
@@ -103,6 +103,40 @@ public static class HumanLanguageCatalog
|
|||||||
map.TryAdd(normalizedAlias, normalizedCanonical);
|
map.TryAdd(normalizedAlias, normalizedCanonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seeded FIRST, and deliberately not derived from the host.
|
||||||
|
//
|
||||||
|
// This table used to come only from CultureInfo.GetCultures, which returns whatever
|
||||||
|
// culture data the machine happens to carry: 806 entries on a normal Linux or Windows
|
||||||
|
// box, exactly 1 under globalization-invariant mode, and an English-only subset on a
|
||||||
|
// container with trimmed ICU data. So whether "Norwegian" was recognised as a human
|
||||||
|
// language depended on the deployment environment, not on the CV. Under invariant mode
|
||||||
|
// every language was silently dropped and a CV import lost its Languages section with
|
||||||
|
// no error at all.
|
||||||
|
//
|
||||||
|
// These are the languages a CV realistically lists. Culture enumeration still runs
|
||||||
|
// below and still adds breadth for free, but nothing here depends on it.
|
||||||
|
//
|
||||||
|
// Nothing in this list may collide with a technical skill — "Go", "Java", "Swift",
|
||||||
|
// "Rust" and "Basic" are deliberately absent. "Basic" is also a proficiency level.
|
||||||
|
string[] seed =
|
||||||
|
[
|
||||||
|
"English", "Norwegian", "Swedish", "Danish", "Finnish", "Icelandic",
|
||||||
|
"German", "Dutch", "French", "Spanish", "Portuguese", "Italian",
|
||||||
|
"Polish", "Czech", "Slovak", "Slovenian", "Croatian", "Serbian", "Bosnian",
|
||||||
|
"Bulgarian", "Romanian", "Hungarian", "Greek", "Albanian", "Macedonian",
|
||||||
|
"Russian", "Ukrainian", "Belarusian", "Lithuanian", "Latvian", "Estonian",
|
||||||
|
"Turkish", "Arabic", "Hebrew", "Persian", "Kurdish", "Pashto", "Urdu",
|
||||||
|
"Hindi", "Bengali", "Punjabi", "Gujarati", "Marathi", "Tamil", "Telugu",
|
||||||
|
"Malayalam", "Kannada", "Sinhala", "Nepali",
|
||||||
|
"Chinese", "Japanese", "Korean", "Vietnamese", "Thai", "Lao", "Khmer",
|
||||||
|
"Burmese", "Malay", "Indonesian", "Filipino", "Tagalog", "Javanese",
|
||||||
|
"Swahili", "Amharic", "Somali", "Hausa", "Yoruba", "Igbo", "Zulu", "Afrikaans",
|
||||||
|
"Catalan", "Basque", "Galician", "Welsh", "Irish", "Scottish Gaelic", "Maltese",
|
||||||
|
"Latin", "Esperanto", "Armenian", "Georgian", "Azerbaijani", "Kazakh", "Uzbek",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (var language in seed) Add(language, language);
|
||||||
|
|
||||||
foreach (var culture in CultureInfo.GetCultures(CultureTypes.NeutralCultures | CultureTypes.SpecificCultures))
|
foreach (var culture in CultureInfo.GetCultures(CultureTypes.NeutralCultures | CultureTypes.SpecificCultures))
|
||||||
{
|
{
|
||||||
var english = CleanCultureLanguageName(culture.EnglishName);
|
var english = CleanCultureLanguageName(culture.EnglishName);
|
||||||
|
|||||||
+57
-7
@@ -109,22 +109,72 @@ hat links open the correct job/tab
|
|||||||
only the rollback. Restore the database only if the data itself is wrong or lost — it discards
|
only the rollback. Restore the database only if the data itself is wrong or lost — it discards
|
||||||
everything written since the dump.
|
everything written since the dump.
|
||||||
|
|
||||||
|
## Environment loading
|
||||||
|
|
||||||
|
`deploy/deploy.sh` **loads `/opt/job-tracker/shared/.env` into its own shell** before it decides
|
||||||
|
anything. The symlink it creates in the checkout is for docker compose, which reads `.env` itself;
|
||||||
|
the script needs the values too, to pick the right backup and to check its own configuration.
|
||||||
|
|
||||||
|
- Parsed line by line, not `source`d — a compose `.env` is not a shell script, so an unquoted value
|
||||||
|
containing spaces would execute as a command.
|
||||||
|
- **Variables already set in the environment win**, so CI-provided `APP_VERSION`, `APP_COMMIT_SHA`
|
||||||
|
and `APP_BUILD_STAMP` still override the file.
|
||||||
|
- No value is ever echoed. Error messages name variables, never their contents.
|
||||||
|
|
||||||
|
This was added on 2026-07-19. Before it, the script read an empty environment: `DATABASE_PROVIDER`
|
||||||
|
fell back to `sqlite` on a MariaDB host, so the deploy tarred the data volume, printed
|
||||||
|
`Backup verified`, and continued with no database dump at all. See
|
||||||
|
`docs/release-candidate-review.md` (B1).
|
||||||
|
|
||||||
|
## Required production variables
|
||||||
|
|
||||||
|
`validate_deploy_config` runs **before** the backup, and therefore before anything is built, stopped
|
||||||
|
or replaced. A missing variable aborts the deploy while the running stack is still untouched.
|
||||||
|
|
||||||
|
| Variable | Required | Why it is checked here |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_PROVIDER` | **Always** — no default | Selects the backup. Guessing it wrong backs up the wrong database and reports success |
|
||||||
|
| `JOBTRACKER_CONNECTION_STRING` | When provider is `mariadb`/`mysql` | Without it there is no way to dump the database |
|
||||||
|
| `AI_SERVICE_TOKEN` | Always | `docker-compose.yml` declares it with `:?`; missing it kills the stack *after* the images are built |
|
||||||
|
| `AUTH_JWT_KEY` | Always | Compose sets `Auth__Require=true`, and the backend throws at startup on a blank key — after the containers have been replaced |
|
||||||
|
| `APP_PUBLIC_BASE_URL` | Optional | If unset the post-deploy public smoke check is skipped, and the script says so rather than skipping silently |
|
||||||
|
|
||||||
|
`DATABASE_PROVIDER` deliberately has **no default**. An unset value used to mean "sqlite"; it now
|
||||||
|
means "stop and tell me".
|
||||||
|
|
||||||
## Backup creation
|
## Backup creation
|
||||||
|
|
||||||
`deploy/deploy.sh` takes a backup **before** it builds, stops or replaces anything, and **aborts the
|
`deploy/deploy.sh` takes a backup **before** it builds, stops or replaces anything, and **aborts the
|
||||||
deploy if the backup fails**. Nothing else in the deploy runs without a restore point.
|
deploy if the backup fails**. Nothing else in the deploy runs without a restore point.
|
||||||
|
|
||||||
- **Location:** `/opt/job-tracker/backups` — override with `BACKUP_DIR`.
|
- **Location:** `/opt/job-tracker/backups` — override with `BACKUP_DIR`.
|
||||||
- **Naming:** `jobtracker-<database>-<UTC timestamp>.sql.gz`, e.g.
|
- **Selection:** driven solely by `DATABASE_PROVIDER`, which must be set.
|
||||||
`jobtracker-jobtracker-20260719T153759Z.sql.gz`. The timestamp makes every file unique, so a deploy
|
- `mariadb` / `mysql` → SQL dump, `jobtracker-<database>-<UTC timestamp>.sql.gz`, e.g.
|
||||||
never overwrites an earlier backup.
|
`jobtracker-jobtracker-20260719T153759Z.sql.gz`
|
||||||
- **SQLite deployments** (`DATABASE_PROVIDER` unset or `sqlite`) get the data volume instead:
|
- `sqlite` → data volume archive, `jobtracker-sqlite-<UTC timestamp>.tar.gz`
|
||||||
`jobtracker-sqlite-<UTC timestamp>.tar.gz`.
|
- anything else → the deploy stops
|
||||||
|
- **The filename tells you which path ran.** If you expect a MariaDB deploy and find a
|
||||||
|
`jobtracker-sqlite-*.tar.gz`, the environment is wrong — that is the exact failure this check exists
|
||||||
|
to make visible.
|
||||||
|
- **Naming:** the UTC timestamp makes every file unique, so a deploy never overwrites an earlier backup.
|
||||||
- **Credentials** come from `JOBTRACKER_CONNECTION_STRING` and are passed via `MYSQL_PWD`, never on the
|
- **Credentials** come from `JOBTRACKER_CONNECTION_STRING` and are passed via `MYSQL_PWD`, never on the
|
||||||
command line, so they cannot appear in the process list or the deploy log.
|
command line, so they cannot appear in the process list or the deploy log.
|
||||||
- **Compression:** gzip. A small database compresses to a few KB.
|
- **Compression:** gzip. A small database compresses to a few KB.
|
||||||
- **Verification:** the script rejects a dump that is empty or missing `CREATE TABLE`, because a
|
- **SQLite volume resolution:** compose prefixes volume names with the project name, so the script
|
||||||
truncated file that *looks* like a restore point is worse than none.
|
resolves `<project>_jobtracker_data` and **fails if that volume does not exist**. Naming the bare
|
||||||
|
volume would silently create an empty one and back *that* up.
|
||||||
|
|
||||||
|
### Verification — each provider gets the check that proves its own format
|
||||||
|
|
||||||
|
A backup that exists but is empty, truncated, or the wrong *kind* is worse than none, because it looks
|
||||||
|
like a restore point.
|
||||||
|
|
||||||
|
| Provider | Checks |
|
||||||
|
|---|---|
|
||||||
|
| MariaDB | non-empty; valid gzip; contains `CREATE TABLE`; contains the `Dump completed` trailer that `mariadb-dump` writes last, so a dump that died partway through is rejected |
|
||||||
|
| SQLite | non-empty; valid gzip; the archive actually contains `jobtracker.db` |
|
||||||
|
|
||||||
|
A failed check deletes the file rather than leaving something that looks like a backup.
|
||||||
|
|
||||||
### Taking one by hand
|
### Taking one by hand
|
||||||
|
|
||||||
|
|||||||
+211
-16
@@ -19,6 +19,48 @@ if [ ! -L "$ENV_TARGET" ] && [ ! -f "$ENV_TARGET" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Load the shared environment into THIS shell.
|
||||||
|
#
|
||||||
|
# The symlink above is for docker compose, which reads .env itself. The script's
|
||||||
|
# own decisions — which backup to take, which variables are missing — ran against
|
||||||
|
# an empty environment until this existed, so DATABASE_PROVIDER defaulted to
|
||||||
|
# sqlite and a MariaDB host silently got a volume tar instead of a dump, with a
|
||||||
|
# "Backup verified" line to match. See docs/release-candidate-review.md (B1).
|
||||||
|
#
|
||||||
|
# Parsed line by line rather than sourced: a compose .env is not a shell script,
|
||||||
|
# so an unquoted value containing spaces would execute as a command under `.`.
|
||||||
|
# Values already present in the environment win, so CI-provided APP_VERSION and
|
||||||
|
# friends still override the file. No value is ever echoed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
load_env_file() {
|
||||||
|
local file="$1" line key value
|
||||||
|
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line="${line%$'\r'}"
|
||||||
|
case "$line" in ''|'#'*) continue ;; esac
|
||||||
|
case "$line" in *=*) ;; *) continue ;; esac
|
||||||
|
|
||||||
|
key="${line%%=*}"
|
||||||
|
value="${line#*=}"
|
||||||
|
key="${key#export }"
|
||||||
|
key="${key//[[:space:]]/}"
|
||||||
|
case "$key" in ''|*[!A-Za-z0-9_]*) continue ;; esac
|
||||||
|
|
||||||
|
case "$value" in
|
||||||
|
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
||||||
|
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Already set (CI, or an explicit override on the command line) wins.
|
||||||
|
[ -n "${!key+x}" ] && continue
|
||||||
|
export "$key=$value"
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file "$ENV_TARGET"
|
||||||
|
echo "Loaded deployment environment from ${ENV_SOURCE}"
|
||||||
|
|
||||||
export APP_VERSION="${APP_VERSION:-0.0.0}"
|
export APP_VERSION="${APP_VERSION:-0.0.0}"
|
||||||
export APP_COMMIT_SHA="${APP_COMMIT_SHA:-unknown}"
|
export APP_COMMIT_SHA="${APP_COMMIT_SHA:-unknown}"
|
||||||
export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}"
|
export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}"
|
||||||
@@ -28,6 +70,74 @@ compose() {
|
|||||||
docker compose "$@"
|
docker compose "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration validation, before anything is built, stopped or replaced.
|
||||||
|
#
|
||||||
|
# Everything checked here is fatal later anyway — compose declares AI_SERVICE_TOKEN
|
||||||
|
# with `:?`, and the backend throws on a blank Auth__JwtKey. Failing here costs an
|
||||||
|
# aborted deploy; failing there costs a half-replaced stack.
|
||||||
|
#
|
||||||
|
# Names only in the output. Never values.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
require_var() {
|
||||||
|
local name="$1" hint="$2"
|
||||||
|
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
echo "Missing required deployment variable: ${name}"
|
||||||
|
echo " ${hint}"
|
||||||
|
echo " Set it in ${ENV_SOURCE}."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_deploy_config() {
|
||||||
|
local failed=0
|
||||||
|
|
||||||
|
# Deliberately no default. Guessing this wrong means backing up the wrong
|
||||||
|
# database and reporting success — the exact failure this block exists to stop.
|
||||||
|
if [ -z "${DATABASE_PROVIDER:-}" ]; then
|
||||||
|
echo "Missing required deployment variable: DATABASE_PROVIDER"
|
||||||
|
echo " Set to 'mariadb' (or 'mysql') for a MariaDB deployment, or 'sqlite'."
|
||||||
|
echo " There is no default: the wrong value backs up the wrong database."
|
||||||
|
echo " Set it in ${ENV_SOURCE}."
|
||||||
|
failed=1
|
||||||
|
else
|
||||||
|
case "$(printf '%s' "$DATABASE_PROVIDER" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
mysql|mariadb)
|
||||||
|
require_var JOBTRACKER_CONNECTION_STRING \
|
||||||
|
"MariaDB connection string. Required to take a database dump." || failed=1
|
||||||
|
;;
|
||||||
|
sqlite)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "DATABASE_PROVIDER is not a recognised value. Use mariadb, mysql or sqlite."
|
||||||
|
failed=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_var AI_SERVICE_TOKEN \
|
||||||
|
"Shared secret between backend and ai-service. docker compose refuses to start without it." || failed=1
|
||||||
|
|
||||||
|
# docker-compose.yml sets Auth__Require=true unconditionally, and the backend
|
||||||
|
# throws at startup rather than issuing tokens signed with a blank key.
|
||||||
|
require_var AUTH_JWT_KEY \
|
||||||
|
"JWT signing key. With Auth__Require=true the backend refuses to start without it." || failed=1
|
||||||
|
|
||||||
|
if [ -z "${APP_PUBLIC_BASE_URL:-}" ]; then
|
||||||
|
echo "Note: APP_PUBLIC_BASE_URL is not set — the post-deploy public smoke check will be skipped."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$failed" -ne 0 ]; then
|
||||||
|
echo "Deployment configuration is incomplete. Nothing was built, stopped or replaced."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployment configuration validated (database provider: ${DATABASE_PROVIDER})."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Database backup, taken BEFORE anything is stopped, built or replaced.
|
# Database backup, taken BEFORE anything is stopped, built or replaced.
|
||||||
#
|
#
|
||||||
@@ -44,27 +154,43 @@ backup_database() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local provider="${DATABASE_PROVIDER:-sqlite}"
|
# validate_deploy_config has already established this is set and recognised.
|
||||||
|
local provider
|
||||||
|
provider="$(printf '%s' "$DATABASE_PROVIDER" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
local stamp
|
local stamp
|
||||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
if [ "$provider" != "mysql" ] && [ "$provider" != "mariadb" ]; then
|
if [ "$provider" = "sqlite" ]; then
|
||||||
# SQLite lives in the jobtracker_data volume. Tar it from a throwaway container
|
# SQLite lives in the jobtracker_data volume. Tar it from a throwaway container
|
||||||
# so the host needs no sqlite tooling and no knowledge of the volume layout.
|
# so the host needs no sqlite tooling and no knowledge of the volume layout.
|
||||||
|
#
|
||||||
|
# docker compose prefixes volume names with the project name, which defaults to
|
||||||
|
# the directory name. Naming the bare volume here would silently CREATE an empty
|
||||||
|
# one and back that up instead, so resolve it and fail if it is not there.
|
||||||
|
local volume="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}_jobtracker_data"
|
||||||
|
if ! docker volume inspect "$volume" >/dev/null 2>&1; then
|
||||||
|
echo "SQLite data volume '${volume}' does not exist. Aborting deploy."
|
||||||
|
echo " Volumes are prefixed with the compose project name; set COMPOSE_PROJECT_NAME if it differs."
|
||||||
|
echo " Available: $(docker volume ls -q | grep jobtracker_data | tr '\n' ' ')"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
local target="$BACKUP_DIR/jobtracker-sqlite-${stamp}.tar.gz"
|
local target="$BACKUP_DIR/jobtracker-sqlite-${stamp}.tar.gz"
|
||||||
echo "Backing up SQLite data volume to ${target}"
|
echo "Backing up SQLite data volume '${volume}' to ${target}"
|
||||||
# The archive path is built inside the container: passing /backup/... as an argument
|
# The archive path is built inside the container: passing /backup/... as an argument
|
||||||
# gets rewritten by MSYS path translation when the script is run from Git Bash.
|
# gets rewritten by MSYS path translation when the script is run from Git Bash.
|
||||||
if ! docker run --rm \
|
if ! docker run --rm \
|
||||||
-v jobtracker_data:/data:ro \
|
-v "$volume":/data:ro \
|
||||||
-v "$BACKUP_DIR":/backup \
|
-v "$BACKUP_DIR":/backup \
|
||||||
-e ARCHIVE_NAME="$(basename "$target")" \
|
-e ARCHIVE_NAME="$(basename "$target")" \
|
||||||
alpine:3 sh -c 'tar czf "/backup/$ARCHIVE_NAME" -C /data .'; then
|
alpine:3 sh -c 'tar czf "/backup/$ARCHIVE_NAME" -C /data .'; then
|
||||||
echo "SQLite volume backup FAILED. Aborting deploy."
|
echo "SQLite volume backup FAILED. Aborting deploy."
|
||||||
|
rm -f "$target"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
verify_backup "$target"
|
verify_volume_backup "$target"
|
||||||
return $?
|
return $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -120,14 +246,20 @@ backup_database() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
verify_backup "$target" "CREATE TABLE"
|
verify_sql_backup "$target"
|
||||||
}
|
}
|
||||||
|
|
||||||
# A dump that exists but is empty or truncated is worse than none, because it
|
# A backup that exists but is empty, truncated, or is the wrong KIND of backup is
|
||||||
# looks like a restore point. Check size, and content when we know what to expect.
|
# worse than none, because it looks like a restore point. Each provider gets the
|
||||||
verify_backup() {
|
# check that proves its own format — a tar cannot pass the dump check and a dump
|
||||||
local target="$1"
|
# cannot pass the archive check.
|
||||||
local expect="${2:-}"
|
#
|
||||||
|
# Note on `set +o pipefail` below: `grep -q` exits at the first match, which
|
||||||
|
# SIGPIPEs the decompressor upstream. Under pipefail that fails the pipeline and
|
||||||
|
# would reject a perfectly good backup.
|
||||||
|
|
||||||
|
verify_backup_file_shape() {
|
||||||
|
local target="$1" kind="$2"
|
||||||
|
|
||||||
if [ ! -s "$target" ]; then
|
if [ ! -s "$target" ]; then
|
||||||
echo "Backup file ${target} is missing or empty. Aborting deploy."
|
echo "Backup file ${target} is missing or empty. Aborting deploy."
|
||||||
@@ -135,20 +267,83 @@ verify_backup() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local size
|
if ! gzip -t "$target" 2>/dev/null; then
|
||||||
size="$(du -h "$target" | cut -f1)"
|
echo "Backup ${target} is not a valid gzip archive (truncated ${kind}?). Aborting deploy."
|
||||||
|
|
||||||
if [ -n "$expect" ] && ! gzip -dc "$target" | grep -q "$expect"; then
|
|
||||||
echo "Backup ${target} does not contain '${expect}' — it is not a usable dump. Aborting deploy."
|
|
||||||
rm -f "$target"
|
rm -f "$target"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
report_backup() {
|
||||||
|
local target="$1" size
|
||||||
|
size="$(du -h "$target" | cut -f1)"
|
||||||
echo "Backup verified: ${target} (${size})"
|
echo "Backup verified: ${target} (${size})"
|
||||||
echo "Retention is manual — old backups in ${BACKUP_DIR} are never deleted automatically."
|
echo "Retention is manual — old backups in ${BACKUP_DIR} are never deleted automatically."
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verify_sql_backup() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
verify_backup_file_shape "$target" "dump" || return 1
|
||||||
|
|
||||||
|
local has_schema=1 has_trailer=1
|
||||||
|
set +o pipefail
|
||||||
|
if gzip -dc "$target" | grep -q "CREATE TABLE"; then has_schema=0; fi
|
||||||
|
# mariadb-dump / mysqldump write "-- Dump completed on ..." as the last line.
|
||||||
|
# Its absence means the dump died partway through.
|
||||||
|
if gzip -dc "$target" | grep -q "Dump completed"; then has_trailer=0; fi
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
if [ "$has_schema" -ne 0 ]; then
|
||||||
|
echo "Backup ${target} contains no CREATE TABLE statement — it is not a usable dump. Aborting deploy."
|
||||||
|
rm -f "$target"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_trailer" -ne 0 ]; then
|
||||||
|
echo "Backup ${target} has no dump trailer — it is truncated. Aborting deploy."
|
||||||
|
rm -f "$target"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_backup "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_volume_backup() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
verify_backup_file_shape "$target" "archive" || return 1
|
||||||
|
|
||||||
|
# Prove this is the DATABASE volume, not merely a non-empty tar. This is what
|
||||||
|
# catches an archive taken from the wrong volume, or from a freshly created
|
||||||
|
# empty one.
|
||||||
|
# Listed in the same container that wrote it, not with the host's tar: GNU tar
|
||||||
|
# reads a leading "C:/" as an rsh host and fails outright when this script runs
|
||||||
|
# from Git Bash. Docker is already required to have produced the archive.
|
||||||
|
local has_db=1
|
||||||
|
if docker run --rm \
|
||||||
|
-v "$(dirname "$target")":/backup:ro \
|
||||||
|
-e ARCHIVE_NAME="$(basename "$target")" \
|
||||||
|
alpine:3 sh -c 'tar tzf "/backup/$ARCHIVE_NAME" | grep -q "jobtracker\.db"' >/dev/null 2>&1; then
|
||||||
|
has_db=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_db" -ne 0 ]; then
|
||||||
|
echo "Archive ${target} contains no jobtracker.db — it is not a SQLite data backup. Aborting deploy."
|
||||||
|
rm -f "$target"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_backup "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! validate_deploy_config; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! backup_database; then
|
if ! backup_database; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -11,16 +11,25 @@
|
|||||||
|
|
||||||
Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
|
Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
|
||||||
|
|
||||||
1. `deploy.sh` links `/opt/job-tracker/shared/.env` into the checkout as `.env`.
|
1. `deploy.sh` links `/opt/job-tracker/shared/.env` into the checkout as `.env`, **and loads it into
|
||||||
2. **It takes a database backup and aborts if that fails.** Nothing else runs without a restore point.
|
its own shell.** The link is for docker compose; the script needs the values itself to pick the
|
||||||
3. `docker compose pull`, then builds `backend` and `frontend` (with one prune-and-retry on failure).
|
right backup. Values already in the environment (CI's `APP_VERSION` and friends) win.
|
||||||
4. `docker compose up -d --force-recreate --remove-orphans backend frontend`.
|
2. **It validates the deployment configuration**, before anything is built, stopped or replaced:
|
||||||
|
`DATABASE_PROVIDER` (required, no default), the connection string when that provider needs one,
|
||||||
|
`AI_SERVICE_TOKEN` and `AUTH_JWT_KEY`. A missing variable aborts the deploy with the running stack
|
||||||
|
untouched. Names are printed, never values.
|
||||||
|
3. **It takes a database backup and aborts if that fails.** Nothing else runs without a restore point.
|
||||||
|
The provider chooses the backup: `mariadb`/`mysql` gives a `.sql.gz` dump, `sqlite` gives a
|
||||||
|
`.tar.gz` of the data volume. Each is verified against its own format — the dump must contain
|
||||||
|
`CREATE TABLE` and the `Dump completed` trailer; the archive must contain `jobtracker.db`.
|
||||||
|
4. `docker compose pull`, then builds `backend` and `frontend` (with one prune-and-retry on failure).
|
||||||
|
5. `docker compose up -d --force-recreate --remove-orphans backend frontend`.
|
||||||
**There is no `compose down`** — containers are replaced in place, so the window is short.
|
**There is no `compose down`** — containers are replaced in place, so the window is short.
|
||||||
5. On backend start, `InitializeJobTrackerAsync` runs: **reconcile → `Database.Migrate()` → reconcile**.
|
6. On backend start, `InitializeJobTrackerAsync` runs: **reconcile → `Database.Migrate()` → reconcile**.
|
||||||
Every Phase 4/5 migration is a no-op; the reconciler creates those tables with correct per-provider
|
Every Phase 4/5 migration is a no-op; the reconciler creates those tables with correct per-provider
|
||||||
DDL. `Migrate()` throws on failure, so a schema problem exits the container rather than limping on.
|
DDL. `Migrate()` throws on failure, so a schema problem exits the container rather than limping on.
|
||||||
6. `deploy.sh` waits, then fails the deploy if `backend` is not running, and runs a public smoke check
|
7. `deploy.sh` waits, then fails the deploy if `backend` is not running, and runs a public smoke check
|
||||||
against `APP_PUBLIC_BASE_URL` if it is set.
|
against `APP_PUBLIC_BASE_URL`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,12 +40,17 @@ Verified by reading `deploy/deploy.sh` and `JobTrackerApi/Program.cs`:
|
|||||||
restored is a hypothesis.
|
restored is a hypothesis.
|
||||||
- [ ] **Disk space checked.** `df -h` on the host. You need room for the backup, two image sets during
|
- [ ] **Disk space checked.** `df -h` on the host. You need room for the backup, two image sets during
|
||||||
the build, and the build cache. `docker system df` shows what Docker is holding.
|
the build, and the build cache. `docker system df` shows what Docker is holding.
|
||||||
- [ ] **Environment variables present.** Check `/opt/job-tracker/shared/.env` contains:
|
- [ ] **Environment variables present.** `deploy.sh` now checks these itself and aborts before it
|
||||||
- `AI_SERVICE_TOKEN` — **compose refuses to start without it**
|
builds or replaces anything, so a miss costs an aborted deploy rather than a broken one. Check
|
||||||
|
first anyway and skip the round trip — `/opt/job-tracker/shared/.env` must contain:
|
||||||
|
- `DATABASE_PROVIDER=mariadb` (or `mysql`) — **required, no default.** An absent value used to
|
||||||
|
mean "sqlite", which silently produced the wrong backup; it now stops the deploy
|
||||||
|
- `JOBTRACKER_CONNECTION_STRING` — see the host-resolution note below
|
||||||
|
- `AI_SERVICE_TOKEN` — compose refuses to start without it
|
||||||
- `AUTH_JWT_KEY` — with `Auth__Require=true`, a blank key **throws at startup** (this is good;
|
- `AUTH_JWT_KEY` — with `Auth__Require=true`, a blank key **throws at startup** (this is good;
|
||||||
it fails loud rather than silently invalidating every session on restart)
|
it fails loud rather than silently invalidating every session on restart)
|
||||||
- `DATABASE_PROVIDER=mysql` — **defaults to `sqlite` if absent**
|
- `APP_PUBLIC_BASE_URL` — optional; without it the post-deploy public smoke check is skipped,
|
||||||
- `JOBTRACKER_CONNECTION_STRING` — see the host-resolution note below
|
and the script prints that it is skipping
|
||||||
- `AUTH_ADMIN_EMAIL` / `AUTH_ADMIN_PASSWORD` only if you want admin seeding on this boot
|
- `AUTH_ADMIN_EMAIL` / `AUTH_ADMIN_PASSWORD` only if you want admin seeding on this boot
|
||||||
- [ ] **Connection string host resolves from inside the container.** `Server=127.0.0.1` means *the
|
- [ ] **Connection string host resolves from inside the container.** `Server=127.0.0.1` means *the
|
||||||
backend container*, not the host — this bit me during validation. Use the host's LAN address, a
|
backend container*, not the host — this bit me during validation. Use the host's LAN address, a
|
||||||
@@ -65,8 +79,12 @@ Automatic — `deploy.sh` runs it first and aborts on failure. Confirm afterward
|
|||||||
ls -lt /opt/job-tracker/backups | head -3
|
ls -lt /opt/job-tracker/backups | head -3
|
||||||
```
|
```
|
||||||
|
|
||||||
Expect a new `jobtracker-<db>-<UTC timestamp>.sql.gz`. The script already rejected it if it were empty
|
Expect a new `jobtracker-<db>-<UTC timestamp>.sql.gz`. The script already rejected it if it were empty,
|
||||||
or missing `CREATE TABLE`.
|
not valid gzip, missing `CREATE TABLE`, or missing the `Dump completed` trailer.
|
||||||
|
|
||||||
|
**Check the filename, not just that a file appeared.** A `jobtracker-sqlite-<stamp>.tar.gz` on a
|
||||||
|
MariaDB deploy means the environment is wrong — that is the exact failure the provider check exists to
|
||||||
|
prevent, and it should now abort rather than reach this point.
|
||||||
|
|
||||||
### 2. Pull code
|
### 2. Pull code
|
||||||
|
|
||||||
@@ -105,7 +123,7 @@ docker compose logs -f backend
|
|||||||
| `Unhandled exception ... Table '...' doesn't exist` | Reconciler ordering problem |
|
| `Unhandled exception ... Table '...' doesn't exist` | Reconciler ordering problem |
|
||||||
| `no such table: INFORMATION_SCHEMA.TABLES` | `DATABASE_PROVIDER=mysql` but the connection string is **empty** — verified failure mode |
|
| `no such table: INFORMATION_SCHEMA.TABLES` | `DATABASE_PROVIDER=mysql` but the connection string is **empty** — verified failure mode |
|
||||||
| `Unable to connect to any of the specified MySQL hosts` | Connection string host unreachable from inside the container |
|
| `Unable to connect to any of the specified MySQL hosts` | Connection string host unreachable from inside the container |
|
||||||
| `Auth is required but Auth:JwtKey is not configured` | `AUTH_JWT_KEY` missing from `.env` |
|
| `Auth is required but Auth:JwtKey is not configured` | `AUTH_JWT_KEY` missing from `.env` — `deploy.sh` should now catch this before the build |
|
||||||
|
|
||||||
### 8. Health verification
|
### 8. Health verification
|
||||||
|
|
||||||
@@ -126,7 +144,12 @@ upstream cannot be resolved.
|
|||||||
```bash
|
```bash
|
||||||
# Health endpoint — anonymous, does not touch the database
|
# Health endpoint — anonymous, does not touch the database
|
||||||
curl -fsS https://<host>/health
|
curl -fsS https://<host>/health
|
||||||
# expect: {"status":"ok","version":"..."}
|
# expect: {"status":"ok","version":"<the APP_VERSION you deployed>"}
|
||||||
|
#
|
||||||
|
# The version comes from App:Version, which compose passes as App__Version from APP_VERSION.
|
||||||
|
# CI sets it to the workflow run number. A version of "1.0.0.0" (the assembly fallback) means
|
||||||
|
# APP_VERSION did not reach the container — harmless in itself, but it tells you the build
|
||||||
|
# metadata is not flowing, so the admin system page will be vague about what is deployed.
|
||||||
|
|
||||||
# Auth still enforced (this is the check that proves the API is not open)
|
# Auth still enforced (this is the check that proves the API is not open)
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/api/jobapplications
|
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/api/jobapplications
|
||||||
@@ -236,6 +259,11 @@ Against MariaDB 11 containers, no production data:
|
|||||||
| Backup against a seeded MariaDB | ✅ verified dump written |
|
| Backup against a seeded MariaDB | ✅ verified dump written |
|
||||||
| Restore into a clean MariaDB | ✅ rows identical |
|
| Restore into a clean MariaDB | ✅ rows identical |
|
||||||
| Backup failure paths | ✅ bad credentials and missing connection string both abort, no partial file |
|
| Backup failure paths | ✅ bad credentials and missing connection string both abort, no partial file |
|
||||||
|
| Shared `.env` loaded into the deploy shell | ✅ MariaDB path selected from the file alone; dump contains schema, rows and trailer |
|
||||||
|
| `DATABASE_PROVIDER` missing or unrecognised | ✅ deploy stops before build/replace, names the variable, leaves no backup file |
|
||||||
|
| `AI_SERVICE_TOKEN` / `AUTH_JWT_KEY` missing | ✅ both reported in one pass, deploy stops |
|
||||||
|
| SQLite volume backup, and its failure paths | ✅ valid archive verified; a volume with no `jobtracker.db`, and a volume name that does not exist, both abort |
|
||||||
|
| Secret leakage in deploy output | ✅ zero occurrences of any password, token or key across every test |
|
||||||
| Empty connection string with `provider=mysql` | ✅ fails loudly (`no such table: INFORMATION_SCHEMA.TABLES`) rather than silently serving an empty database |
|
| Empty connection string with `provider=mysql` | ✅ fails loudly (`no such table: INFORMATION_SCHEMA.TABLES`) rather than silently serving an empty database |
|
||||||
| Backend with unreachable database | ✅ exits, reported `unhealthy` |
|
| Backend with unreachable database | ✅ exits, reported `unhealthy` |
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ All four scenarios, 2026-07-19, against MariaDB 11 and SQLite:
|
|||||||
|
|
||||||
| Scenario | Result |
|
| Scenario | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Empty MariaDB | 40 tables created, app starts |
|
| Empty MariaDB | 42 tables created, app starts |
|
||||||
| Populated MariaDB, restart | idempotent — still 40 tables, rows preserved |
|
| Populated MariaDB, restart | idempotent — still 42 tables, rows preserved |
|
||||||
| Empty MariaDB via the Docker image | 40 tables created, app starts |
|
| Partially-migrated MariaDB (Phase 4/5 tables dropped) | healed 35 → 42, surviving rows preserved |
|
||||||
|
| Empty MariaDB via the Docker image | 42 tables created, app starts |
|
||||||
| Fresh SQLite | 42 tables created, app starts |
|
| Fresh SQLite | 42 tables created, app starts |
|
||||||
| Existing partially-migrated SQLite dev DB (34 tables) | upgraded to 44 tables, 13 applications and 8 companies preserved |
|
| Existing partially-migrated SQLite dev DB (34 tables) | upgraded to 44 tables, 13 applications and 8 companies preserved |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Backup and restore verification
|
||||||
|
|
||||||
|
> **2026-07-19 19:32 UTC.** Full backup → verify → restore → start-the-app rehearsal of the
|
||||||
|
> `deploy/deploy.sh` backup path, exercised end to end against MariaDB 11.
|
||||||
|
>
|
||||||
|
> **This is a rehearsal, not a verification of production data.** See *Limitations* — that section is
|
||||||
|
> the most important part of this document, and the checklist at the end is what actually closes the
|
||||||
|
> gap.
|
||||||
|
|
||||||
|
## Scope — read this first
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **What was verified** | The backup *mechanism*: the real `backup_database` function from `deploy.sh`, against a real MariaDB 11 database carrying the real 42-table schema, restored into a separate clean MariaDB 11 container, with the real application started against the result |
|
||||||
|
| **What was NOT verified** | **Your production database.** No production host was contacted, no production credentials were used, and no production data was read, copied or restored |
|
||||||
|
| **Why** | The machine this ran on has no route to production: no `/opt/job-tracker`, no `DATABASE_PROVIDER` or `JOBTRACKER_CONNECTION_STRING` in its `.env`, and the local stack runs SQLite. The production host, user and key are CI secrets (`PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`) that are not available here |
|
||||||
|
|
||||||
|
The commands below are the ones to run against production. They are recorded so the owner can execute
|
||||||
|
the same sequence with production values substituted.
|
||||||
|
|
||||||
|
## 1. Backup configuration
|
||||||
|
|
||||||
|
Read from `deploy/deploy.sh` and `docker-compose.yml`:
|
||||||
|
|
||||||
|
- `deploy.sh` loads `/opt/job-tracker/shared/.env` into its own shell before any decision.
|
||||||
|
- `validate_deploy_config` runs **before** the backup, and therefore before any build, stop or replace.
|
||||||
|
It requires `DATABASE_PROVIDER` (no default), `JOBTRACKER_CONNECTION_STRING` when the provider is
|
||||||
|
MariaDB, plus `AI_SERVICE_TOKEN` and `AUTH_JWT_KEY`.
|
||||||
|
- Backups land in `/opt/job-tracker/backups` (override with `BACKUP_DIR`), UTC-timestamped, gzipped.
|
||||||
|
Nothing is ever overwritten and nothing is ever auto-deleted.
|
||||||
|
- The password travels via `MYSQL_PWD`, never on the command line.
|
||||||
|
|
||||||
|
## 2. Provider resolution
|
||||||
|
|
||||||
|
`DATABASE_PROVIDER=mariadb` selected the MariaDB dump path. Confirmed by the script's own output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Deployment configuration validated (database provider: mariadb).
|
||||||
|
Backing up MariaDB database 'jobtracker' on <host>:<port> to <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz
|
||||||
|
Backup verified: <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz (8.0K)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The filename is the check that matters.** `jobtracker-<database>-<stamp>.sql.gz` means the MariaDB
|
||||||
|
path ran. A `jobtracker-sqlite-<stamp>.tar.gz` on a MariaDB host would mean the environment is wrong.
|
||||||
|
|
||||||
|
## 3. Commands used
|
||||||
|
|
||||||
|
Schema built by the real application, not by hand — `Database.Migrate()` plus the startup reconciler
|
||||||
|
against an empty MariaDB, producing 42 tables. Representative rows were then seeded across users,
|
||||||
|
companies, applications, career profile and CV variants.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup — the real function from deploy.sh, not a hand-written dump
|
||||||
|
DATABASE_PROVIDER=mariadb \
|
||||||
|
JOBTRACKER_CONNECTION_STRING='Server=<host>;Port=<port>;Database=jobtracker;User Id=<user>;Password=<password>;' \
|
||||||
|
BACKUP_DIR=/opt/job-tracker/backups \
|
||||||
|
deploy/deploy.sh # takes the backup first and aborts the deploy if it fails
|
||||||
|
|
||||||
|
# Integrity and content
|
||||||
|
gzip -t "$BACKUP" # valid archive
|
||||||
|
gzip -dc "$BACKUP" | grep -c 'CREATE TABLE' # schema present
|
||||||
|
gzip -dc "$BACKUP" | tail -1 # "-- Dump completed on ..." trailer
|
||||||
|
|
||||||
|
# Restore into a SEPARATE, empty database — never over a live one
|
||||||
|
gzip -dc "$BACKUP" | MYSQL_PWD='<password>' mariadb --host=<host> --user=<user> jobtracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are supplied via `MYSQL_PWD` so they never reach the process list or the shell history.
|
||||||
|
|
||||||
|
## 4. Backup verification result
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Correct backup type | ✅ `.sql.gz` MariaDB dump, not a volume archive |
|
||||||
|
| File integrity | ✅ `gzip -t` passed |
|
||||||
|
| Schema markers | ✅ 42 `CREATE TABLE` statements |
|
||||||
|
| Dump trailer | ✅ `-- Dump completed on 2026-07-19 19:30:23` — proves the dump was not truncated |
|
||||||
|
| Expected tables | ✅ `AspNetUsers`, `Companies`, `JobApplications`, `CareerProfiles`, `CvVariants`, `JobEvents`, `ApplicationChecklistItems`, `AiInteractions` all present |
|
||||||
|
| Data present | ✅ 7 `INSERT INTO` statements |
|
||||||
|
|
||||||
|
## 5. Restore result
|
||||||
|
|
||||||
|
Restored into a **separate, empty** MariaDB 11 container (0 tables before, 42 after).
|
||||||
|
|
||||||
|
| Entity | Source | Restored | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Users | 1 | 1 | ✅ |
|
||||||
|
| Companies | 2 | 2 | ✅ |
|
||||||
|
| Applications | 2 | 2 | ✅ |
|
||||||
|
| Career profiles | 1 | 1 | ✅ |
|
||||||
|
| CV variants | 1 | 1 | ✅ |
|
||||||
|
|
||||||
|
Beyond counts:
|
||||||
|
|
||||||
|
- **All 42 tables compared** — table lists identical, and **every table's row count matched**.
|
||||||
|
- **Content survived, not just cardinality** — the restored applications resolve their company
|
||||||
|
foreign keys (`Senior Backend Engineer @ Northwind Consulting`), the CV variant kept its name and
|
||||||
|
`IsPublic` flag, and the career profile JSON still contained its languages.
|
||||||
|
- **The application starts against the restored database.** `/health` returned
|
||||||
|
`{"status":"ok","version":"restore-rehearsal"}`, the schema stayed at 42 tables (the reconciler
|
||||||
|
correctly found nothing to do) and rows were preserved. A restore that produces a database the app
|
||||||
|
cannot boot against is not a restore.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Read these before treating the deployment as backed up.
|
||||||
|
|
||||||
|
1. **No production data was touched.** Everything above ran against a locally built MariaDB with
|
||||||
|
seeded rows. It proves the mechanism; it proves nothing about your database.
|
||||||
|
2. **The seeded dataset is tiny** (7 rows). It does not exercise dump duration, disk headroom, lock
|
||||||
|
behaviour under load, or timeout limits on a real dataset. A production database large enough to
|
||||||
|
make `mariadb-dump` slow could behave differently.
|
||||||
|
3. **Character-set and collation fidelity was not stress-tested.** The seeded data was mostly ASCII.
|
||||||
|
Real CV content contains non-ASCII text — Norwegian `æøå`, accents, CJK. The dump defaults should
|
||||||
|
handle this, but it was not proven here.
|
||||||
|
4. **No restore was performed over a populated database.** The destination was empty. Restoring over
|
||||||
|
an existing database is a different operation with different failure modes.
|
||||||
|
5. **`deploy.sh` was exercised up to and including the backup**, not through the build and container
|
||||||
|
replacement, which would have required a full deployment.
|
||||||
|
6. **The `mariadb-dump` client came from a container** (`mariadb:11`). If the production host has its
|
||||||
|
own client installed, `deploy.sh` uses that instead, and version differences are possible.
|
||||||
|
|
||||||
|
## What the owner still needs to do
|
||||||
|
|
||||||
|
This is the checklist that turns a rehearsal into a verified backup.
|
||||||
|
|
||||||
|
- [ ] **Run one backup by hand against production** and confirm the filename is
|
||||||
|
`jobtracker-<database>-<stamp>.sql.gz`.
|
||||||
|
- [ ] **Check the dump size is plausible** for the amount of data you have. A suspiciously small file
|
||||||
|
is the signal worth catching.
|
||||||
|
- [ ] **Restore it into a scratch database** — never over the live one — and confirm row counts for
|
||||||
|
`AspNetUsers`, `JobApplications`, `Companies` and `CareerProfiles` match production.
|
||||||
|
- [ ] **Check non-ASCII text survived.** Open one CV or career profile containing `æ`, `ø` or `å` in
|
||||||
|
the restored copy and confirm it is not mangled. This is the most likely silent failure.
|
||||||
|
- [ ] **Confirm `/opt/job-tracker/backups` has disk headroom** for several dumps.
|
||||||
|
- [ ] **Note how long the dump takes.** It runs before every deploy and blocks it.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Both MariaDB containers and all rehearsal files were removed. No test artefacts remain, and the
|
||||||
|
existing local `jobtracker-backend-1` / `jobtracker-frontend-1` stack was left untouched.
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# Release candidate review
|
||||||
|
|
||||||
|
> 2026-07-19. Final validation pass before the first production deployment. **No application code was
|
||||||
|
> changed by this review** — findings only. Every claim below was checked against the implementation,
|
||||||
|
> not against the other documents. Where a document and the code disagreed, the code is reported.
|
||||||
|
>
|
||||||
|
> Companions: `deploy/first-production-deployment.md` (how to deploy),
|
||||||
|
> `docs/production-readiness-review.md` (what was audited),
|
||||||
|
> `docs/infrastructure/database-ownership.md` (who creates which table),
|
||||||
|
> `docs/release-checklist.md` (state of the build).
|
||||||
|
|
||||||
|
**Updated 2026-07-19 (final pass).** Three findings are now fixed and verified:
|
||||||
|
|
||||||
|
- **B1** — the pre-deploy backup silently backed up the wrong thing (`66b02bc`)
|
||||||
|
- **N2** — `/health` always reported `version: unknown` (`8f6f2ba`)
|
||||||
|
- **CV languages** — human languages were dropped depending on the host's ICU data (`9681618`)
|
||||||
|
|
||||||
|
The first two are recorded under *Closed since the first pass* with their original text, because the
|
||||||
|
failure modes are worth understanding. Also since the previous pass: the deployment sequence was
|
||||||
|
re-verified by line number rather than by prose, and a full backup → restore → start-the-app rehearsal
|
||||||
|
was completed and recorded in
|
||||||
|
[`docs/operations/production-backup-verification.md`](operations/production-backup-verification.md).
|
||||||
|
|
||||||
|
**Release-candidate audit, 2026-07-19.** A full verification pass — 420 backend tests (Windows + Linux,
|
||||||
|
both ICU modes), frontend tests/typecheck/build, all three Docker images, and all four database startup
|
||||||
|
scenarios run against live MariaDB 11 and SQLite — found and fixed two more issues: a follow-up reminder
|
||||||
|
index that never created on MariaDB (and logged a false rollback signal on every boot), and a
|
||||||
|
nondeterministic timeline test. Both are recorded below. Backup → restore → app-start was re-run
|
||||||
|
end to end. No open blocker remains in the code or deployment path.
|
||||||
|
|
||||||
|
**Verdict: one blocker remains, and it is external — CI (B2).** Nothing in the application or the
|
||||||
|
deployment path is now known to be blocking. Everything verified here is *local* verification; CI has
|
||||||
|
proven none of it.
|
||||||
|
|
||||||
|
| Section | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| [READY](#ready--completed-technical-checks) | Verified. Nothing further needed before deploying |
|
||||||
|
| [BLOCKED](#blocked--requires-external-action) | Requires action outside this repository. Deployment is gated on it |
|
||||||
|
| [MANUAL VERIFICATION](#manual-verification--only-the-owner-can-do-these) | Only the owner can confirm — sign-in, real data, production smoke tests |
|
||||||
|
| [Accepted](#accepted-for-the-first-release-non-blocking) | Known limitations, deliberately shipped as-is |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## READY — completed technical checks
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
| Item | Verified against |
|
||||||
|
|---|---|
|
||||||
|
| Startup order is connect → reconcile → `Migrate()` → reconcile → seed | `StartupInitializationExtensions.InitializeJobTrackerAsync`; matches `database-ownership.md` exactly |
|
||||||
|
| Migration ownership | `Companies`, `JobApplications`, `Jobs`, `Correspondences`, `Attachments`, `JobEvents`, `RuleSettings`, Identity — created only by migrations; reconciler repairs but never `CREATE TABLE`s them |
|
||||||
|
| Reconciler ownership | Every Phase 4/5 table is reconciler-owned with a paired no-op migration holding the snapshot. The seven no-op migrations named in `database-ownership.md` all exist on disk |
|
||||||
|
| Dependency guards | Reconciler tables with an FK are guarded on the parent existing, so pass 1 skips and pass 2 creates. `EnsureMySqlIndex` guards on table existence, not just index existence |
|
||||||
|
| Reconciler is non-destructive | `DropMalformedMySqlTable` checks row count first and skips any table holding rows |
|
||||||
|
| Provider selection accepts both spellings | `Program.cs:68` — `provider is "mysql" or "mariadb"`. `deploy/README.md` documents `DATABASE_PROVIDER=mariadb`; that value works |
|
||||||
|
| Rollback reasoning is sound | Phase 4/5 migrations are no-ops, so reverting code never leaves migration history ahead of the schema. Older code ignores the extra tables |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
| Item | Verified against |
|
||||||
|
|---|---|
|
||||||
|
| Explicit authorization | Every user-owned controller carries a class-level `[Authorize(AuthenticationSchemes = "local")]`. Confirmed directly on `JobApplicationsController`, `CompaniesController`, `CorrespondenceController`, `RulesController`, `AttachmentsController`, `BackupController`, `ExportController` |
|
||||||
|
| Independent of `Auth:Require` | The `FallbackPolicy` at `Program.cs:380` is *additional*, not the only defence. `/api/jobapplications` returns 401 with `Auth:Require` unset |
|
||||||
|
| Anonymous surface is a deliberate allow-list | `PublicCvController` (class-level `[AllowAnonymous]`), `ClientErrorsController`, `/health`, and per-method anonymity on `AuthController` / `TwoFactorController` / the two OAuth callbacks |
|
||||||
|
| OAuth callbacks are not an open door | `GmailController.Callback` and `MicrosoftGraphController.Callback` are anonymous by necessity but gated on `ConsumeState(state)`; an unknown or replayed state is rejected before any token exchange |
|
||||||
|
| Tenant isolation | 25 global query filters, all deny-on-null (`CurrentUserId != null && OwnerUserId == CurrentUserId`), covering every owner-scoped root entity |
|
||||||
|
| Attachments are tenant-scoped and path-safe | Resolved through the parent `JobApplication` query (so the job-level filter applies); stored names go through `Path.GetFileName` + `BuildStoredFileName`, so a crafted upload name cannot escape the attachments root |
|
||||||
|
| AI service is not reachable from outside | `ai-service` is on `ai_internal` only — not on `default`, not on the external `shared_services`, no published port. Backend is the only member that can route to it |
|
||||||
|
| AI service authenticates its caller | `X-Ai-Service-Token` middleware with `hmac.compare_digest`; only `/health` is open. Compose declares the token with `:?` so a deploy that forgets it fails loudly |
|
||||||
|
| Public CV is opt-in | Off by default, per-variant, unguessable slug, `noindex` |
|
||||||
|
| Secrets not committed | `.env` is gitignored (`.gitignore:8`); nothing sensitive tracked |
|
||||||
|
| Backup handles the password safely | `MYSQL_PWD`, never on the command line, so it cannot reach the process list or the deploy log. Dump stderr is scrubbed before it is echoed |
|
||||||
|
|
||||||
|
### Application integrity — architecture rules
|
||||||
|
|
||||||
|
| Rule | Verified |
|
||||||
|
|---|---|
|
||||||
|
| **CareerProfile is the only editable career source** | The two Phase 5 services that touch `CareerProfiles` — `ApplicationChecklistService:343` and `ApplicationIntelligenceService:199` — both read `AsNoTracking()`. Nothing downstream writes to it |
|
||||||
|
| **CvVariant is a derived lens** | `ApplicationAssetsService.AttachVariantAsync` writes only `JobApplicationId` and `UpdatedAtUtc`. Attaching a CV to an application never touches variant content |
|
||||||
|
| **Application Workspace aggregates only** | `ApplicationIntelligenceService` and `ApplicationTimelineService` contain **zero** `SaveChanges` calls. They are pure projections |
|
||||||
|
| **JobEvent is the timeline source of truth** | `ApplicationTimelineService` interprets `JobEvent` rows and stores nothing. Emission is centralised in `JobLifecycleEvents` |
|
||||||
|
| **AI is suggestion-only** | Generation appends to `AiInteraction`; nothing is written to a profile, variant, cover letter or prep item without an explicit user save |
|
||||||
|
|
||||||
|
### Deployment sequence — verified against the code, not the prose
|
||||||
|
|
||||||
|
Checked by line number in `deploy/deploy.sh` and `StartupInitializationExtensions.cs` on 2026-07-19.
|
||||||
|
|
||||||
|
**Before anything is replaced** — the order is what matters, and it holds:
|
||||||
|
|
||||||
|
| Order | Step | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Load `/opt/job-tracker/shared/.env` into the deploy shell | `deploy.sh` (before any decision) |
|
||||||
|
| 2 | **Validate configuration** — provider, connection string, `AI_SERVICE_TOKEN`, `AUTH_JWT_KEY` | `deploy.sh:343` |
|
||||||
|
| 3 | **Take and verify the database backup**; abort the deploy if it fails | `deploy.sh:347` |
|
||||||
|
| 4 | Build images | `deploy.sh:375` |
|
||||||
|
| 5 | Replace containers (`up -d --force-recreate`) | `deploy.sh:382` |
|
||||||
|
|
||||||
|
Nothing is built, stopped or replaced before validation and backup. Confirmed: 343 and 347 both
|
||||||
|
precede 375 and 382.
|
||||||
|
|
||||||
|
**During deployment** — backend startup, `InitializeJobTrackerAsync`:
|
||||||
|
|
||||||
|
| Order | Step | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `ReconcileSchema()` — pass 1, repair and reconciler-owned tables | `StartupInitializationExtensions.cs:1965` |
|
||||||
|
| 2 | `Database.Migrate()` — migration-owned tables | `:1973` |
|
||||||
|
| 3 | `ReconcileSchema()` — pass 2, everything pass 1 had to skip | `:1984` |
|
||||||
|
|
||||||
|
**Migrations expected to run: none that create anything.** All seven Phase 4/5 migrations were
|
||||||
|
confirmed to have a **literally empty `Up` body** (0 statements each): `AddCareerProfileRelationalChildren`,
|
||||||
|
`AddCvVariants`, `AddAiInteractions`, `AddApplicationChecklistItems`, `SyncCareerChildKeyLengths`,
|
||||||
|
`AddCoverLetterVersions`, `AddInterviewPrepItems`. The reconciler owns their DDL with correct
|
||||||
|
per-provider types. This is what makes a code rollback safe — migration history never runs ahead of
|
||||||
|
the schema.
|
||||||
|
|
||||||
|
**Health checks and failure behaviour:**
|
||||||
|
|
||||||
|
| Item | Verified |
|
||||||
|
|---|---|
|
||||||
|
| Backend health check | `curl` against `/health`, 90s start period for first-boot reconciliation |
|
||||||
|
| Frontend health check | `wget` against nginx |
|
||||||
|
| Dependency gate | `frontend` declares `depends_on: backend: condition: service_healthy` — a backend that never reports healthy makes `compose up` fail and `set -e` aborts the deploy |
|
||||||
|
| Rollback procedure documented | `deploy/first-production-deployment.md` and `deploy/README.md`, both distinguishing code rollback from database restore |
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
| Item | Verified |
|
||||||
|
|---|---|
|
||||||
|
| CI gates deployment | `deploy` job declares `needs: test` and `if: push && ref == refs/heads/main` |
|
||||||
|
| Deploy is pinned to the tested commit | The remote script `git reset --hard ${{ github.sha }}` and aborts if that commit is not fetchable |
|
||||||
|
| Container dependency ordering | `frontend` declares `depends_on: backend: condition: service_healthy`. A backend that never reports healthy makes `compose up` fail, and `set -e` aborts the deploy — a broken backend cannot present as a running stack |
|
||||||
|
| Health checks exist on both services | `curl` against `/health` for backend (90s start period, covering first-boot reconciliation), `wget` against nginx for frontend |
|
||||||
|
| `/health` does not touch the database | Deliberate: a DB-querying probe would restart a healthy backend on any database blip, and would hand out an unauthenticated way to probe database availability |
|
||||||
|
| Missing `AI_SERVICE_TOKEN` fails the stack | `${AI_SERVICE_TOKEN:?…}` in compose, on both `backend` and `ai-service` |
|
||||||
|
| Missing `AUTH_JWT_KEY` fails the backend | With `Auth__Require=true`, `Program.cs:241` throws `InvalidOperationException`. It does not silently generate an ephemeral key |
|
||||||
|
| Backup rejects a useless dump | `verify_backup` fails the deploy on an empty file, and on a MariaDB dump lacking `CREATE TABLE` |
|
||||||
|
| Backups never overwrite | UTC-timestamped filenames; retention is explicitly manual and the script says so |
|
||||||
|
| **Deploy script loads its own environment** | `deploy.sh` parses the shared `.env` into its own shell before any decision. Values already in the environment (CI's `APP_VERSION`) still win. No value is echoed |
|
||||||
|
| **`DATABASE_PROVIDER` is required, not defaulted** | Missing or unrecognised aborts the deploy. Verified: exits 1, names the variable, leaves no backup file |
|
||||||
|
| **Configuration validated before the stack is touched** | Connection string, `AI_SERVICE_TOKEN` and `AUTH_JWT_KEY` are checked before the backup, and therefore before any build, stop or replace |
|
||||||
|
| **Backups verified per provider** | A dump needs valid gzip, `CREATE TABLE` and the `Dump completed` trailer; an archive needs `jobtracker.db`. Truncated and trailer-stripped dumps both rejected |
|
||||||
|
| **SQLite volume resolved by real name** | Project-prefixed, and fails if absent — the bare name would have created an empty volume and backed that up |
|
||||||
|
| **No secret leakage in deploy output** | Zero occurrences of any password, token or key across every failure-path test |
|
||||||
|
| **`/health` reports the deployed version** | Reads `App:Version`; verified end to end that `App__Version=9.9.9-test` surfaces as `9.9.9-test`, and that an unset value falls back to the assembly version |
|
||||||
|
| Test suite | 420 backend tests pass in Release, and also pass under `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` and on Linux — see the ICU finding below |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Closed since the first pass
|
||||||
|
|
||||||
|
Both were found by this review, fixed in their own commits, and re-verified. Original findings kept.
|
||||||
|
|
||||||
|
### B1. ~~The pre-deploy database backup does not back up the database~~ — **CLOSED 2026-07-19**
|
||||||
|
|
||||||
|
*Original finding, kept because the failure mode is worth understanding:*
|
||||||
|
|
||||||
|
**Severity: critical. This invalidates the restore point that the entire deployment plan depends on.**
|
||||||
|
|
||||||
|
`deploy/deploy.sh` symlinks `/opt/job-tracker/shared/.env` into the checkout so that **docker compose**
|
||||||
|
can read it. It never *sources* it. There is no `set -a`, no `. .env`, no `export` of the database
|
||||||
|
variables anywhere in the script.
|
||||||
|
|
||||||
|
So the script's own shell evaluates:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
local provider="${DATABASE_PROVIDER:-sqlite}"
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATABASE_PROVIDER` is not set in that shell. The CI deploy step exports only `APP_VERSION`,
|
||||||
|
`APP_COMMIT_SHA` and `APP_BUILD_STAMP`, and a non-interactive `ssh` session does not read a profile.
|
||||||
|
**`provider` resolves to `sqlite` on a MariaDB production host.**
|
||||||
|
|
||||||
|
The consequence is not a loud failure, which is what makes this serious:
|
||||||
|
|
||||||
|
1. The SQLite branch runs and tars the `jobtracker_data` volume.
|
||||||
|
2. That volume exists in production (the backend mounts it for `/data`, exports and attachments), so
|
||||||
|
the tar succeeds and produces a non-empty file.
|
||||||
|
3. `verify_backup` is called **without** the `CREATE TABLE` expectation on this path — it only checks
|
||||||
|
the file is non-empty.
|
||||||
|
4. The script prints `Backup verified: …` and the deploy proceeds.
|
||||||
|
|
||||||
|
The operator sees a green backup line and a new file in `/opt/job-tracker/backups`. There is no
|
||||||
|
MariaDB dump. If the deploy then damages the schema, there is nothing to restore.
|
||||||
|
|
||||||
|
**Same root cause, two further silent effects:**
|
||||||
|
|
||||||
|
- `APP_PUBLIC_BASE_URL` is likewise unset, so the public smoke check at the end of `deploy.sh` never
|
||||||
|
runs. `deploy/first-production-deployment.md` states it does.
|
||||||
|
- `OLLAMA_MODEL` is unset, so the post-deploy Ollama warmup never runs.
|
||||||
|
|
||||||
|
**How to confirm before trusting any fix:** run the deploy and check that the newest file in
|
||||||
|
`/opt/job-tracker/backups` is named `jobtracker-<dbname>-<stamp>.sql.gz`, not
|
||||||
|
`jobtracker-sqlite-<stamp>.tar.gz`. The filename alone distinguishes the two paths.
|
||||||
|
|
||||||
|
**Closed.** `deploy/deploy.sh` now:
|
||||||
|
|
||||||
|
1. **Loads `/opt/job-tracker/shared/.env` into its own shell** before any decision. Parsed line by
|
||||||
|
line rather than sourced, because a compose `.env` is not a shell script. Variables already set in
|
||||||
|
the environment win, so CI-provided `APP_VERSION` and friends still override the file. No value is
|
||||||
|
echoed.
|
||||||
|
2. **Requires `DATABASE_PROVIDER` explicitly.** The `:-sqlite` default is gone. Missing means stop and
|
||||||
|
say which variable is missing; an unrecognised value means stop.
|
||||||
|
3. **Validates the rest of the deployment configuration before the backup**, and therefore before
|
||||||
|
anything is built, stopped or replaced: the connection string when the provider needs one,
|
||||||
|
`AI_SERVICE_TOKEN` (compose declares it with `:?`, so missing it would otherwise kill the stack
|
||||||
|
after the images are built) and `AUTH_JWT_KEY` (the backend throws at startup on a blank key, after
|
||||||
|
the containers have been replaced). Names only in the output, never values.
|
||||||
|
4. **Verifies each backup against its own format.** A MariaDB dump must be valid gzip, contain
|
||||||
|
`CREATE TABLE`, and carry the `Dump completed` trailer that `mariadb-dump` writes last — so a dump
|
||||||
|
that died partway through is rejected. A SQLite archive must be valid gzip and actually contain
|
||||||
|
`jobtracker.db`. A tar can no longer pass the dump check, which is precisely what went wrong.
|
||||||
|
5. **Resolves the SQLite volume by its real, project-prefixed name** and fails if it does not exist.
|
||||||
|
The old code named the bare `jobtracker_data`, which on a real deploy would have silently *created*
|
||||||
|
an empty volume and backed that up — a second instance of the same class of bug, found while
|
||||||
|
testing the fix.
|
||||||
|
|
||||||
|
Two side effects of the same root cause are also resolved: `APP_PUBLIC_BASE_URL` now reaches the
|
||||||
|
script, so the post-deploy public smoke check actually runs (and the script says so explicitly when it
|
||||||
|
is unset rather than skipping in silence), as does `OLLAMA_MODEL` for the warmup.
|
||||||
|
|
||||||
|
*Verified against a seeded MariaDB 11 container and real Docker volumes:*
|
||||||
|
|
||||||
|
| Scenario | Result |
|
||||||
|
|---|---|
|
||||||
|
| MariaDB production-style `.env` | ✅ env loaded, MariaDB path selected, `.sql.gz` written containing `CREATE TABLE`, the seeded row, and the `Dump completed` trailer |
|
||||||
|
| `DATABASE_PROVIDER` missing | ✅ exits 1 naming the variable; nothing built, stopped or replaced; no backup file |
|
||||||
|
| `DATABASE_PROVIDER` unrecognised | ✅ exits 1 |
|
||||||
|
| `AI_SERVICE_TOKEN` / `AUTH_JWT_KEY` missing | ✅ both reported in one pass, exits 1 |
|
||||||
|
| SQLite with a populated volume | ✅ `.tar.gz` written and verified |
|
||||||
|
| SQLite volume containing no `jobtracker.db` | ✅ rejected, file deleted |
|
||||||
|
| SQLite volume name not present | ✅ rejected before any container ran; no stray empty volume created |
|
||||||
|
| Broken database credentials | ✅ exits 1, no partial file left behind |
|
||||||
|
| Truncated dump (no `CREATE TABLE`) | ✅ rejected |
|
||||||
|
| Dump with the trailer stripped | ✅ rejected as truncated |
|
||||||
|
| Secret leakage across every test's output | ✅ zero occurrences of any password, token or key |
|
||||||
|
|
||||||
|
### N2. ~~`/health` always reports `version: unknown` under Docker~~ — **CLOSED 2026-07-19**
|
||||||
|
|
||||||
|
*Original finding:* the endpoint read the `APP_VERSION` **environment variable**, but compose passes
|
||||||
|
`App__Version`, which binds to the `App:Version` **configuration key**. No variable by that name
|
||||||
|
existed in the container, so the version was always `unknown`.
|
||||||
|
|
||||||
|
**Closed.** `/health` now reads `App:Version` through `IConfiguration` — the approach
|
||||||
|
`AdminSystemController` already used for the same value. The resolution rule (configured version, else
|
||||||
|
assembly version) moved to a shared `BuildMetadata` helper rather than being written twice, so the
|
||||||
|
admin page and `/health` cannot drift apart.
|
||||||
|
|
||||||
|
*Verified against a running backend:* `App__Version=9.9.9-test` reports `9.9.9-test`; unset reports the
|
||||||
|
assembly version rather than `unknown`. Tests pin the configuration **key**, including that an
|
||||||
|
`App__Version` environment variable binds to `App:Version` — the original bug failed silently, so a
|
||||||
|
behavioural test alone would not have caught it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CV languages depended on the host's ICU data — **CLOSED 2026-07-19**
|
||||||
|
|
||||||
|
Found while investigating a reported CI failure. `HumanLanguageCatalog` built its lookup table solely
|
||||||
|
from `CultureInfo.GetCultures`, so which languages counted as human languages depended on the
|
||||||
|
machine's culture data rather than on the CV. Measured: **806 cultures** on a normal Windows or Linux
|
||||||
|
box, **exactly 1** under globalization-invariant mode.
|
||||||
|
|
||||||
|
Consequences, all silent — no error, no log line:
|
||||||
|
|
||||||
|
| Environment | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Full ICU | Correct |
|
||||||
|
| Trimmed ICU data | Names present in the reduced set survive, the rest are dropped — a CV keeps English and loses Norwegian |
|
||||||
|
| Invariant mode | **Every** language dropped; a CV import loses its Languages section entirely |
|
||||||
|
|
||||||
|
**The tests were right and were not modified.** The catalog is now seeded explicitly with the languages
|
||||||
|
a CV realistically lists, before the culture enumeration, which still runs and still adds breadth.
|
||||||
|
Nothing in the seed collides with a technical skill — `Go`, `Java`, `Swift`, `Rust` and `Basic` are
|
||||||
|
deliberately absent, and `Basic` is also a proficiency level.
|
||||||
|
|
||||||
|
*Verified:* 420 tests pass in four environments — Windows and Linux, each with full ICU and with
|
||||||
|
`DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`. Before the fix the invariant runs failed 5 tests. A new
|
||||||
|
`HumanLanguageCatalogTests` pins the seeded catalog, confirmed non-vacuous by removing the seed and
|
||||||
|
watching 15 tests fail.
|
||||||
|
|
||||||
|
**Why this matters beyond the bug:** the defect was invisible to a normal local test run. It is the
|
||||||
|
clearest evidence in this review that *passing locally* and *correct in the deployed container* are
|
||||||
|
different claims — which is exactly what B2 below leaves unresolved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Follow-up reminder index never created on MariaDB — **CLOSED 2026-07-19**
|
||||||
|
|
||||||
|
Found during the release-candidate audit, watching a real backend boot against an empty MariaDB.
|
||||||
|
|
||||||
|
`IX_JobApplications_OwnerUserId_FollowUpAt` was declared `(OwnerUserId(191), FollowUpAt)` with **no
|
||||||
|
prefix length on `FollowUpAt`**. But `FollowUpAt` is `text` on MariaDB — `JobApplications` is
|
||||||
|
migration-owned and the migration was scaffolded against SQLite, which stores `DateTimeOffset` as
|
||||||
|
`TEXT`. A text column cannot be indexed without a prefix length, so the index failed the 3072-byte key
|
||||||
|
check on **every** MariaDB boot, was caught by `TryCreateIndex`, and was silently skipped.
|
||||||
|
|
||||||
|
Two consequences, both real:
|
||||||
|
|
||||||
|
- The follow-up reminder query (`OwnerUserId + FollowUpAt`) ran unindexed — the index the code says it
|
||||||
|
creates never existed.
|
||||||
|
- Every healthy boot logged `Specified key was too long` — the exact string
|
||||||
|
`deploy/first-production-deployment.md` lists as a **stop-and-roll-back** signal. An operator
|
||||||
|
following the runbook could abort a perfectly good deploy on a false alarm.
|
||||||
|
|
||||||
|
**Fixed** by prefixing `FollowUpAt(20)`, matching the `Status(50)` fix the author already applied one
|
||||||
|
line below for the identical longtext problem. ISO-8601 date strings sort lexicographically, so a
|
||||||
|
20-char prefix stays useful for the reminder scan.
|
||||||
|
|
||||||
|
*Verified* on a fresh empty MariaDB 11 container: index now created with both key parts, **zero**
|
||||||
|
"too long" lines, **zero** skipped indexes, **zero** unhandled exceptions, 42 tables, app healthy.
|
||||||
|
Audited the whole class — `FollowUpAt` was the only unprefixed text column in any reconciler composite
|
||||||
|
index; the datetime columns on reconciler-owned tables are `datetime(6)`.
|
||||||
|
|
||||||
|
### Timeline day-grouping test was nondeterministic — **CLOSED 2026-07-19**
|
||||||
|
|
||||||
|
`Timeline_groups_by_day_newest_first` seeded two "same day" events with
|
||||||
|
`DateTime.Now.AddDays(-3).AddHours(2)`, which crossed midnight whenever the wall clock was within two
|
||||||
|
hours of it — failing the test ~2 hours out of every 24, including in CI. The service is correct
|
||||||
|
(groups by `.Date`); the test was fixed to anchor the older events to `DateTime.Today` plus fixed
|
||||||
|
hours. Verified passing at 22:35 local (inside the failing window) and on Linux, both ICU modes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BLOCKED — requires external action
|
||||||
|
|
||||||
|
One item. Nothing in this repository can clear it.
|
||||||
|
|
||||||
|
### B2. CI is red — deployment is gated on it
|
||||||
|
|
||||||
|
Unchanged and still external to the repository. Commit `8f73548` changed one markdown file and its
|
||||||
|
test job failed in the same duration band as every other run; a change that cannot affect compilation
|
||||||
|
cannot fail a test job. The `deploy` job declares `needs: test`, so nothing promotes until this clears.
|
||||||
|
|
||||||
|
Everything in this review is therefore **local verification**. Blocked on, and needing you:
|
||||||
|
|
||||||
|
- **Job logs** — the Gitea API returns 401 unauthenticated. A read-scoped token would unblock this
|
||||||
|
- **`journalctl -u act_runner --since '2 hours ago'`** on the runner host
|
||||||
|
- **The act_runner container/service configuration**
|
||||||
|
|
||||||
|
Evidence in `docs/infrastructure/runner-investigation.md`.
|
||||||
|
|
||||||
|
**The decision this forces.** Either fix the runner, or deploy deliberately from a locally verified
|
||||||
|
commit with CI knowingly red. The second is a defensible choice for a self-hosted single-node
|
||||||
|
deployment — but it should be a decision rather than a default, and it means the 420 backend tests and
|
||||||
|
128 frontend tests have only ever run on one machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accepted for the first release (non-blocking)
|
||||||
|
|
||||||
|
Known limitations, deliberately shipped as-is. None should stop a deploy; all are worth knowing.
|
||||||
|
|
||||||
|
| # | Finding | Why it is not blocking |
|
||||||
|
|---|---|---|
|
||||||
|
| N1 | ~~**`.env.example` omits `DATABASE_PROVIDER` and `JOBTRACKER_CONNECTION_STRING`**~~ — **CLOSED 2026-07-19** | Both added to the template with the connection-string host caveat. `deploy.sh` now hard-fails without `DATABASE_PROVIDER`, so the template had to name it |
|
||||||
|
| N2 | ~~**`/health` always reports `"version":"unknown"` under Docker**~~ — **CLOSED 2026-07-19** | Fixed; see *Closed since the first pass* above |
|
||||||
|
| N3 | **The post-deploy gate in `deploy.sh` checks `.State == running`, not health** — a container can be `running` while `starting` or `unhealthy` | Harmless in practice: `compose up` already blocks on `service_healthy` for the frontend's dependency, so an unhealthy backend aborts the deploy before this check is reached. The check is weaker than it looks, not wrong |
|
||||||
|
| N4 | **Table-count discrepancy across documents** — `database-ownership.md` records 40 tables on MariaDB; `release-checklist.md` and the runbook say ~42 | Both were measured, at different points in Phase 5. Use "the tables listed in `database-ownership.md` all exist" as the check, not a number |
|
||||||
|
| N5 | **`ai-service` opens completely if `AI_SERVICE_TOKEN` is the empty string** — the middleware is `if AI_SERVICE_TOKEN and …`, so a blank token disables the check rather than failing closed | Compose declares the variable with `:?` on both services, so the stack refuses to start without it. No defence in depth behind that, though |
|
||||||
|
| N6 | **Logging is console-only** | Captured by `docker logs`; adequate for a single-node self-hosted deployment. No retention, structure or aggregation |
|
||||||
|
| N7 | **No global exception handler** | Unhandled errors return a bare 500 with no correlation id. No stack traces leak outside Development, so this is a supportability cost, not a security one |
|
||||||
|
| N8 | **`ClientErrorsController` is anonymous** | By design — browser error reports must work on pages reached before sign-in. Size-limited to 32 KB and field-truncated; stores nothing |
|
||||||
|
| N9 | **A code rollback does not recover rows written to the new tables** | Correct and documented. A *code* rollback loses nothing; only a *database restore* discards checklist items, cover letter versions, interview prep and AI history created since the dump |
|
||||||
|
| N10 | **Prompt quality is unmeasured** | Interview generation now receives analysis and match context. Whether the output is *better* is a judgement no test makes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MANUAL VERIFICATION — only the owner can do these
|
||||||
|
|
||||||
|
Nothing in this review, and nothing in the test suite, covers any of these — every automated check
|
||||||
|
stops at the authentication boundary, and none of it has touched production data.
|
||||||
|
|
||||||
|
### Post-deployment checklist — walk this in order
|
||||||
|
|
||||||
|
Each item names what "wrong" looks like, because "it loaded" is not a check.
|
||||||
|
|
||||||
|
| # | Area | Check | Wrong looks like |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Login** | Sign in with a real existing account | Password rejected, or the session drops on refresh — the latter means `AUTH_JWT_KEY` changed |
|
||||||
|
| 2 | **Existing applications** | The list loads and the **count matches the pre-deploy number** | Any drop. This is the single most important check on the page |
|
||||||
|
| 3 | **Application workspace** | Open one application: Overview, Checklist, Timeline, Analysis, Match | A section erroring. Empty is **correct** for applications that predate the feature |
|
||||||
|
| 4 | **Career profile** | Opens with your real experience, education, skills — and **languages** | Languages missing or reduced to English only. That is the bug fixed in `9681618`; if it reappears the container's ICU data differs from what was tested |
|
||||||
|
| 5 | **CV builder** | Lists existing variants; open one; it renders with its theme | A variant that opens blank, or loses its theme |
|
||||||
|
| 6 | **Public CV** | Open `/cv/<slug>` for an already-public variant, then **refresh it** | A 404 on refresh — that is SPA deep-link routing, not the CV |
|
||||||
|
| 7 | **AI features** | Run one generation (interview prep or cover letter) | A 5xx, or a hang. If `ai-service` is down the deploy still succeeds — it is not a deploy gate |
|
||||||
|
| 8 | **Attachments** | Download an existing attachment from an old application | A 404. Attachments live in the `jobtracker_data` volume; a missing file means the volume did not survive |
|
||||||
|
|
||||||
|
Then confirm `/health` reports the version you deployed rather than `1.0.0.0`.
|
||||||
|
|
||||||
|
### Backup and restore readiness
|
||||||
|
|
||||||
|
A full backup → verify → restore → start-the-app rehearsal was completed on 2026-07-19 and is recorded
|
||||||
|
in [`docs/operations/production-backup-verification.md`](operations/production-backup-verification.md):
|
||||||
|
42 tables dumped and restored into a clean MariaDB 11 container, every table's row count identical,
|
||||||
|
content and foreign keys intact, and the application started healthy against the restored database.
|
||||||
|
|
||||||
|
**That rehearsal used seeded data, not production data.** No production host was contacted. What
|
||||||
|
remains unproven is the one thing a rehearsal cannot prove: that it works **on your database**.
|
||||||
|
|
||||||
|
- [ ] **Take a MariaDB dump by hand from production and restore it into a scratch database.** Not a
|
||||||
|
formality. Container verification says the mechanism is sound; it says nothing about your data
|
||||||
|
volume, your disk space, or your MariaDB version's dump quirks.
|
||||||
|
- [ ] **Confirm `/opt/job-tracker/backups` exists and has room.** `deploy.sh` creates it, but a
|
||||||
|
full disk fails the backup and therefore the deploy.
|
||||||
|
- [ ] **Know which backup is the restore point** before you start. After the deploy, confirm the newest
|
||||||
|
file is `jobtracker-<database>-<stamp>.sql.gz` — a `jobtracker-sqlite-*.tar.gz` would mean the
|
||||||
|
environment is wrong, though the provider check should now stop that first.
|
||||||
|
- [ ] **Check non-ASCII text survived the round trip.** Open a restored CV or career profile containing
|
||||||
|
`æ`, `ø` or `å` and confirm it is not mangled. The rehearsal data was ASCII-heavy, so this is the
|
||||||
|
most likely silent failure and the least likely to be noticed.
|
||||||
|
|
||||||
|
### Before deploying
|
||||||
|
|
||||||
|
- [ ] **Confirm `/opt/job-tracker/shared/.env` contains `DATABASE_PROVIDER=mariadb` and
|
||||||
|
`JOBTRACKER_CONNECTION_STRING`.** `deploy.sh` now aborts without them, so a missing value costs
|
||||||
|
an aborted deploy rather than a bad backup — but check first and skip the round trip.
|
||||||
|
- [ ] **Confirm the connection-string host resolves *from inside the backend container*** —
|
||||||
|
`127.0.0.1` there means the container, not the Docker host.
|
||||||
|
- [ ] **Record the current commit** (`git rev-parse HEAD`) and the current row counts for
|
||||||
|
`JobApplications` and `Companies`. The row counts are the check that matters most afterwards.
|
||||||
|
|
||||||
|
### After deploying — beyond the eight-point checklist above
|
||||||
|
|
||||||
|
- [ ] **Email and calendar integrations still connect**, if you use them — the OAuth callbacks were
|
||||||
|
reviewed statically but never exercised against live Google or Microsoft.
|
||||||
|
- [ ] **The full journey once, deliberately** — empty profile through to a recorded outcome. No one has
|
||||||
|
walked it. Friction in that path is currently unmapped.
|
||||||
|
|
||||||
|
### Why this section cannot shrink
|
||||||
|
|
||||||
|
Authenticated smoke testing is not an automation gap that more work would close. Signing in requires a
|
||||||
|
password, and no automated step in this repository should ever handle one. The 420 backend tests
|
||||||
|
verify that the authorization boundary *exists* and holds; only you can verify what is behind it, with
|
||||||
|
your data.
|
||||||
Reference in New Issue
Block a user