Prerequisites: application, helm-application, adoption, app-of-apps

This is the command-by-command procedure for bringing one hand-installed Helm release under Argo CD without recreating it. migrating-the-imperative-stack lists which components to adopt and in what order and why; this note is the exact move you run for each one. The finished nvidia-device-plugin files in the lab repo are the worked reference for every step below.

Run it once and the shape repeats: the only things that change per component are the chart, the namespace, and the recovered values.

Step 1: pick the next component and read its live state

Ask the cluster what is still imperative. A release that shows up in Helm but not in Argo is a candidate.

helm list -A          # every Helm release on the cluster
argocd app list       # what Argo already owns

Cross-check the candidate against the order in migrating-the-imperative-stack: adopt least blast radius first, leave Cilium for last. Once you have picked one, collect the four facts the application will need.

helm list -n <ns> <release>            # the exact chart name and version to pin
helm get values <release> -n <ns>      # only your overrides, this becomes values.yaml
kubectl get all -n <ns>                # what is actually running, so you recognise an empty diff later

The fifth fact, the chart repository URL, is not on the cluster. It is the repoURL Argo renders from, and it comes from the project’s install docs, for example https://prometheus-community.github.io/helm-charts for kube-prometheus-stack or https://nvidia.github.io/k8s-device-plugin for the device plugin. If you still have the repo added locally, helm repo list shows it.

Where to look, summarised: chart name and version from helm list, overrides from helm get values, repo URL from the upstream install docs.

Step 2: decide where the two files live

The repo conventions put one concern per directory under kubernetes/infra/. Every adopted component is two files:

  • kubernetes/infra/<concern>/<name>/values.yaml, the recovered overrides.
  • kubernetes/clusters/devata/<name>.yaml, the child application the root app-of-apps picks up.

<concern> is one of networking, observability, controllers, ingress, storage; the infra READMEs and the component table in migrating-the-imperative-stack tell you which one a given component belongs to.

<name> is the release name from helm list, not the chart name, and the distinction is load-bearing. Charts embed the release name in every resource they render (devata’s kube-prometheus-stack runs as release kps, so the live objects are kps-grafana, kps-prometheus-node-exporter, and so on), and Argo uses the Application’s own name as the Helm release name when it renders. Name the Application kube-prometheus-stack and Argo renders a parallel universe of kube-prometheus-stack-grafana objects: the diff can never go empty, and a sync would install a second copy of the stack next to the first. So name the Application, the folder, and the child file after the release. If you ever need the Application named differently, set releaseName: <release> under the chart source’s helm: block instead (release name docs).

Step 3: write the values file

Paste the helm get values output into values.yaml. Only your overrides, never the full computed values, so the file stays the short declarative description of what you changed. For the device plugin that was a single line:

runtimeClassName: nvidia

Step 4: write the child Application

Use the multi-source Helm shape from helm-application: the chart comes from upstream, the values come from git through the $values ref. Leave automation off for now, the reason is in adoption.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: <name>
  namespace: argocd
spec:
  project: default
  sources:
    - repoURL: <chart-repo-url>
      chart: <chart-name>
      targetRevision: <version>     # exactly what helm list showed, pinned
      helm:
        valueFiles:
          - $values/kubernetes/infra/<concern>/<name>/values.yaml
    - repoURL: https://github.com/PragalvaXFREZ/lab.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: <ns>
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    # automated:                    # uncomment only after the diff is empty
    #   selfHeal: true
    #   prune: true

One addition for big charts: kube-prometheus-stack ships CRDs over the client-side apply size limit, so for it add - ServerSideApply=true to syncOptions from the start. The full reasoning is in adoption.

Step 5: commit on a branch and merge to main

Argo CD reads main, not your working tree, so nothing reconciles until the files are on the branch the root tracks.

git checkout -b adopt-<name>
git add kubernetes/infra/<concern>/<name> kubernetes/clusters/devata/<name>.yaml
git commit -s -m "Adopt <name> into GitOps"
# push, open a PR, merge to main

The root app-of-apps notices the new child file under clusters/devata/ and creates the Application. Because automation is off, the new app sits OutOfSync and changes nothing on the cluster yet.

Step 6: drive the diff to empty, then hand over the keys

argocd app get <name>      # Synced or OutOfSync, both tell you something
argocd app diff <name>     # exactly what Argo would change; empty output means done

If the recovered values were exact, the app shows Synced with an empty diff immediately, before any sync has run. That is adoption succeeding, not a step skipped: Argo compared its render against the live objects and found nothing to change (the diff already normalizes Argo’s own tracking label away, so it does not appear as a leftover). The nvidia-device-plugin rehearsal on devata landed exactly this way. If instead the app is OutOfSync, every line in the diff is a value your committed values.yaml does not yet match: recover it with helm get values, fix the file, commit, and diff again until empty. Now uncomment the automated: block and commit; the root applies the change, and because the diff was empty the first automated sync is a no-op against the already-running workload. From then on reconciliation defends it.

Step 7: prove it, then retire the Helm record

Confirm Argo is genuinely in control with a drift test:

kubectl -n <ns> scale deploy <something> --replicas=9    # if the chart ships a Deployment
kubectl -n <ns> set env ds/<name> DRIFT_TEST=1           # if it ships a DaemonSet (nvidia-device-plugin, promtail)

Either way the change contradicts what git renders, so Argo reverts it. Watch it happen in argocd app get <name> or the UI.

Then delete the now-vestigial Helm release record, which is bookkeeping Argo no longer needs and a loaded gun if a stray helm command ever runs. Delete only the record, never helm uninstall, which would delete the workload Argo just adopted.

kubectl delete secret -n <ns> -l owner=helm,name=<release>

The component is now under git: declared, reconciled, and rollback-able with git revert. Repeat from Step 1 for the next component in migrating-the-imperative-stack.