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 VI: Rendering -- From C# Back to Ruby

The builder produces a C# object graph. Docker needs a Ruby string. The renderer bridges that gap: it walks the object tree, converts each property back to the correct Ruby syntax, and produces the exact GITLAB_OMNIBUS_CONFIG value that the GitLab container expects.

The Renderer

GitLabRbRenderer is a static class that converts a GitLabOmnibusConfig into a Ruby string. It handles three categories of output:

  1. Standalone URLs -- external_url 'https://...' (single-quoted, no prefix)
  2. Per-prefix settings -- prefix['key'] = value (bracket notation)
  3. Roles -- roles ['role1', 'role2'] (standalone list)
public static class GitLabRbRenderer
{
    public static string Render(GitLabOmnibusConfig config)
    {
        var sb = new StringBuilder();

        // 1. Standalone URLs (single-quoted)
        RenderStandaloneUrls(sb, config);

        // 2. Per-prefix sections
        foreach (var prop in typeof(GitLabOmnibusConfig)
            .GetProperties(BindingFlags.Public | BindingFlags.Instance))
        {
            if (StandaloneUrlMap.ContainsKey(prop.Name)) continue;
            if (prop.Name == "Roles") continue;

            var value = prop.GetValue(config);
            if (value is null) continue;

            if (!PrefixMap.TryGetValue(prop.Name, out var prefix))
                continue;

            sb.AppendLine();
            RenderSection(sb, prefix, value, new List<string>());
        }

        // 3. Roles (special standalone list)
        if (config.Roles is not null)
        {
            sb.AppendLine();
            sb.AppendLine($"roles {config.Roles}");
        }

        return sb.ToString();
    }
}

Only non-null properties are rendered. If you don't set Nginx.ListenPort, it doesn't appear in the output -- GitLab uses its default. This is the core design principle: explicit configuration only.

Standalone URLs

GitLab has a handful of settings that use a special syntax -- no bracket notation, just a function call with a quoted string:

external_url 'https://gitlab.example.com'
registry_external_url 'https://registry.example.com'
pages_external_url 'https://pages.example.com'
mattermost_external_url 'https://mattermost.example.com'
gitlab_kas_external_url 'wss://kas.example.com'

The renderer maps C# property names to Ruby function names:

private static readonly Dictionary<string, (string RubyKey, bool IsStandalone)>
    StandaloneUrlMap = new()
{
    ["ExternalUrl"] = ("external_url", true),
    ["RegistryExternalUrl"] = ("registry_external_url", true),
    ["PagesExternalUrl"] = ("pages_external_url", true),
    ["MattermostExternalUrl"] = ("mattermost_external_url", true),
    ["GitlabKasExternalUrl"] = ("gitlab_kas_external_url", true),
    ["RuntimeDir"] = ("runtime_dir", true),
};

private static void RenderStandaloneUrls(StringBuilder sb, GitLabOmnibusConfig config)
{
    foreach (var kvp in StandaloneUrlMap)
    {
        var prop = typeof(GitLabOmnibusConfig).GetProperty(kvp.Key);
        var value = prop?.GetValue(config) as string;
        if (value is null) continue;

        sb.AppendLine($"{kvp.Value.RubyKey} '{value}'");
    }
}

Standalone URLs are rendered first, before any prefix sections. They use single quotes (Ruby convention for strings without interpolation).

Prefix Resolution

Each C# property on GitLabOmnibusConfig maps to a Ruby prefix. The mapping is built from property type names with manual overrides for edge cases:

private static Dictionary<string, string> BuildPrefixMap()
{
    var map = new Dictionary<string, string>();
    foreach (var prop in typeof(GitLabOmnibusConfig).GetProperties())
    {
        var typeName = (Nullable.GetUnderlyingType(prop.PropertyType)
            ?? prop.PropertyType).Name;
        if (!typeName.EndsWith("Config")) continue;

        // "NginxConfig" → "nginx", "GitLabRailsConfig" → "gitlab_rails"
        var prefix = ToSnakeCase(typeName.Replace("Config", ""));
        map[prop.Name] = prefix;
    }

    // Fix known special cases
    map["GitlabRails"] = "gitlab_rails";
    map["GitlabWorkhorse"] = "gitlab_workhorse";
    map["GitlabShell"] = "gitlab_shell";
    map["PagesNginx"] = "pages_nginx";
    map["RegistryNginx"] = "registry_nginx";
    map["OmnibusGitconfig"] = "omnibus_gitconfig";
    // ... 20+ more overrides
    return map;
}

The overrides are necessary because ToSnakeCase("GitLabRails") produces git_lab_rails, not gitlab_rails. The 30+ special cases in the prefix map ensure the rendered Ruby exactly matches what GitLab expects.

Rendering Per-Prefix Sections

For each non-null sub-config object, the renderer walks the property tree and emits prefix['key'] = value lines:

private static void RenderSection(StringBuilder sb, string prefix,
    object section, List<string> keyPath)
{
    foreach (var prop in section.GetType()
        .GetProperties(BindingFlags.Public | BindingFlags.Instance))
    {
        var value = prop.GetValue(section);
        if (value is null) continue;

        var rubyKey = ToSnakeCase(prop.Name);
        var currentPath = new List<string>(keyPath) { rubyKey };

        // Sub-object → recurse
        if (IsConfigClass(prop.PropertyType))
        {
            RenderSection(sb, prefix, value, currentPath);
            continue;
        }

        // Leaf value → render
        var bracketPath = string.Join("",
            currentPath.Select(k => $"['{k}']"));
        var rubyValue = FormatRubyValue(value);
        sb.AppendLine($"{prefix}{bracketPath} = {rubyValue}");
    }
}

