Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VIII: Testing, Comparison, and Philosophy

The project works. But how do we know it works correctly? How does it compare to other approaches? And what design principles guided the decisions along the way?

Testing Strategy

The test suite covers three layers: rendering correctness, contributor behavior, and parser coverage.

Rendering Tests

The rendering tests validate that GitLabRbRenderer produces syntactically correct Ruby for every value type:

[Fact]
public void Render_BooleanValues_AreLowercase()
{
    var config = new GitLabOmnibusConfigBuilder()
        .WithNginx(n => n
            .WithEnable(true)
            .WithRedirectHttpToHttps(false))
        .Build();

    var ruby = GitLabRbRenderer.Render(config);

    Assert.Contains("nginx['enable'] = true", ruby);
    Assert.Contains("nginx['redirect_http_to_https'] = false", ruby);
    // Not "True" or "False" — Ruby requires lowercase
}

[Fact]
public void Render_StandaloneUrls_UseSingleQuotes()
{
    var config = new GitLabOmnibusConfigBuilder()
        .WithExternalUrl("https://gitlab.example.com")
        .WithRegistryExternalUrl("https://registry.example.com")
        .Build();

    var ruby = GitLabRbRenderer.Render(config);

    Assert.Contains("external_url 'https://gitlab.example.com'", ruby);
    Assert.Contains("registry_external_url 'https://registry.example.com'", ruby);
}

[Fact]
public void Render_Dictionary_UsesHashRocket()
{
    var config = new GitLabOmnibusConfigBuilder()
        .WithNginx(n => n
            .WithProxySetHeaders(new Dictionary<string, string?>
            {
                ["X-Forwarded-Proto"] = "https",
                ["X-Forwarded-Ssl"] = "on",
            }))
        .Build();

    var ruby = GitLabRbRenderer.Render(config);

    Assert.Contains("\"X-Forwarded-Proto\" => \"https\"", ruby);
    Assert.Contains("\"X-Forwarded-Ssl\" => \"on\"", ruby);
}

[Fact]
public void Render_NullProperties_AreOmitted()
{
    var config = new GitLabOmnibusConfigBuilder()
        .WithExternalUrl("https://gitlab.example.com")
        // Nothing else set — all other properties are null
        .Build();

    var ruby = GitLabRbRenderer.Render(config);

    Assert.Contains("external_url", ruby);
    Assert.DoesNotContain("nginx", ruby);
    Assert.DoesNotContain("gitlab_rails", ruby);
}

The test suite also covers:

  • Nested properties rendering to multi-bracket syntax
  • Integer, long, and double formatting
  • String escaping (backslashes, double quotes)
  • List formatting with single-quoted items
  • Multi-line dictionary formatting (4+ entries)
  • Complex nested configurations (object store with multiple levels)

Contributor Tests

Each contributor is tested in isolation: given specific parameters, does it add the correct service definition?

[Fact]
public void GitLabContributor_AddsService_WithHealthcheck()
{
    var composeFile = new ComposeFile();
    var contributor = new GitLabComposeContributor(
        hostname: "gitlab.test",
        rootPassword: "test-password");

    contributor.Contribute(composeFile);

    Assert.True(composeFile.Services.ContainsKey("gitlab"));
    var service = composeFile.Services["gitlab"];
    Assert.Equal("gitlab/gitlab-ce:latest", service.Image);
    Assert.Equal("gitlab.test", service.Hostname);
    Assert.NotNull(service.Healthcheck);
    Assert.Equal("300s", service.Healthcheck.StartPeriod);
    Assert.Contains(service.Environment, e =>
        e.Key == "GITLAB_ROOT_PASSWORD" && e.Value == "test-password");
}

[Fact]
public void PostgresqlContributor_AddsService_WithPgIsReady()
{
    var composeFile = new ComposeFile();
    new PostgresqlComposeContributor(
        database: "testdb",
        username: "testuser"
    ).Contribute(composeFile);

    var service = composeFile.Services["postgresql"];
    Assert.Equal("postgres:16-alpine", service.Image);
    Assert.Contains("pg_isready -U testuser",
        string.Join(" ", service.Healthcheck.Test));
}

