Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions scripts/validate-gm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ s = open(path).read().replace("<!-- GOLDPATH_FEED -->", f'<add key="goldpath-loc
open(path, "w").write(s)
PY

# The engine, resolved ONCE and BEFORE any CLI verb runs. Pinned, consumed as the
# PUBLISHED tool (README dependency policy: source references are the exception, not the
# silent default). A local checkout is used only via an explicit GOLDPATH_SPECDRIFT_SRC
# and announces itself — a green local run must mean a green CI run.
#
# It is exported as GOLDPATH_SPECDRIFT because the post-generation verbs (goldpath new
# service|gateway) gate their own result through the engine: the nightly's GmSixGateway
# shape was red for three weeks on "could not run 'specdrift'" — the tool was installed
# only at spec-lint, AFTER those verbs, and into a directory that is not on PATH.
SPECDRIFT_VERSION=0.4.2
if [ -n "${GOLDPATH_SPECDRIFT_SRC:-}" ]; then
echo "── engine: using LOCAL specdrift checkout (GOLDPATH_SPECDRIFT_SRC=$GOLDPATH_SPECDRIFT_SRC) — not the $SPECDRIFT_VERSION pin"
SPECDRIFT="dotnet run --project $GOLDPATH_SPECDRIFT_SRC/src/Specdrift --"
else
[ -x "$WORK/tools/specdrift" ] || dotnet tool install --tool-path "$WORK/tools" specdrift --version "$SPECDRIFT_VERSION" >/dev/null
SPECDRIFT="$WORK/tools/specdrift"
fi
export GOLDPATH_SPECDRIFT="$SPECDRIFT"

echo "── initial migration (goldpath db init — Development migrates, EnsureCreated is gone)"
(cd "$APP" && dotnet run --project "$ROOT/tools/Goldpath.Cli" -- db init --path .)

Expand All @@ -87,18 +106,6 @@ if [ -d "$APP/src/$NAME.Api" ]; then
fi

echo "── spec-lint (specdrift: validate + drift)"
# Pinned engine, consumed as the PUBLISHED tool (README dependency policy: source
# references are the exception, not the silent default). A local checkout is used only
# via an explicit GOLDPATH_SPECDRIFT_SRC and announces itself — a green local run must
# mean a green CI run.
SPECDRIFT_VERSION=0.4.2
if [ -n "${GOLDPATH_SPECDRIFT_SRC:-}" ]; then
echo "── spec-lint: using LOCAL specdrift checkout (GOLDPATH_SPECDRIFT_SRC=$GOLDPATH_SPECDRIFT_SRC) — not the $SPECDRIFT_VERSION pin"
SPECDRIFT="dotnet run --project $GOLDPATH_SPECDRIFT_SRC/src/Specdrift --"
else
[ -x "$WORK/tools/specdrift" ] || dotnet tool install --tool-path "$WORK/tools" specdrift --version "$SPECDRIFT_VERSION" >/dev/null
SPECDRIFT="$WORK/tools/specdrift"
fi
$SPECDRIFT validate "$APP/.goldpath/manifest.yaml" --schema "$ROOT/schemas/manifest/v1/goldpath-manifest.schema.json" --rules "$APP/.specdrift/rules.yaml"
# First generation: commit the contract (what a team does on day one), then drift must be clean.
if [ -d "$APP/src/$NAME.Api" ]; then
Expand Down
879 changes: 879 additions & 0 deletions tests/Goldpath.Cli.Tests/AddWorkerMutationTests.cs

Large diffs are not rendered by default.

400 changes: 400 additions & 0 deletions tests/Goldpath.Cli.Tests/DbCommandMutationTests.cs

Large diffs are not rendered by default.

311 changes: 311 additions & 0 deletions tests/Goldpath.Cli.Tests/ExportMutationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
using Xunit;

namespace Goldpath.Cli.Tests;

/// <summary>
/// Exact-output tests for <c>goldpath export compose</c>: the compose file is a GENERATED
/// artifact, so every line, indentation and ordering is contract — a loose Contains would
/// let a dropped line or a renamed env var slip through (the mutation survivors showed it).
/// </summary>
public class ExportMutationTests
{
// Every vocabulary word once: each container kind, a postgres with and without a database,
// a project with env + healthcheck + every container reference, a bare project, a project
// referencing projects, and a reference to a variable the AppHost never declared.
private const string AppHost = """
var builder = DistributedApplication.CreateBuilder(args);

var database = builder.AddPostgres("dbserver").AddDatabase("ordersdb");
var mssql = builder.AddSqlServer("mssql").AddDatabase("legacydb");
var messaging = builder.AddRabbitMQ("messaging");
var cache = builder.AddRedis("redis");
builder.AddPostgres("plain");

var api = builder.AddProject<Projects.Shop_Api>("api")
.WithReference(database).WaitFor(database)
.WithReference(mssql)
.WithReference(messaging).WaitFor(messaging)
.WithReference(cache).WaitFor(cache)
.WithEnvironment("Worker:Interval", "00:00:01")
.WithHttpHealthCheck("/health/ready");

var worker = builder.AddProject<Projects.Shop_Worker>("worker")
.WithReference(database);

builder.AddProject<Projects.Shop_Gateway>("gateway")
.WithReference(api)
.WithReference(worker)
.WithReference(ghost)
.WithHttpHealthCheck("/health/ready");

builder.Build().Run();
""";

private const string ExpectedCompose = """
# GENERATED by `goldpath export compose` FROM the AppHost — do not hand-edit;
# change the AppHost and re-run (foundation §10: the two definitions cannot diverge).
# DEV tier: fixed credentials, one node. Environments stay CI-built manifests.
services:
dbserver:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: goldpath-dev
POSTGRES_DB: ordersdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
interval: 2s
retries: 30

mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: goldpath-dev1!

messaging:
image: rabbitmq:4
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_port_connectivity"]
interval: 3s
retries: 30

redis:
image: redis:7-alpine

plain:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: goldpath-dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
interval: 2s
retries: 30

api:
build:
context: .
dockerfile: src/Shop.Api/Dockerfile
ports:
- "8080" # random host port — `docker compose port api 8080`
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && head -1 <&3 | grep -q 200"]
interval: 3s
retries: 40
environment:
ASPNETCORE_URLS: http://+:8080
# Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4).
ASPNETCORE_ENVIRONMENT: Development
Worker__Interval: "00:00:01"
ConnectionStrings__ordersdb: Host=dbserver;Port=5432;Database=ordersdb;Username=postgres;Password=goldpath-dev
ConnectionStrings__legacydb: Server=mssql,1433;Database=legacydb;User Id=sa;Password=goldpath-dev1!;TrustServerCertificate=true
ConnectionStrings__messaging: amqp://guest:guest@messaging:5672
ConnectionStrings__redis: redis:6379
depends_on:
dbserver:
condition: service_healthy
messaging:
condition: service_healthy
redis:
condition: service_started

worker:
build:
context: .
dockerfile: src/Shop.Worker/Dockerfile
ports:
- "8080" # random host port — `docker compose port worker 8080`
environment:
ASPNETCORE_URLS: http://+:8080
# Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4).
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__ordersdb: Host=dbserver;Port=5432;Database=ordersdb;Username=postgres;Password=goldpath-dev

gateway:
build:
context: .
dockerfile: src/Shop.Gateway/Dockerfile
ports:
- "8080" # random host port — `docker compose port gateway 8080`
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && head -1 <&3 | grep -q 200"]
interval: 3s
retries: 40
environment:
ASPNETCORE_URLS: http://+:8080
# Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4).
ASPNETCORE_ENVIRONMENT: Development
services__api__http__0: http://api:8080
services__worker__http__0: http://worker:8080
depends_on:
api:
condition: service_healthy
worker:
condition: service_started
""";

// The anchors AppFiles.Locate needs to find the AppHost by content.
private const string Anchors = "\n// goldpath:features resources\n// goldpath:features references\n// goldpath:workers\n";

private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal);

private static string Compose()
=> Lf(ExportCommand.WriteCompose(ExportCommand.Parse(AppHost), safe => safe.Replace('_', '.')));

[Fact]
public void The_compose_file_matches_the_golden_output_byte_for_byte()
{
var compose = Compose();
// Each service ends with a blank separator line — the last one too.
Assert.EndsWith("condition: service_started\n\n", compose, StringComparison.Ordinal);
Assert.Equal(Lf(ExpectedCompose), compose.TrimEnd('\n'));
}

[Fact]
public void Parse_reads_every_field_of_the_vocabulary()
{
var resources = ExportCommand.Parse(AppHost);
Assert.Equal(["dbserver", "mssql", "messaging", "redis", "plain", "api", "worker", "gateway"], resources.Select(r => r.Name));
Assert.Equal(["postgres", "sqlserver", "rabbitmq", "redis", "postgres", "project", "project", "project"], resources.Select(r => r.Kind));

// The chain's variable is the reference handle; a chain without one falls back to the name.
Assert.Equal(["database", "mssql", "messaging", "cache", "plain", "api", "worker", "gateway"], resources.Select(r => r.Variable));

var plain = resources.Single(r => r.Name == "plain");
Assert.Null(plain.DatabaseName);
Assert.Null(plain.ProjectSafe);
Assert.False(plain.HasHealthCheck);
Assert.Equal("legacydb", resources.Single(r => r.Name == "mssql").DatabaseName);

var api = resources.Single(r => r.Name == "api");
Assert.Equal("Shop_Api", api.ProjectSafe);
Assert.True(api.HasHealthCheck);
Assert.Equal(["database", "mssql", "messaging", "cache"], api.References);
Assert.Equal(["database", "messaging", "cache"], api.WaitsFor);
Assert.Equal(new Dictionary<string, string> { ["Worker:Interval"] = "00:00:01" }, api.Environment);

var worker = resources.Single(r => r.Name == "worker");
Assert.False(worker.HasHealthCheck);
Assert.Empty(worker.WaitsFor);
Assert.Empty(worker.Environment);
}

[Fact]
public void A_reference_to_an_undeclared_variable_is_skipped_not_fatal()
{
var resources = ExportCommand.Parse("""
builder.AddProject<Projects.Shop_Api>("api").WithReference(ghost).WaitFor(ghost);
""");
var compose = Lf(ExportCommand.WriteCompose(resources, _ => "Shop.Api"));
Assert.DoesNotContain("ghost", compose, StringComparison.Ordinal);
Assert.DoesNotContain("depends_on", compose, StringComparison.Ordinal);
}

[Fact]
public void A_project_without_dependencies_emits_no_depends_on_block()
{
var resources = ExportCommand.Parse("""
builder.AddProject<Projects.Shop_Api>("api");
""");
var compose = Lf(ExportCommand.WriteCompose(resources, _ => "Shop.Api"));
Assert.Equal("""
# GENERATED by `goldpath export compose` FROM the AppHost — do not hand-edit;
# change the AppHost and re-run (foundation §10: the two definitions cannot diverge).
# DEV tier: fixed credentials, one node. Environments stay CI-built manifests.
services:
api:
build:
context: .
dockerfile: src/Shop.Api/Dockerfile
ports:
- "8080" # random host port — `docker compose port api 8080`
environment:
ASPNETCORE_URLS: http://+:8080
# Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4).
ASPNETCORE_ENVIRONMENT: Development

""".Replace("\r\n", "\n", StringComparison.Ordinal) + "\n", compose);
}

[Fact]
public void WriteCompose_fails_with_the_disagreement_message_when_no_directory_matches()
{
var resources = ExportCommand.Parse("""
builder.AddProject<Projects.Shop_Missing>("api");
""");
var exception = Assert.Throws<CliFailureException>(() => ExportCommand.WriteCompose(resources, _ => null));
Assert.Equal("no project directory matches Projects.Shop_Missing — the AppHost and src/ disagree.", exception.Message);
}

[Fact]
public void The_run_reports_each_step_exactly_and_lays_the_dockerfile_once()
{
using var app = new FakeApp();
var output = new StringWriter();
var error = new StringWriter();

Assert.Equal(0, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), output, error));

