Part 24: Talking to GitLab — Omnibus, CLI, REST
"There are three GitLabs: the one you install, the one you scriptize, and the one you call from code. We talk to all three."
Why
GitLab is the single most complex piece of software DevLab hosts. It is also the most central — every dogfood loop from Part 06 routes through GitLab in some way. HomeLab needs to:
- Install GitLab Omnibus with the right
gitlab.rbconfiguration (external URL, SMTP, root password, registry, runner enablement, observability scrape endpoints) - Configure GitLab post-install (create groups, projects, deploy keys, runners, project variables)
- Operate GitLab day-2 (backups, restores, upgrades, user management, runner registration)
There are three surfaces for these concerns:
gitlab.rb— the Omnibus installer's configuration file. Read once at install time. Changed viagitlab-ctl reconfigure. The "low-level" surface.glab— the official GitLab CLI. Useful for some shell-friendly tasks (creating projects, listing pipelines). Wraps the REST API.- REST API — the canonical source of truth for everything GitLab can be told to do. Strongly typed via OpenAPI, accessible over HTTPS.
HomeLab consumes all three. Each one has its own wrapper. The choice of which to use for a given task is principled: structural concerns go through gitlab.rb, day-2 operations go through the REST API, and glab is reserved for the few cases where it is genuinely the right tool.
Surface 1: gitlab.rb — typed Ruby generation
public sealed class GitLabRbConfig
{
public string ExternalUrl { get; set; } = "";
public string? RegistryExternalUrl { get; set; }
public Dictionary<string, object?> Nginx { get; set; } = new();
public Dictionary<string, object?> Postgresql { get; set; } = new();
public Dictionary<string, object?> Redis { get; set; } = new();
public Dictionary<string, object?> GitLabRails { get; set; } = new();
public Dictionary<string, object?> Prometheus { get; set; } = new();
public Dictionary<string, object?> Grafana { get; set; } = new();
public Dictionary<string, object?> RegistryStorage { get; set; } = new();
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRbGenerator
{
public GitLabRbConfig Generate(HomeLabConfig hl)
{
var url = hl.GitLab.ExternalUrl ?? $"https://gitlab.{hl.Acme.Tld}";
return new GitLabRbConfig
{
ExternalUrl = url,
RegistryExternalUrl = $"https://registry.{hl.Acme.Tld}",
Nginx = new()
{
["redirect_http_to_https"] = true,
["ssl_certificate"] = "/etc/gitlab/ssl/wildcard.crt",
["ssl_certificate_key"] = "/etc/gitlab/ssl/wildcard.key",
},
GitLabRails = new()
{
["gitlab_email_from"] = $"gitlab@{hl.Acme.Tld}",
["initial_root_password"] = "{{ secret:gitlab_root_password }}",
["smtp_enable"] = false, // homelab; no SMTP
["registry_enabled"] = true,
},
// GitLab's bundled Prometheus and Grafana are disabled —
// HomeLab uses its own observability stack on the obs VM (Part 44)
Prometheus = new() { ["enable"] = false },
Grafana = new() { ["enable"] = false },
RegistryStorage = new()
{
["s3"] = new Dictionary<string, object?>
{
["accesskey"] = "{{ secret:minio_access_key }}",
["secretkey"] = "{{ secret:minio_secret_key }}",
["bucket"] = "registry",
["regionendpoint"] = $"https://minio.{hl.Acme.Tld}",
["pathstyle"] = true,
}
}
};
}
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRbWriter
{
public string Render(GitLabRbConfig config)
{
var sb = new StringBuilder();
sb.AppendLine($"external_url '{config.ExternalUrl}'");
if (config.RegistryExternalUrl is not null)
sb.AppendLine($"registry_external_url '{config.RegistryExternalUrl}'");
sb.AppendLine();
AppendBlock(sb, "nginx", config.Nginx);
AppendBlock(sb, "gitlab_rails", config.GitLabRails);
AppendBlock(sb, "prometheus", config.Prometheus);
AppendBlock(sb, "grafana", config.Grafana);
AppendBlock(sb, "registry", config.RegistryStorage);
return sb.ToString();
}
private static void AppendBlock(StringBuilder sb, string prefix, Dictionary<string, object?> values)
{
foreach (var (k, v) in values)
{
sb.AppendLine($"{prefix}['{k}'] = {RubyFormat(v)}");
}
sb.AppendLine();
}
private static string RubyFormat(object? v) => v switch
{
null => "nil",
bool b => b ? "true" : "false",
string s when s.StartsWith("{{ secret:") => $"<%= ENV['{ExtractSecretName(s)}'] %>",
string s => $"'{s.Replace("'", "\\'")}'",
int i => i.ToString(),
Dictionary<string, object?> d => "{ " + string.Join(", ", d.Select(kv => $"'{kv.Key}' => {RubyFormat(kv.Value)}")) + " }",
IEnumerable<object?> e => "[ " + string.Join(", ", e.Select(RubyFormat)) + " ]",
_ => v.ToString() ?? "nil"
};
private static string ExtractSecretName(string s) => s.Substring("{{ secret:".Length, s.Length - "{{ secret:".Length - 3).ToUpperInvariant();
}public sealed class GitLabRbConfig
{
public string ExternalUrl { get; set; } = "";
public string? RegistryExternalUrl { get; set; }
public Dictionary<string, object?> Nginx { get; set; } = new();
public Dictionary<string, object?> Postgresql { get; set; } = new();
public Dictionary<string, object?> Redis { get; set; } = new();
public Dictionary<string, object?> GitLabRails { get; set; } = new();
public Dictionary<string, object?> Prometheus { get; set; } = new();
public Dictionary<string, object?> Grafana { get; set; } = new();
public Dictionary<string, object?> RegistryStorage { get; set; } = new();
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRbGenerator
{
public GitLabRbConfig Generate(HomeLabConfig hl)
{
var url = hl.GitLab.ExternalUrl ?? $"https://gitlab.{hl.Acme.Tld}";
return new GitLabRbConfig
{
ExternalUrl = url,
RegistryExternalUrl = $"https://registry.{hl.Acme.Tld}",
Nginx = new()
{
["redirect_http_to_https"] = true,
["ssl_certificate"] = "/etc/gitlab/ssl/wildcard.crt",
["ssl_certificate_key"] = "/etc/gitlab/ssl/wildcard.key",
},
GitLabRails = new()
{
["gitlab_email_from"] = $"gitlab@{hl.Acme.Tld}",
["initial_root_password"] = "{{ secret:gitlab_root_password }}",
["smtp_enable"] = false, // homelab; no SMTP
["registry_enabled"] = true,
},
// GitLab's bundled Prometheus and Grafana are disabled —
// HomeLab uses its own observability stack on the obs VM (Part 44)
Prometheus = new() { ["enable"] = false },
Grafana = new() { ["enable"] = false },
RegistryStorage = new()
{
["s3"] = new Dictionary<string, object?>
{
["accesskey"] = "{{ secret:minio_access_key }}",
["secretkey"] = "{{ secret:minio_secret_key }}",
["bucket"] = "registry",
["regionendpoint"] = $"https://minio.{hl.Acme.Tld}",
["pathstyle"] = true,
}
}
};
}
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRbWriter
{
public string Render(GitLabRbConfig config)
{
var sb = new StringBuilder();
sb.AppendLine($"external_url '{config.ExternalUrl}'");
if (config.RegistryExternalUrl is not null)
sb.AppendLine($"registry_external_url '{config.RegistryExternalUrl}'");
sb.AppendLine();
AppendBlock(sb, "nginx", config.Nginx);
AppendBlock(sb, "gitlab_rails", config.GitLabRails);
AppendBlock(sb, "prometheus", config.Prometheus);
AppendBlock(sb, "grafana", config.Grafana);
AppendBlock(sb, "registry", config.RegistryStorage);
return sb.ToString();
}
private static void AppendBlock(StringBuilder sb, string prefix, Dictionary<string, object?> values)
{
foreach (var (k, v) in values)
{
sb.AppendLine($"{prefix}['{k}'] = {RubyFormat(v)}");
}
sb.AppendLine();
}
private static string RubyFormat(object? v) => v switch
{
null => "nil",
bool b => b ? "true" : "false",
string s when s.StartsWith("{{ secret:") => $"<%= ENV['{ExtractSecretName(s)}'] %>",
string s => $"'{s.Replace("'", "\\'")}'",
int i => i.ToString(),
Dictionary<string, object?> d => "{ " + string.Join(", ", d.Select(kv => $"'{kv.Key}' => {RubyFormat(kv.Value)}")) + " }",
IEnumerable<object?> e => "[ " + string.Join(", ", e.Select(RubyFormat)) + " ]",
_ => v.ToString() ?? "nil"
};
private static string ExtractSecretName(string s) => s.Substring("{{ secret:".Length, s.Length - "{{ secret:".Length - 3).ToUpperInvariant();
}The generator produces a typed GitLabRbConfig. The writer renders it to Ruby. Secrets are templated as <%= ENV['...'] %> so they are injected at boot time from the environment, which the secrets store from Part 43 populates.
The result is a gitlab.rb that looks like this:
external_url 'https://gitlab.frenchexdev.lab'
registry_external_url 'https://registry.frenchexdev.lab'
nginx['redirect_http_to_https'] = true
nginx['ssl_certificate'] = '/etc/gitlab/ssl/wildcard.crt'
nginx['ssl_certificate_key'] = '/etc/gitlab/ssl/wildcard.key'
gitlab_rails['gitlab_email_from'] = 'gitlab@frenchexdev.lab'
gitlab_rails['initial_root_password'] = <%= ENV['GITLAB_ROOT_PASSWORD'] %>
gitlab_rails['registry_enabled'] = true
prometheus['enable'] = false
grafana['enable'] = falseexternal_url 'https://gitlab.frenchexdev.lab'
registry_external_url 'https://registry.frenchexdev.lab'
nginx['redirect_http_to_https'] = true
nginx['ssl_certificate'] = '/etc/gitlab/ssl/wildcard.crt'
nginx['ssl_certificate_key'] = '/etc/gitlab/ssl/wildcard.key'
gitlab_rails['gitlab_email_from'] = 'gitlab@frenchexdev.lab'
gitlab_rails['initial_root_password'] = <%= ENV['GITLAB_ROOT_PASSWORD'] %>
gitlab_rails['registry_enabled'] = true
prometheus['enable'] = false
grafana['enable'] = falseGenerated. Never edited. Diffable. Reproducible.
Surface 2: glab — the CLI wrapper
[BinaryWrapper("glab", HelpCommand = "--help")]
public partial class GlabClient : IGlabClient
{
[Command("auth", SubCommand = "login")]
public partial Task<Result<GlabAuthLoginOutput>> AuthLoginAsync(
[Flag("--hostname")] string? hostname = null,
[Flag("--token")] string? token = null,
CancellationToken ct = default);
[Command("project", SubCommand = "list")]
public partial Task<Result<GlabProjectListOutput>> ProjectListAsync(
[Flag("--per-page")] int? perPage = null,
[Flag("--owned")] bool owned = false,
CancellationToken ct = default);
[Command("ci", SubCommand = "view")]
public partial Task<Result<GlabCiViewOutput>> CiViewAsync(
[PositionalArgument] string? pipelineId = null,
[Flag("--web")] bool openInBrowser = false,
CancellationToken ct = default);
}[BinaryWrapper("glab", HelpCommand = "--help")]
public partial class GlabClient : IGlabClient
{
[Command("auth", SubCommand = "login")]
public partial Task<Result<GlabAuthLoginOutput>> AuthLoginAsync(
[Flag("--hostname")] string? hostname = null,
[Flag("--token")] string? token = null,
CancellationToken ct = default);
[Command("project", SubCommand = "list")]
public partial Task<Result<GlabProjectListOutput>> ProjectListAsync(
[Flag("--per-page")] int? perPage = null,
[Flag("--owned")] bool owned = false,
CancellationToken ct = default);
[Command("ci", SubCommand = "view")]
public partial Task<Result<GlabCiViewOutput>> CiViewAsync(
[PositionalArgument] string? pipelineId = null,
[Flag("--web")] bool openInBrowser = false,
CancellationToken ct = default);
}glab is used for interactive tasks where the user types homelab gitlab open and HomeLab runs glab project view --web. For automation, we go to the REST API directly.
Surface 3: REST API — typed via HttpClient
[TypedHttpClient(BaseUri = "https://gitlab.frenchexdev.lab/api/v4")]
public partial interface IGitLabApi
{
[Get("/version")]
Task<Result<GitLabVersionResponse>> GetVersionAsync(CancellationToken ct);
[Post("/projects")]
Task<Result<GitLabProjectResponse>> CreateProjectAsync(
[Body] CreateProjectRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
[Post("/projects/{projectId}/runners")]
Task<Result<GitLabRunnerRegistrationResponse>> RegisterRunnerAsync(
int projectId,
[Body] RegisterRunnerRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
[Get("/projects/{projectId}/pipelines")]
Task<Result<IReadOnlyList<GitLabPipelineResponse>>> GetPipelinesAsync(
int projectId,
[Query("status")] string? status = null,
[Header("PRIVATE-TOKEN")] string token = "",
CancellationToken ct = default);
[Post("/projects/{projectId}/repository/files/{filePath}")]
Task<Result<GitLabFileResponse>> CreateFileAsync(
int projectId,
string filePath,
[Body] CreateFileRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
}[TypedHttpClient(BaseUri = "https://gitlab.frenchexdev.lab/api/v4")]
public partial interface IGitLabApi
{
[Get("/version")]
Task<Result<GitLabVersionResponse>> GetVersionAsync(CancellationToken ct);
[Post("/projects")]
Task<Result<GitLabProjectResponse>> CreateProjectAsync(
[Body] CreateProjectRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
[Post("/projects/{projectId}/runners")]
Task<Result<GitLabRunnerRegistrationResponse>> RegisterRunnerAsync(
int projectId,
[Body] RegisterRunnerRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
[Get("/projects/{projectId}/pipelines")]
Task<Result<IReadOnlyList<GitLabPipelineResponse>>> GetPipelinesAsync(
int projectId,
[Query("status")] string? status = null,
[Header("PRIVATE-TOKEN")] string token = "",
CancellationToken ct = default);
[Post("/projects/{projectId}/repository/files/{filePath}")]
Task<Result<GitLabFileResponse>> CreateFileAsync(
int projectId,
string filePath,
[Body] CreateFileRequest request,
[Header("PRIVATE-TOKEN")] string token,
CancellationToken ct);
}The interface is partial. The [TypedHttpClient] source generator (from FrenchExDev.Net.HttpClient) emits the implementation: HttpRequestMessage construction, JSON serialisation, status-code handling, error mapping into Result<T>.
The wiring
The three surfaces are layered behind one orchestrator:
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabOrchestrator : IGitLabOrchestrator
{
private readonly IGitLabApi _api;
private readonly GlabClient _glab;
private readonly GitLabRbGenerator _rbGen;
private readonly GitLabRbWriter _rbWriter;
private readonly IBundleWriter _writer;
private readonly ISecretStore _secrets;
public async Task<Result<string>> GenerateOmnibusConfigAsync(HomeLabConfig hl, DirectoryInfo outputDir, CancellationToken ct)
{
var rb = _rbGen.Generate(hl);
var rendered = _rbWriter.Render(rb);
var path = Path.Combine(outputDir.FullName, "gitlab", "gitlab.rb");
await File.WriteAllTextAsync(path, rendered, ct);
return Result.Success(path);
}
public async Task<Result> ConfigureAsync(string baseUrl, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
// 1. Wait for GitLab to be reachable
var version = await WaitForHealthAsync(baseUrl, token.Value, ct);
if (version.IsFailure) return version.Map();
// 2. Create the FrenchExDev/HomeLab project
var project = await _api.CreateProjectAsync(
new CreateProjectRequest { Name = "HomeLab", Namespace = "frenchexdev", Visibility = "private" },
token.Value, ct);
if (project.IsFailure) return project.Map();
// 3. Create CI variables (BAGET_API_KEY, etc.)
// ...
return Result.Success();
}
public async Task<Result> RegisterRunnerAsync(int projectId, string runnerName, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
var result = await _api.RegisterRunnerAsync(projectId,
new RegisterRunnerRequest { Description = runnerName, RunUntagged = true, Locked = false },
token.Value, ct);
return result.Map();
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabOrchestrator : IGitLabOrchestrator
{
private readonly IGitLabApi _api;
private readonly GlabClient _glab;
private readonly GitLabRbGenerator _rbGen;
private readonly GitLabRbWriter _rbWriter;
private readonly IBundleWriter _writer;
private readonly ISecretStore _secrets;
public async Task<Result<string>> GenerateOmnibusConfigAsync(HomeLabConfig hl, DirectoryInfo outputDir, CancellationToken ct)
{
var rb = _rbGen.Generate(hl);
var rendered = _rbWriter.Render(rb);
var path = Path.Combine(outputDir.FullName, "gitlab", "gitlab.rb");
await File.WriteAllTextAsync(path, rendered, ct);
return Result.Success(path);
}
public async Task<Result> ConfigureAsync(string baseUrl, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
// 1. Wait for GitLab to be reachable
var version = await WaitForHealthAsync(baseUrl, token.Value, ct);
if (version.IsFailure) return version.Map();
// 2. Create the FrenchExDev/HomeLab project
var project = await _api.CreateProjectAsync(
new CreateProjectRequest { Name = "HomeLab", Namespace = "frenchexdev", Visibility = "private" },
token.Value, ct);
if (project.IsFailure) return project.Map();
// 3. Create CI variables (BAGET_API_KEY, etc.)
// ...
return Result.Success();
}
public async Task<Result> RegisterRunnerAsync(int projectId, string runnerName, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
var result = await _api.RegisterRunnerAsync(projectId,
new RegisterRunnerRequest { Description = runnerName, RunUntagged = true, Locked = false },
token.Value, ct);
return result.Map();
}
}The orchestrator is the public face. It picks the right surface for each operation. The CLI verbs (homelab gitlab configure, homelab gitlab runner register) delegate to it.
The test
[Fact]
public void rb_generator_emits_external_url_and_registry()
{
var hl = new HomeLabConfig
{
Name = "test",
Topology = "single",
Acme = new() { Tld = "lab" },
GitLab = new()
};
var rb = new GitLabRbGenerator().Generate(hl);
rb.ExternalUrl.Should().Be("https://gitlab.lab");
rb.RegistryExternalUrl.Should().Be("https://registry.lab");
rb.Nginx["ssl_certificate"].Should().Be("/etc/gitlab/ssl/wildcard.crt");
}
[Fact]
public void rb_writer_renders_secrets_as_env_lookups()
{
var rb = new GitLabRbConfig
{
ExternalUrl = "https://x",
GitLabRails = new() { ["initial_root_password"] = "{{ secret:gitlab_root_password }}" }
};
var rendered = new GitLabRbWriter().Render(rb);
rendered.Should().Contain("gitlab_rails['initial_root_password'] = <%= ENV['GITLAB_ROOT_PASSWORD'] %>");
}
[Fact]
public async Task orchestrator_waits_for_health_before_configuring()
{
var api = new ScriptedGitLabApi();
api.OnGetVersion(callsBeforeReady: 3, eventualVersion: "16.4.2");
var orchestrator = TestOrchestrators.GitLab(api);
var result = await orchestrator.WaitForHealthAsync("https://gitlab.lab", "token", CancellationToken.None);
result.IsSuccess.Should().BeTrue();
api.GetVersionCallCount.Should().Be(4); // 3 failures + 1 success
}[Fact]
public void rb_generator_emits_external_url_and_registry()
{
var hl = new HomeLabConfig
{
Name = "test",
Topology = "single",
Acme = new() { Tld = "lab" },
GitLab = new()
};
var rb = new GitLabRbGenerator().Generate(hl);
rb.ExternalUrl.Should().Be("https://gitlab.lab");
rb.RegistryExternalUrl.Should().Be("https://registry.lab");
rb.Nginx["ssl_certificate"].Should().Be("/etc/gitlab/ssl/wildcard.crt");
}
[Fact]
public void rb_writer_renders_secrets_as_env_lookups()
{
var rb = new GitLabRbConfig
{
ExternalUrl = "https://x",
GitLabRails = new() { ["initial_root_password"] = "{{ secret:gitlab_root_password }}" }
};
var rendered = new GitLabRbWriter().Render(rb);
rendered.Should().Contain("gitlab_rails['initial_root_password'] = <%= ENV['GITLAB_ROOT_PASSWORD'] %>");
}
[Fact]
public async Task orchestrator_waits_for_health_before_configuring()
{
var api = new ScriptedGitLabApi();
api.OnGetVersion(callsBeforeReady: 3, eventualVersion: "16.4.2");
var orchestrator = TestOrchestrators.GitLab(api);
var result = await orchestrator.WaitForHealthAsync("https://gitlab.lab", "token", CancellationToken.None);
result.IsSuccess.Should().BeTrue();
api.GetVersionCallCount.Should().Be(4); // 3 failures + 1 success
}What this gives you that bash doesn't
A bash script that talks to GitLab is curl plus jq plus a hand-rolled retry loop plus a hard-coded admin token in a temp file plus a comment that says # this works for now. There is no type. There is no schema. There is no test.
A typed three-surface GitLab orchestrator gives you, for the same surface area:
- Typed
gitlab.rbgeneration with secrets templated for env injection - Typed REST API generated from the schema, returning
Result<T> - Typed
glabwrapper for the few cases where the CLI is the right tool - An orchestrator that picks the right surface per operation
- Health-wait logic unit-tested with a scripted API fake
- Architecture tests that prevent any code outside the orchestrator from calling the GitLab API directly
The bargain pays back the first time GitLab ships a breaking API change: the typed client breaks at compile time, you fix one method, the rest of HomeLab does not change.
End of Act III
We have now wrapped every external tool HomeLab depends on: Docker, Docker Compose, Podman, Podman Compose, Packer JSON, Packer HCL2, the Vagrantfile, the Vos data YAML, Traefik, and GitLab. Each wrapper is typed, source-generated where possible, Result<T>-returning, and [Injectable]-friendly. The pipeline can now consume any of them through a typed interface.
Act IV drops one level further: building the VM images themselves. Alpine base, Docker host overlay, Podman host overlay, host hardening, the Vagrant box registry, and the topology composition picker. By the end of Act IV, we will have everything we need to run homelab vos up and have a real VM boot with our exact image, exposed Docker socket, and ready for the compose stacks Act V will define.