[Fact]
public void AllContributors_Compose_FiveServices_SevenVolumes()
{
    var composeFile = new ComposeFile();
    new GitLabComposeContributor().Contribute(composeFile);
    new PostgresqlComposeContributor().Contribute(composeFile);
    new RedisComposeContributor().Contribute(composeFile);
    new GitLabRunnerComposeContributor().Contribute(composeFile);
    new MinioComposeContributor().Contribute(composeFile);

    Assert.Equal(5, composeFile.Services.Count);
    Assert.Equal(7, composeFile.Volumes.Count);
    // gitlab-config, gitlab-logs, gitlab-data,
    // postgresql-data, redis-data, runner-config, minio-data
}

Parser Tests

The source generator tests verify that the Ruby parser handles all patterns correctly across versions:

[Fact]
public void Parse_ScalarSetting_ExtractsType()
{
    var content = "# nginx['listen_port'] = 80";
    var model = GitLabRbParser.Parse(content, "18.10.1");

    var group = model.PrefixGroups.Single(g => g.Prefix == "nginx");
    var node = group.Root.Children["listen_port"];
    Assert.Equal(GitLabRbValueType.Integer, node.LeafType);
}

[Fact]
public void Parse_MultiLineHash_CollectsAllEntries()
{
    var content = @"
# nginx['proxy_set_headers'] = {
#   ""Host"" => ""$http_host"",
#   ""X-Real-IP"" => ""$remote_addr"",
# }";
    var model = GitLabRbParser.Parse(content, "18.10.1");

    var group = model.PrefixGroups.Single(g => g.Prefix == "nginx");
    var node = group.Root.Children["proxy_set_headers"];
    Assert.Equal(GitLabRbValueType.StringDict, node.LeafType);
}

[Fact]
public void Parse_DocComment_AttachesToNextSetting()
{
    var content = @"
##! Most root users won't need this setting.
# nginx['listen_port'] = nil";
    var model = GitLabRbParser.Parse(content, "18.10.1");

    var group = model.PrefixGroups.Single(g => g.Prefix == "nginx");
    var node = group.Root.Children["listen_port"];
    Assert.Contains("Most root users", node.DocComment);
}

[Theory]
[InlineData("true", GitLabRbValueType.Boolean)]
[InlineData("false", GitLabRbValueType.Boolean)]
[InlineData("nil", GitLabRbValueType.Nil)]
[InlineData("80", GitLabRbValueType.Integer)]
[InlineData("'smtp.server'", GitLabRbValueType.String)]
[InlineData("[]", GitLabRbValueType.StringList)]
[InlineData("{}", GitLabRbValueType.StringDict)]
public void InferValueType_CorrectlyIdentifies(
    string value, GitLabRbValueType expected)
{
    Assert.Equal(expected, GitLabRbParser.InferValueType(value));
}

Raw docker-compose.yml

The approach most people start with: write the YAML by hand.

Aspect Raw YAML GitLab.DockerCompose
Validation Container startup Compile time
IntelliSense None Full autocomplete with docs
Version awareness Manual [SinceVersion] / [UntilVersion]
Refactoring Find-and-replace in strings IDE rename symbol
Composability Copy-paste fragments IComposeFileContributor
Learning curve Low Requires C# toolchain
Portability Universal .NET ecosystem

Ansible Roles

Ansible roles like geerlingguy.gitlab provide a higher-level abstraction over docker-compose.yml:

Aspect Ansible Role GitLab.DockerCompose
Configuration YAML variables Typed C# builders
Validation Ansible lint + runtime Compile time
Version tracking Role version ≠ GitLab version Per-setting version metadata
Execution Ansible → SSH → Docker Direct Docker Compose
Dependencies Python + Ansible + Galaxy .NET SDK

Ansible adds a useful abstraction layer but introduces its own complexity (Python environment, SSH, playbook structure). The typed approach trades that for compile-time guarantees.

Helm Charts

GitLab provides an official Helm chart for Kubernetes:

Aspect Helm Chart GitLab.DockerCompose
Target Kubernetes Docker Compose
Configuration values.yaml Typed C# builders
Validation Helm lint Compile time
Complexity High (100+ YAML templates) Moderate (5 contributors)
Scale Production clusters Small/medium, HomeLab