var dockerfileRelative = Path.Combine("src", "Shop.Api", "Dockerfile");
Assert.Equal(
"── docker-compose.yml generated FROM the AppHost (re-run after AppHost changes; edits belong upstream)\n"
+ $"── Dockerfile laid: {dockerfileRelative}\n"
+ "── compose is the DEV tier (fixed credentials, one node): environments stay CI-built manifests (foundation §10)\n",
Lf(output.ToString()));
Assert.Equal(string.Empty, error.ToString());

// The compose on disk IS WriteCompose's output for the FakeApp AppHost.
var expected = Lf(ExportCommand.WriteCompose(ExportCommand.Parse(File.ReadAllText(app.AppHost)), _ => "Shop.Api"));
Assert.Equal(expected, Lf(File.ReadAllText(Path.Combine(app.Root, "docker-compose.yml"))));
Assert.Contains(" dbserver:\n image: postgres:17-alpine", expected, StringComparison.Ordinal);

Assert.Equal(Lf("""
# GENERATED by `goldpath export compose` (laid once — edit freely afterwards).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /app
COPY . .
# The host's global.json pins an exact SDK the image may not carry — inside the container
# the IMAGE TAG is the determinism, so the pin steps aside for the build.
RUN rm -f global.json && dotnet publish src/Shop.Api/Shop.Api.csproj -c Release -o /out

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /out .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Shop.Api.dll"]
"""), Lf(File.ReadAllText(Path.Combine(app.Root, dockerfileRelative))));

