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);
}[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
}[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));
}[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);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"))// 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.WithGitlabRails(r => r
.WithDbPassword("${GITLAB_DB_PASSWORD}")) // ← Docker Compose variableThe ${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 externalgitlab-runner depends_on gitlab (service_healthy)
gitlab depends_on postgresql (service_healthy) ← if external
gitlab depends_on redis (service_healthy) ← if externalNo 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:
- Find a configuration surface that is large, versioned, and untyped
- Download the source of truth (schemas, templates, specs) at design time
- Parse and merge across versions with a source generator
- Emit typed models with version metadata and fluent builders
- 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.