Helm is the right choice for Kubernetes-scale deployments. GitLab.DockerCompose is for Docker Compose environments where Helm's complexity isn't justified.

Pulumi / CDK

Infrastructure-as-code tools like Pulumi offer typed configuration in general-purpose languages:

Aspect Pulumi GitLab.DockerCompose
Language TypeScript, Python, Go, C# C#
Scope Full cloud infrastructure Docker Compose only
GitLab knowledge Generic Docker provider GitLab-specific typed models
Version metadata None [SinceVersion] / [UntilVersion]
State management Pulumi state backend None (stateless)

Pulumi is broader in scope but doesn't know about GitLab's configuration surface. It would let you define a Docker container with an environment variable, but you'd still be writing the GITLAB_OMNIBUS_CONFIG string by hand.

1. Typed Configuration

The core thesis: configuration is code, and code should be typed. An untyped string is a bug waiting to happen. A typed property with IntelliSense is a bug prevented.

This extends the philosophy from Infrastructure as Code and the Contention over Convention series: if the compiler can catch it, the compiler should catch it.

2. Contributor Composition

No single monolithic docker-compose.yml. Instead, independent contributors that add their concern to a shared model:

composeFile.Apply(
    gitlabContributor,
    postgresqlContributor,
    redisContributor,
    runnerContributor,
    minioContributor);

Add a service: add a contributor. Remove a service: remove the call. No YAML merge conflicts. No forgetting to update a volume or remove a dependency.

3. Embrace Omnibus

GitLab Omnibus bundles everything -- PostgreSQL, Redis, NGINX, Puma, Sidekiq, Gitaly, Prometheus -- in a single container. For small and medium deployments, this is pragmatic. The typed config defaults to bundled services and lets you externalize selectively:

// Bundled (default): just set the URL
.WithExternalUrl("https://gitlab.example.com")

// External PostgreSQL: two changes
.WithPostgresql(p => p.WithEnable(false))
.WithGitlabRails(r => r.WithDbHost("postgresql"))

The contributor pattern matches this: PostgresqlComposeContributor is opt-in. If you don't call it, the bundled PostgreSQL inside the GitLab container handles everything.

4. Traefik as TLS Terminator

The reference architecture puts Traefik in front of GitLab:

  • GitLab speaks HTTP internally (nginx['listen_port'] = 80, nginx['listen_https'] = false)
  • Traefik handles HTTPS (certificate management, renewal, HTTP→HTTPS redirect)
  • No double-TLS (Traefik → GitLab is plain HTTP on the Docker network)

This pattern applies to all GitLab subservices: Registry, Pages, Mattermost. One TLS terminator, one certificate resolver, consistent behavior.

5. Secrets via Environment Variables

Passwords are never hardcoded in the configuration:

.WithGitlabRails(r => r
    .WithDbPassword("${GITLAB_DB_PASSWORD}"))     // ← Docker Compose variable

The ${VAR} syntax is a Docker Compose environment variable reference. Actual values live in a .env file or the deployment environment -- never in the codebase.

6. Health-First Orchestration

Every service has a healthcheck. Dependents use service_healthy:

gitlab-runner depends_on gitlab (service_healthy)
gitlab depends_on postgresql (service_healthy)  ← if external
gitlab depends_on redis (service_healthy)       ← if external

No polling scripts. No sleep 300 && register-runner. Docker Compose handles the startup order through health conditions.

What This Project Demonstrates

GitLab.DockerCompose is a specific application of a general pattern:

  1. Find a configuration surface that is large, versioned, and untyped
  2. Download the source of truth (schemas, templates, specs) at design time
  3. Parse and merge across versions with a source generator
  4. Emit typed models with version metadata and fluent builders
  5. Render back to the original format at runtime

The same pattern applies to Docker Compose schemas, Traefik configurations, and potentially any tool that exposes a configuration surface through a parseable format. The Docker Compose Bundle and Traefik Bundle use JSON Schema as input. GitLab.DockerCompose proves the pattern works even when the input format was never designed to be machine-readable.

The compiler doesn't just check your code. It checks your infrastructure.

⬇ Download