Part 33: ArgoCD and the GitOps Repo Generator
"Workloads are deployed via ArgoCD, not via raw kubectl apply. ArgoCD watches a git repo. HomeLab generates the repo. The repo lives in DevLab's GitLab. The loop closes."
Why
Two things this part does:
- Install ArgoCD as the canonical deployment mechanism. Every workload — Acme's API, Globex's services, the docs site, the test fixtures — is deployed via ArgoCD
ApplicationCRDs that point at a git repository, not via directkubectl apply. This matches production GitOps patterns and lets developers test the production deployment flow in dev. - Generate the GitOps repository itself via first-class HomeLab tooling. New verbs:
homelab k8s argocd init <repo>,homelab k8s argocd add-app <name>,homelab k8s argocd add-env <env>. The repo lives in DevLab's GitLab. HomeLab commits the typed Application manifests via the existingIGitClientBinaryWrapper.
The thesis: K8s.Dsl ships an ArgoCdHelmReleaseContributor that installs ArgoCD plus a new IGitOpsRepoGenerator and IArgoCdApplicationContributor pair. The user runs homelab k8s argocd init devlab-gitops once. After that, every new workload is a typed [ArgoCdApplication] declaration plus one homelab k8s argocd add-app invocation. ArgoCD picks up the change and reconciles.
ArgoCD installation
[Injectable(ServiceLifetime.Singleton)]
public sealed class ArgoCdHelmReleaseContributor : IHelmReleaseContributor
{
public string TargetCluster => "*";
public void Contribute(KubernetesBundle bundle)
{
bundle.HelmReleases.Add(new HelmReleaseSpec
{
Name = "argocd",
Namespace = "argocd",
Chart = "argo/argo-cd",
Version = "7.7.7",
RepoUrl = "https://argoproj.github.io/argo-helm",
CreateNamespace = true,
Wait = true,
Values = new()
{
["server"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1,
["ingress"] = new Dictionary<string, object?>
{
["enabled"] = true,
["ingressClassName"] = "nginx",
["annotations"] = new Dictionary<string, object?>
{
["cert-manager.io/cluster-issuer"] = "homelab-ca",
["nginx.ingress.kubernetes.io/backend-protocol"] = "GRPC"
},
["hostname"] = $"argocd.{_config.Acme.Tld}",
["tls"] = true
}
},
["controller"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1
},
["repoServer"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1
},
["applicationSet"] = new Dictionary<string, object?>
{
["enabled"] = true
},
["configs"] = new Dictionary<string, object?>
{
["secret"] = new Dictionary<string, object?>
{
["argocdServerAdminPassword"] = "{{ secret:ARGOCD_ADMIN_PASSWORD_BCRYPT }}"
}
}
}
});
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class ArgoCdHelmReleaseContributor : IHelmReleaseContributor
{
public string TargetCluster => "*";
public void Contribute(KubernetesBundle bundle)
{
bundle.HelmReleases.Add(new HelmReleaseSpec
{
Name = "argocd",
Namespace = "argocd",
Chart = "argo/argo-cd",
Version = "7.7.7",
RepoUrl = "https://argoproj.github.io/argo-helm",
CreateNamespace = true,
Wait = true,
Values = new()
{
["server"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1,
["ingress"] = new Dictionary<string, object?>
{
["enabled"] = true,
["ingressClassName"] = "nginx",
["annotations"] = new Dictionary<string, object?>
{
["cert-manager.io/cluster-issuer"] = "homelab-ca",
["nginx.ingress.kubernetes.io/backend-protocol"] = "GRPC"
},
["hostname"] = $"argocd.{_config.Acme.Tld}",
["tls"] = true
}
},
["controller"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1
},
["repoServer"] = new Dictionary<string, object?>
{
["replicas"] = _config.K8s?.Topology == "k8s-ha" ? 2 : 1
},
["applicationSet"] = new Dictionary<string, object?>
{
["enabled"] = true
},
["configs"] = new Dictionary<string, object?>
{
["secret"] = new Dictionary<string, object?>
{
["argocdServerAdminPassword"] = "{{ secret:ARGOCD_ADMIN_PASSWORD_BCRYPT }}"
}
}
}
});
}
}The GitOps repo generator
public interface IGitOpsRepoGenerator
{
Task<Result> InitAsync(string repoName, string namespacePrefix, CancellationToken ct);
Task<Result> AddAppAsync(string appName, ArgoCdApplicationSpec spec, CancellationToken ct);
Task<Result> AddEnvironmentAsync(string envName, CancellationToken ct);
Task<Result> CommitAndPushAsync(string commitMessage, CancellationToken ct);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabHostedGitOpsRepoGenerator : IGitOpsRepoGenerator
{
private readonly IGitClient _git;
private readonly IGitLabApi _gitlab;
private readonly IFileSystem _fs;
private readonly ISecretStore _secrets;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
private readonly DirectoryInfo _workTree;
public async Task<Result> InitAsync(string repoName, string namespacePrefix, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
// 1. Create the repo in DevLab's GitLab
var createResult = await _gitlab.CreateProjectAsync(
new CreateProjectRequest { Name = repoName, Namespace = namespacePrefix, Visibility = "private" },
token.Value, ct);
if (createResult.IsFailure) return createResult.Map();
// 2. Clone it locally
var repoUrl = createResult.Value.HttpUrlToRepo;
var cloneResult = await _git.CloneAsync(repoUrl, _workTree.FullName, ct);
if (cloneResult.IsFailure) return cloneResult.Map();
// 3. Lay out the App-of-Apps directory structure
var dirs = new[] { "apps", "bootstrap", "charts", "environments/dev", "environments/stage", "environments/prod" };
foreach (var dir in dirs)
_fs.Directory.CreateDirectory(Path.Combine(_workTree.FullName, dir));
// 4. Write the App-of-Apps root Application
var rootApp = ArgoCdApplicationGenerator.GenerateRootApp(repoName, repoUrl);
await _fs.File.WriteAllTextAsync(
Path.Combine(_workTree.FullName, "bootstrap", "root.yaml"),
KubernetesYamlSerializer.Serialize(rootApp), ct);
// 5. Commit and push
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"chore: initialize GitOps repo via homelab k8s argocd init", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
await _events.PublishAsync(new GitOpsRepoInitialized(repoName, repoUrl, _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result> AddAppAsync(string appName, ArgoCdApplicationSpec spec, CancellationToken ct)
{
// 1. Generate the typed Application manifest
var appManifest = ArgoCdApplicationGenerator.GenerateApplication(appName, spec);
// 2. Write it under apps/<app>/
var appDir = Path.Combine(_workTree.FullName, "apps", appName);
_fs.Directory.CreateDirectory(appDir);
await _fs.File.WriteAllTextAsync(
Path.Combine(appDir, $"{appName}.yaml"),
KubernetesYamlSerializer.Serialize(appManifest), ct);
// 3. Commit and push
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"feat: add app {appName}", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
return Result.Success();
}
public async Task<Result> AddEnvironmentAsync(string envName, CancellationToken ct)
{
var envDir = Path.Combine(_workTree.FullName, "environments", envName);
_fs.Directory.CreateDirectory(envDir);
// Write a kustomization.yaml that includes all apps for this environment
var kustomization = KustomizationGenerator.GenerateForEnvironment(envName);
await _fs.File.WriteAllTextAsync(
Path.Combine(envDir, "kustomization.yaml"),
kustomization, ct);
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"feat: add environment {envName}", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
return Result.Success();
}
public async Task<Result> CommitAndPushAsync(string commitMessage, CancellationToken ct)
{
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync(commitMessage, _workTree.FullName, ct);
return await _git.PushAsync(_workTree.FullName, ct).Map();
}
}public interface IGitOpsRepoGenerator
{
Task<Result> InitAsync(string repoName, string namespacePrefix, CancellationToken ct);
Task<Result> AddAppAsync(string appName, ArgoCdApplicationSpec spec, CancellationToken ct);
Task<Result> AddEnvironmentAsync(string envName, CancellationToken ct);
Task<Result> CommitAndPushAsync(string commitMessage, CancellationToken ct);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabHostedGitOpsRepoGenerator : IGitOpsRepoGenerator
{
private readonly IGitClient _git;
private readonly IGitLabApi _gitlab;
private readonly IFileSystem _fs;
private readonly ISecretStore _secrets;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
private readonly DirectoryInfo _workTree;
public async Task<Result> InitAsync(string repoName, string namespacePrefix, CancellationToken ct)
{
var token = await _secrets.ReadAsync("GITLAB_ROOT_PAT", ct);
if (token.IsFailure) return token.Map();
// 1. Create the repo in DevLab's GitLab
var createResult = await _gitlab.CreateProjectAsync(
new CreateProjectRequest { Name = repoName, Namespace = namespacePrefix, Visibility = "private" },
token.Value, ct);
if (createResult.IsFailure) return createResult.Map();
// 2. Clone it locally
var repoUrl = createResult.Value.HttpUrlToRepo;
var cloneResult = await _git.CloneAsync(repoUrl, _workTree.FullName, ct);
if (cloneResult.IsFailure) return cloneResult.Map();
// 3. Lay out the App-of-Apps directory structure
var dirs = new[] { "apps", "bootstrap", "charts", "environments/dev", "environments/stage", "environments/prod" };
foreach (var dir in dirs)
_fs.Directory.CreateDirectory(Path.Combine(_workTree.FullName, dir));
// 4. Write the App-of-Apps root Application
var rootApp = ArgoCdApplicationGenerator.GenerateRootApp(repoName, repoUrl);
await _fs.File.WriteAllTextAsync(
Path.Combine(_workTree.FullName, "bootstrap", "root.yaml"),
KubernetesYamlSerializer.Serialize(rootApp), ct);
// 5. Commit and push
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"chore: initialize GitOps repo via homelab k8s argocd init", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
await _events.PublishAsync(new GitOpsRepoInitialized(repoName, repoUrl, _clock.UtcNow), ct);
return Result.Success();
}
public async Task<Result> AddAppAsync(string appName, ArgoCdApplicationSpec spec, CancellationToken ct)
{
// 1. Generate the typed Application manifest
var appManifest = ArgoCdApplicationGenerator.GenerateApplication(appName, spec);
// 2. Write it under apps/<app>/
var appDir = Path.Combine(_workTree.FullName, "apps", appName);
_fs.Directory.CreateDirectory(appDir);
await _fs.File.WriteAllTextAsync(
Path.Combine(appDir, $"{appName}.yaml"),
KubernetesYamlSerializer.Serialize(appManifest), ct);
// 3. Commit and push
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"feat: add app {appName}", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
return Result.Success();
}
public async Task<Result> AddEnvironmentAsync(string envName, CancellationToken ct)
{
var envDir = Path.Combine(_workTree.FullName, "environments", envName);
_fs.Directory.CreateDirectory(envDir);
// Write a kustomization.yaml that includes all apps for this environment
var kustomization = KustomizationGenerator.GenerateForEnvironment(envName);
await _fs.File.WriteAllTextAsync(
Path.Combine(envDir, "kustomization.yaml"),
kustomization, ct);
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync($"feat: add environment {envName}", _workTree.FullName, ct);
await _git.PushAsync(_workTree.FullName, ct);
return Result.Success();
}
public async Task<Result> CommitAndPushAsync(string commitMessage, CancellationToken ct)
{
await _git.AddAsync("--all", _workTree.FullName, ct);
await _git.CommitAsync(commitMessage, _workTree.FullName, ct);
return await _git.PushAsync(_workTree.FullName, ct).Map();
}
}The CLI verbs
[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sArgoCdInitCommand : IHomeLabVerbCommand
{
public Command Build()
{
var name = new Argument<string>("name");
var namespacePrefix = new Option<string>("--namespace-prefix", () => "frenchexdev");
var cmd = new Command("init", "Initialize the GitOps repository for ArgoCD");
cmd.AddArgument(name);
cmd.AddOption(namespacePrefix);
cmd.SetHandler(async (string n, string ns) =>
{
var result = await _mediator.SendAsync(new K8sArgoCdInitRequest(n, ns), default);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, namespacePrefix);
return cmd;
}
}
// Similar shells for K8sArgoCdAddAppCommand and K8sArgoCdAddEnvCommand[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sArgoCdInitCommand : IHomeLabVerbCommand
{
public Command Build()
{
var name = new Argument<string>("name");
var namespacePrefix = new Option<string>("--namespace-prefix", () => "frenchexdev");
var cmd = new Command("init", "Initialize the GitOps repository for ArgoCD");
cmd.AddArgument(name);
cmd.AddOption(namespacePrefix);
cmd.SetHandler(async (string n, string ns) =>
{
var result = await _mediator.SendAsync(new K8sArgoCdInitRequest(n, ns), default);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name, namespacePrefix);
return cmd;
}
}
// Similar shells for K8sArgoCdAddAppCommand and K8sArgoCdAddEnvCommandThe CLI verbs are the standard thin shells from Part 11. They live in the K8sDsl.Cli NuGet. The lib NuGet has the handlers and the generators.
The repository layout
After homelab k8s argocd init devlab-gitops and a few add-app calls:
devlab-gitops/
├── README.md
├── bootstrap/
│ └── root.yaml ← the App-of-Apps root Application
├── apps/
│ ├── acme-api/
│ │ ├── acme-api.yaml ← ArgoCD Application
│ │ ├── kustomization.yaml
│ │ └── overlays/
│ │ ├── dev/
│ │ │ └── deployment-patch.yaml
│ │ ├── stage/
│ │ │ └── deployment-patch.yaml
│ │ └── prod/
│ │ └── deployment-patch.yaml
│ ├── acme-frontend/
│ │ └── ...
│ └── observability/
│ └── ...
├── environments/
│ ├── dev/
│ │ └── kustomization.yaml ← bundles all dev overlays
│ ├── stage/
│ │ └── kustomization.yaml
│ └── prod/
│ └── kustomization.yaml
└── charts/ ← in-tree Helm charts the user authors
└── acme-api/
├── Chart.yaml
├── values.yaml
└── templates/
└── ...devlab-gitops/
├── README.md
├── bootstrap/
│ └── root.yaml ← the App-of-Apps root Application
├── apps/
│ ├── acme-api/
│ │ ├── acme-api.yaml ← ArgoCD Application
│ │ ├── kustomization.yaml
│ │ └── overlays/
│ │ ├── dev/
│ │ │ └── deployment-patch.yaml
│ │ ├── stage/
│ │ │ └── deployment-patch.yaml
│ │ └── prod/
│ │ └── deployment-patch.yaml
│ ├── acme-frontend/
│ │ └── ...
│ └── observability/
│ └── ...
├── environments/
│ ├── dev/
│ │ └── kustomization.yaml ← bundles all dev overlays
│ ├── stage/
│ │ └── kustomization.yaml
│ └── prod/
│ └── kustomization.yaml
└── charts/ ← in-tree Helm charts the user authors
└── acme-api/
├── Chart.yaml
├── values.yaml
└── templates/
└── ...The structure is the standard ArgoCD App-of-Apps + Kustomize overlays pattern. ArgoCD's root Application (in bootstrap/root.yaml) points at the apps/ directory. The apps/ directory contains one Application per workload. Each Application points at a chart (in charts/) plus an overlay (in apps/<app>/overlays/<env>/).
What the user types
$ homelab k8s argocd init devlab-gitops --namespace-prefix frenchexdev
✓ created project frenchexdev/devlab-gitops in DevLab GitLab
✓ cloned to ./devlab-gitops/
✓ wrote bootstrap/root.yaml
✓ committed and pushed initial structure
$ homelab k8s argocd add-app acme-api \
--chart charts/acme-api \
--destination-namespace acme-prod \
--target-revision main
✓ generated apps/acme-api/acme-api.yaml
✓ committed and pushed
✓ ArgoCD will reconcile the new application within 60 seconds
$ kubectl get applications -n argocd
NAME SYNC STATUS HEALTH STATUS
root Synced Healthy
acme-api Synced Healthy$ homelab k8s argocd init devlab-gitops --namespace-prefix frenchexdev
✓ created project frenchexdev/devlab-gitops in DevLab GitLab
✓ cloned to ./devlab-gitops/
✓ wrote bootstrap/root.yaml
✓ committed and pushed initial structure
$ homelab k8s argocd add-app acme-api \
--chart charts/acme-api \
--destination-namespace acme-prod \
--target-revision main
✓ generated apps/acme-api/acme-api.yaml
✓ committed and pushed
✓ ArgoCD will reconcile the new application within 60 seconds
$ kubectl get applications -n argocd
NAME SYNC STATUS HEALTH STATUS
root Synced Healthy
acme-api Synced HealthyThree commands. One generated repo. One application reconciled. The dogfood loop is intact: HomeLab generated the GitOps repo, the repo is hosted by GitLab in DevLab, ArgoCD inside the cluster watches it, the workload deploys.