// Second run: the Dockerfile already exists, so its line is NOT reported again.
var second = new StringWriter();
Assert.Equal(0, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), second, error));
Assert.Equal(
"── docker-compose.yml generated FROM the AppHost (re-run after AppHost changes; edits belong upstream)\n"
+ "── compose is the DEV tier (fixed credentials, one node): environments stay CI-built manifests (foundation §10)\n",
Lf(second.ToString()));
}

[Fact]
public void An_apphost_without_resources_fails_before_writing_anything()
{
using var app = new FakeApp();
File.WriteAllText(app.AppHost, "var builder = DistributedApplication.CreateBuilder(args);" + Anchors + "builder.Build().Run();\n");
var error = new StringWriter();

Assert.Equal(1, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), TextWriter.Null, error));

Assert.Equal("goldpath: the AppHost declares no resources — nothing to export.\n", Lf(error.ToString()));
Assert.False(File.Exists(Path.Combine(app.Root, "docker-compose.yml")));
}

[Fact]
public void A_project_without_a_source_directory_fails_with_the_disagreement_message()
{
using var app = new FakeApp();
File.WriteAllText(app.AppHost, """builder.AddProject<Projects.Shop_Missing>("api");""" + Anchors + "builder.Build().Run();\n");
var error = new StringWriter();

Assert.Equal(1, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), TextWriter.Null, error));

Assert.Equal("goldpath: no project directory matches Projects.Shop_Missing — the AppHost and src/ disagree.\n", Lf(error.ToString()));
Assert.False(File.Exists(Path.Combine(app.Root, "docker-compose.yml")));
}
}
Loading
Loading