diff --git a/.changeset/repair-postgres-migration-configuration.md b/.changeset/repair-postgres-migration-configuration.md new file mode 100644 index 000000000..7254f09c2 --- /dev/null +++ b/.changeset/repair-postgres-migration-configuration.md @@ -0,0 +1,5 @@ +--- +"agentweaver": patch +--- + +PostgreSQL migration containers now use their injected runtime database configuration instead of an image-embedded local database address. diff --git a/apps/Agentweaver.Api/MemoryDbContextDesignFactory.cs b/apps/Agentweaver.Api/MemoryDbContextDesignFactory.cs index 75c30d868..f5d28bf6b 100644 --- a/apps/Agentweaver.Api/MemoryDbContextDesignFactory.cs +++ b/apps/Agentweaver.Api/MemoryDbContextDesignFactory.cs @@ -1,25 +1,48 @@ -using Agentweaver.Api.Memory; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Agentweaver.Api; - -public sealed class MemoryDbContextDesignFactory : IDesignTimeDbContextFactory -{ - public MemoryDbContext CreateDbContext(string[] args) - { - var options = new DbContextOptionsBuilder(); - if (args.Contains("--postgres-migrations", StringComparer.Ordinal)) - { - options.UseNpgsql( - "Host=localhost;Database=agentweaver_design;Username=postgres;Password=postgres", - npg => npg.MigrationsAssembly("Agentweaver.Api.Migrations.Postgres")); - } - else - { - options.UseSqlite("Data Source=agentweaver-design.db", sqlite => sqlite.MigrationsAssembly("Agentweaver.Api")); - } - - return new MemoryDbContext(options.Options); - } -} +using Agentweaver.Api.Memory; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace Agentweaver.Api; + +public sealed class MemoryDbContextDesignFactory : IDesignTimeDbContextFactory +{ + public MemoryDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder(); + if (args.Contains("--postgres-migrations", StringComparer.Ordinal)) + { + options.UseNpgsql( + ResolvePostgresConnectionString(), + npg => npg.MigrationsAssembly("Agentweaver.Api.Migrations.Postgres")); + } + else + { + options.UseSqlite("Data Source=agentweaver-design.db", sqlite => sqlite.MigrationsAssembly("Agentweaver.Api")); + } + + return new MemoryDbContext(options.Options); + } + + private static string ResolvePostgresConnectionString() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; + var configurationBuilder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true); + + if (string.Equals(environment, "Development", StringComparison.OrdinalIgnoreCase)) + configurationBuilder.AddUserSecrets(optional: true); + + var configuration = configurationBuilder + .AddEnvironmentVariables() + .Build(); + + return configuration.GetConnectionString("Postgres") + ?? configuration.GetConnectionString("MemoryDb") + ?? configuration["Database:ConnectionString"] + ?? throw new InvalidOperationException( + "ConnectionStrings:Postgres (or MemoryDb / Database:ConnectionString) is required when using --postgres-migrations."); + } +} diff --git a/docs/guide/architecture-aks.md b/docs/guide/architecture-aks.md index c8884285b..bdf0f8125 100644 --- a/docs/guide/architecture-aks.md +++ b/docs/guide/architecture-aks.md @@ -231,9 +231,9 @@ PVC: agentweaver-workspace (Azure Files, RWX) ### EF Core migrations -On startup, the API runs schema migrations via an **init container** (`migrate-memory-db`) that executes the EF bundle (`/app/efbundle`) against the Postgres connection string. This runs before the main API container starts, ensuring the schema is always current before the application accepts traffic. +On startup, the API and worker run schema migrations via their **init containers** (`migrate-memory-db`). They execute the EF bundle as `/app/efbundle --verbose -- --postgres-migrations`, which selects the Postgres migrations assembly and reads the production connection string from the injected configuration. This runs before the main container starts, ensuring the schema is always current before the application accepts traffic. -The init container uses the same image as the API (`agentweaver-api:${IMAGE_TAG}`) and reads `ConnectionStrings__MemoryDb` + `ConnectionStrings__Postgres` from the `agentweaver-postgres` Secret. +The init container uses the same image as the API (`agentweaver-api:${IMAGE_TAG}`) and reads `ConnectionStrings__MemoryDb` + `ConnectionStrings__Postgres` from the `agentweaver-postgres` Secret. No connection string is embedded in the image or manifest. Local design-time commands remain SQLite by default; use `--postgres-migrations` only when a Postgres connection string is supplied through configuration, environment variables, or Development user secrets. ### Ephemeral storage for testing diff --git a/k8s/base/api-deployment.yaml b/k8s/base/api-deployment.yaml index 1c0660dba..56766b6d3 100644 --- a/k8s/base/api-deployment.yaml +++ b/k8s/base/api-deployment.yaml @@ -48,6 +48,8 @@ spec: command: - /app/efbundle - --verbose + - -- + - --postgres-migrations env: - name: ASPNETCORE_ENVIRONMENT value: Production diff --git a/k8s/base/worker-deployment.yaml b/k8s/base/worker-deployment.yaml index f4f6b2400..595391e99 100644 --- a/k8s/base/worker-deployment.yaml +++ b/k8s/base/worker-deployment.yaml @@ -49,6 +49,8 @@ spec: command: - /app/efbundle - --verbose + - -- + - --postgres-migrations env: - name: ASPNETCORE_ENVIRONMENT value: Production diff --git a/k8s/reference/job-ef-migrate-postgres.yaml b/k8s/reference/job-ef-migrate-postgres.yaml index 5d6ef5cd4..c070d5d65 100644 --- a/k8s/reference/job-ef-migrate-postgres.yaml +++ b/k8s/reference/job-ef-migrate-postgres.yaml @@ -29,6 +29,8 @@ spec: command: - /app/efbundle - --verbose + - -- + - --postgres-migrations env: - name: ASPNETCORE_ENVIRONMENT value: Production diff --git a/tests/Agentweaver.Tests/Persistence/MemoryDbContextDesignFactoryTests.cs b/tests/Agentweaver.Tests/Persistence/MemoryDbContextDesignFactoryTests.cs new file mode 100644 index 000000000..cb6ae0949 --- /dev/null +++ b/tests/Agentweaver.Tests/Persistence/MemoryDbContextDesignFactoryTests.cs @@ -0,0 +1,100 @@ +using Agentweaver.Api; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Agentweaver.Tests.Persistence; + +public sealed class MemoryDbContextDesignFactoryTests +{ + [Fact] + public void PostgresMigrations_UsesInjectedPostgresConnectionString() + { + const string connectionString = + "Host=postgres-service;Database=agentweaver;Username=agentweaver;Password=test-only"; + + WithEnvironmentVariable("ConnectionStrings__Postgres", connectionString, () => + { + using var context = new MemoryDbContextDesignFactory() + .CreateDbContext(["--postgres-migrations"]); + + context.Database.ProviderName.Should().Be("Npgsql.EntityFrameworkCore.PostgreSQL"); + context.Database.GetDbConnection().ConnectionString.Should().Be(connectionString); + }); + } + + [Fact] + public void DefaultDesignTimeWorkflow_RemainsLocalSqlite() + { + using var context = new MemoryDbContextDesignFactory().CreateDbContext([]); + + context.Database.ProviderName.Should().Be("Microsoft.EntityFrameworkCore.Sqlite"); + context.Database.GetDbConnection().ConnectionString.Should().Be("Data Source=agentweaver-design.db"); + } + + [Fact] + public void PostgresMigrations_WithoutConnectionString_DoesNotFallBackToSqlite() + { + WithEnvironmentVariables( + new Dictionary + { + ["ASPNETCORE_ENVIRONMENT"] = "Production", + ["ConnectionStrings__Postgres"] = null, + ["ConnectionStrings__MemoryDb"] = null, + ["Database__ConnectionString"] = null, + }, + () => + { + var create = () => new MemoryDbContextDesignFactory() + .CreateDbContext(["--postgres-migrations"]); + + create.Should().Throw() + .WithMessage("*ConnectionStrings:Postgres*"); + }); + } + + [Fact] + public void Factory_DoesNotEmbedLocalhostPostgresConfiguration() + { + var source = File.ReadAllText( + Path.Combine(RepositoryRoot(), "apps", "Agentweaver.Api", "MemoryDbContextDesignFactory.cs")); + + source.Should().NotContain("Host=localhost", "production migration configuration must be injected"); + source.Should().Contain("AddEnvironmentVariables"); + source.Should().Contain("AddUserSecrets"); + } + + private static void WithEnvironmentVariable(string name, string value, Action assertion) + => WithEnvironmentVariables(new Dictionary { [name] = value }, assertion); + + private static void WithEnvironmentVariables( + IReadOnlyDictionary values, + Action assertion) + { + var previous = values.Keys.ToDictionary( + name => name, + Environment.GetEnvironmentVariable, + StringComparer.Ordinal); + try + { + foreach (var (name, value) in values) + Environment.SetEnvironmentVariable(name, value); + + assertion(); + } + finally + { + foreach (var (name, value) in previous) + Environment.SetEnvironmentVariable(name, value); + } + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "agentweaver.sln"))) + directory = directory.Parent; + + return directory?.FullName + ?? throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} diff --git a/tests/Agentweaver.Tests/Persistence/PostgresMigrationManifestTests.cs b/tests/Agentweaver.Tests/Persistence/PostgresMigrationManifestTests.cs new file mode 100644 index 000000000..831d05a40 --- /dev/null +++ b/tests/Agentweaver.Tests/Persistence/PostgresMigrationManifestTests.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; +using FluentAssertions; + +namespace Agentweaver.Tests.Persistence; + +public sealed class PostgresMigrationManifestTests +{ + [Theory] + [InlineData("api-deployment.yaml")] + [InlineData("worker-deployment.yaml")] + public void InitContainer_ForwardsPostgresMigrationsArgument(string fileName) + { + var manifest = File.ReadAllText(Path.Combine(RepositoryRoot(), "k8s", "base", fileName)); + + var command = Regex.Match( + manifest, + @"(?s)- name: migrate-memory-db\s+image:.*?command:\s+(?- /app/efbundle\s+- --verbose\s+- --\s+- --postgres-migrations)\s+env:"); + + command.Success.Should().BeTrue($"{fileName} must select the Postgres EF migrations assembly"); + manifest.Should().MatchRegex( + @"(?s)- name: ConnectionStrings__Postgres\s+valueFrom:\s+secretKeyRef:\s+name: agentweaver-postgres\s+key: connectionstring"); + } + + [Fact] + public void ReferencePostgresMigrationJob_ForwardsPostgresMigrationsArgument() + { + var manifest = File.ReadAllText( + Path.Combine(RepositoryRoot(), "k8s", "reference", "job-ef-migrate-postgres.yaml")); + + manifest.Should().MatchRegex( + @"(?s)command:\s+- /app/efbundle\s+- --verbose\s+- --\s+- --postgres-migrations\s+env:"); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "agentweaver.sln"))) + directory = directory.Parent; + + return directory?.FullName + ?? throw new DirectoryNotFoundException("Could not locate the repository root."); + } +}