Nested keys produce multiple brackets: a property at path ["object_store", "connection", "provider"] under gitlab_rails renders as:

gitlab_rails['object_store']['connection']['provider'] = "AWS"

Type Mapping

The FormatRubyValue method converts C# values to Ruby syntax:

private static string FormatRubyValue(object value)
{
    return value switch
    {
        bool b       => b ? "true" : "false",
        int i        => i.ToString(),
        long l       => l.ToString(),
        double d     => d.ToString(CultureInfo.InvariantCulture),
        string s     => $"\"{EscapeRubyString(s)}\"",
        List<string> list => FormatStringList(list),
        List<double> list => FormatDoubleList(list),
        Dictionary<string, string?> dict => FormatStringDict(dict),
        _            => $"\"{value}\""
    };
}
C# Type C# Value Ruby Output
bool true true
bool false false
int 80 80
long 5368709120 5368709120
double 0.75 0.75
string "smtp.example.com" "smtp.example.com"
null (any) (not rendered)
List<string> ["a", "b"] ['a', 'b']
Dictionary<string, string?> {{"k", "v"}} { "k" => "v" }

Key details:

  • Booleans are lowercase. Ruby true/false, not C# True/False.
  • Strings are double-quoted with backslash escaping for \ and ".
  • Lists use single quotes for string items (Ruby convention).
  • Dictionaries use hash-rocket syntax ("key" => "value"). Small dictionaries (3 or fewer entries) render on one line; larger ones get multi-line formatting.

Dictionary Formatting

private static string FormatStringDict(Dictionary<string, string?> dict)
{
    if (dict.Count == 0) return "{}";

    var entries = dict.Select(kvp =>
        $"\"{EscapeRubyString(kvp.Key)}\" => " +
        $"\"{EscapeRubyString(kvp.Value ?? "")}\"");

    if (dict.Count <= 3)
        return "{ " + string.Join(", ", entries) + " }";

    // Multi-line for readability
    var sb = new StringBuilder();
    sb.AppendLine("{");
    foreach (var entry in entries)
        sb.AppendLine($"  {entry},");
    sb.Append("}");
    return sb.ToString();
}

Builder Input

var config = new GitLabOmnibusConfigBuilder()
    .WithExternalUrl("https://gitlab.example.com")
    .WithNginx(n => n
        .WithListenPort(80)
        .WithListenHttps(false)
        .WithProxySetHeaders(new Dictionary<string, string?>
        {
            ["X-Forwarded-Proto"] = "https",
            ["X-Forwarded-Ssl"] = "on",
        }))
    .WithGitlabRails(r => r
        .WithSmtpEnable(true)
        .WithSmtpAddress("smtp.example.com")
        .WithSmtpPort(587))
    .WithRedis(r => r
        .WithEnable(false))
    .WithLetsencrypt(l => l
        .WithEnable(false))
    .Build();

var ruby = GitLabRbRenderer.Render(config);

Rendered Ruby Output

external_url 'https://gitlab.example.com'

nginx['listen_port'] = 80
nginx['listen_https'] = false
nginx['proxy_set_headers'] = { "X-Forwarded-Proto" => "https", "X-Forwarded-Ssl" => "on" }

gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.example.com"
gitlab_rails['smtp_port'] = 587

redis['enable'] = false

letsencrypt['enable'] = false

This is the exact string that gets injected into the GITLAB_OMNIBUS_CONFIG environment variable. Every line is syntactically valid Ruby. Every value is correctly typed and escaped.

PascalCase to snake_case

The reverse naming conversion handles the edge cases of consecutive uppercase letters:

private static string ToSnakeCase(string pascalCase)
{
    var sb = new StringBuilder();
    for (int i = 0; i < pascalCase.Length; i++)
    {
        var c = pascalCase[i];
        if (char.IsUpper(c) && i > 0)
        {
            // Don't insert underscore between consecutive uppercase
            // (e.g., "DB" → "db", not "d_b")
            if (!char.IsUpper(pascalCase[i - 1]) ||
                (i + 1 < pascalCase.Length && char.IsLower(pascalCase[i + 1])))
                sb.Append('_');
        }
        sb.Append(char.ToLowerInvariant(c));
    }
    return sb.ToString();
}

Examples:

  • ListenPortlisten_port
  • SmtpEnableStarttlsAutosmtp_enable_starttls_auto
  • DbHostdb_host
  • ProxySetHeadersproxy_set_headers

The Bridge to Docker Compose

The rendered Ruby string flows into the GitLabComposeContributor, which injects it as an environment variable:

// Inside GitLabComposeContributor.Contribute()
var env = new Dictionary<string, string?>();
if (_omnibusConfig is not null)
    env["GITLAB_OMNIBUS_CONFIG"] = GitLabRbRenderer.Render(_omnibusConfig);

The contributor adds this to the service definition, which eventually serializes into docker-compose.yml:

services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'https://gitlab.example.com'
        nginx['listen_port'] = 80
        nginx['listen_https'] = false
        ...

The full contributor implementation is covered in the next part.

⬇ Download