GitOps with Argo CD and Flux¶
upgradescope fits a GitOps setup in three places: the chart installs like any
other, the charts your GitOps tool deploys are found without a Helm release,
and the ClusterReadiness object can live in Git, so a sync or a promotion
can wait on readiness.
Installing the chart from Git¶
Tools that render the chart without cluster access (Argo CD renders with
helm template; so does Kustomize's Helm support) cannot look up live
Secrets. Without
server.ingestToken or server.existingSecret, the chart would generate a
new ingest token on every render, so under GitOps set one of them, or
reference Secrets your secret manager creates (agent.existingSecret,
server.existingSecret). The chart README
explains the lookup.
Helm installs the ClusterReadiness CRD from the chart's crds/ directory
and never upgrades it. The agent keeps the CRD's schema in step with its
own binary at startup (agent.manageCRD=true, the default) using
server-side apply, and touches only the fields it owns, so labels and
annotations Argo CD or Flux set on the CRD survive. To manage the CRD from
Git yourself, set agent.manageCRD=false: the agent then never writes it,
and the CRD write permissions are dropped from its role. The manifest to
commit is deploy/chart/crds/,
documented field by field in the CRD reference.
Charts your GitOps tool deploys¶
Helm checks (a chart's kubeVersion, removed APIs in a release's stored
manifest) read the release records Helm leaves in the cluster. Argo CD
renders charts with helm template and leaves no record at all; Flux's
helm-controller keeps releases where the HelmRelease says. A cluster managed
that way has nothing for those checks to read, so upgradescope reads what the
tools themselves declare, and says what it could not assess. This is exactly
what is supported:
| Read | Not read | |
|---|---|---|
| Helm | releases in the secrets and configmaps storage drivers (HELM_DRIVER=secret, the default, and configmap): chart, versions, kubeVersion, stored manifest |
the sql driver: its releases live in an external database the cluster does not show. Not supported |
| Argo CD | applications.argoproj.io/v1alpha1: the chart, repoURL and targetRevision of each spec.sources[] entry (or of spec.source, when spec.sources is empty, as Argo CD does) that sets chart, and each such entry whose repoURL is oci://... with no chart, which is how Argo CD reads a Helm chart from an OCI registry (path: .), unless it sets only ref (a multi-source entry that supplies values files to another source's chart): the chart is the last element of the repository path (oci://ghcr.io/acme/charts/ingress-nginx is ingress-nginx, with the userinfo, query and fragment cut first) and its version the targetRevision, for Applications (including those an ApplicationSet generates) whose destination is this cluster (https://kubernetes.default.svc or https://kubernetes.default.svc.cluster.local, with or without :443, or the name in-cluster) |
sources that render a path in Git (a Helm chart in a repository, Kustomize with helmCharts) name no chart, and an oci:// source with no repository path below the registry host names none either. An oci:// source that holds plain manifests (a path other than .) is read as a chart too, since it cannot be told from one here: that over-reports the gap below, the safe direction (pinned by TestGitOpsArgoOCISourceWithAPathIsStillReadAsTheRepositoryChart). Applications for other clusters, which are counted in the reason |
| Flux | helmreleases.helm.toolkit.fluxcd.io: spec.chart.spec (chart, version, sourceRef; from a GitRepository or Bucket the chart is a path, and the last element of it is taken as the chart's name) and a spec.chartRef to an OCIRepository (source.toolkit.fluxcd.io, its spec.url and spec.ref: the digest, else the semver range, else the tag, the order Flux itself applies; the OCIRepositories are listed, one list of the namespace the chartRefs point into or one cluster-wide, paged at 50, and with a role that may list only some namespaces, per namespace, fetched by name where even that is refused; the agent remembers a refused list across ticks and asks for it again only once an hour, so a role with get alone costs those refused lists once an hour, not on every tick, and a list granted later is used within the hour, while scan asks once per run). The newest of v2, v2beta2 and v2beta1 the cluster serves |
a chartRef to a HelmChart, or an OCIRepository that cannot be read (both are counted in the reason, with the first read error, and the capability is partial). HelmReleases with a spec.kubeConfig, which deploy to other clusters |
What the charts feed is add-on detection: a chart the registry knows (for
example ingress-nginx) is an install in the namespace the chart deploys
into, found whether or not a pod's image or labels would have found it. A
chart reference carries no application version, so the version comes from the
pods that run it; an add-on retired as a whole (Ingress NGINX) is still
end-of-life without one. The inventory lists the references as gitopsCharts,
and the add-on's source is gitops when nothing stronger found it.
A chart version in these resources is what the author asked for, often a
constraint (4.*, >=4.0.0 <5.0.0), not what is installed. It is shown as the
add-on's chart version only when it is a single version and no Helm release
records the real one. For a Flux OCIRepository the version follows Flux's own
precedence, digest over semver over tag: a ref.digest is recorded as the
digest (sha256:...), which is no chart version, so a tag beside it is not
reported as one; a ref.semver is recorded as the range it is (a range is no
exact version; a semver of one version is), whatever the tag says; a ref.tag
alone is the version; no ref at all (Flux then follows latest) records
none. A Flux cluster, where helm-controller leaves a release
Secret, reports that release's chart version when the release and the chart
source are in the same namespace: the HelmRelease's targetNamespace is unset
or its own namespace (the agent matches by namespace and does not read
spec.storageNamespace; the release Secret lives in the storage namespace,
which defaults to the HelmRelease's). With a targetNamespace elsewhere, the
Secret is in the HelmRelease's namespace and the chart deploys into the
target, so they are two installs of the add-on, as
for any chart with a namespace override: the release's at its app version, the
chart source's with the chart version it asks for, if exact.
Repository URLs are recorded without userinfo, query string or fragment. An Application's
repoURL and an OCIRepository's spec.url can carry userinfo
(https://user:token@host) or a token in the query string, and the agent
copies neither: only the scheme, host, port and path are kept, whether or not
the value parses as a URL (a Git address such as git@github.com:org/repo.git
is recorded as github.com:org/repo.git). The cut does not depend on the URL
parsing: everything up to the last @ is dropped, so a token or password that
itself contains a /, ? or # does not leak either. A URL with a ? or
# before its last @ cannot be told apart from a query that holds an @
and a token (?user=ci@example.com&token=..., which Helm, curl and git all
send), so it is recorded as empty. An @ anywhere else in the URL, which a
chart repository does not use, therefore costs the part before it; an OCI reference pinned by digest keeps its digest. A token
embedded in the URL path cannot be told from a path and is recorded: a
Cloudsmith entitlement URL such as https://dl.cloudsmith.io/<token>/org/repo/helm/charts/
keeps its token, so give such tokens through userinfo or a Secret-backed
repository instead. The agent does not read the Secrets that hold
repository credentials. The URL as recorded is what the inventory holds,
what is pushed to the server and stored, and what the CLI prints.
An Application's destination.namespace and a HelmRelease's targetNamespace
are free text to the API server. A chart source whose target is not a valid
namespace name is not counted (the reason says how many), so one such resource
cannot stop the agent from reporting the rest of the cluster.
What stays not assessed for these charts is the part that needs a release:
kubeVersion and the stored manifest. When the cluster has no Helm release at
all but shows one of the tools, the helm capability is partial and names it
(argocd, flux) in its skipped list, so the report shows those checks as not
assessed instead of clean. The tool shows when its CRD is served, or when a
Deployment, StatefulSet or DaemonSet carries its tracking metadata: the
argocd.argoproj.io/tracking-id annotation, the argocd.argoproj.io/instance
label, or Flux's helm.toolkit.fluxcd.io/name label. The workload check is how
an Argo CD running in another cluster shows: it needs a role that can list
those workloads (the chart's can; a restricted CLI user's may not, and the check
then shows nothing and is no gap). Argo CD sets the tracking annotation by
default only from 3.0; with 2.x's default of the app.kubernetes.io/instance
label, which Helm sets too and so proves nothing, an Argo CD in another
cluster leaves no marker here until application.resourceTrackingMethod is
annotation (or annotation+label). The CRD check needs no permission. Helm is
an optional capability, so this never turns a verdict to unknown by itself.
Every chart read from an Argo CD Application, a native OCI source included, is
that gap too, whether or not the cluster has other Helm releases: helm template leaves the chart no
release, so its kubeVersion and stored-manifest checks cannot have run. This
is the common case, since Argo CD is usually installed with its own Helm chart.
Applications that name no chart (a path in Git) add no gap while releases
exist. Flux is different: helm-controller leaves ordinary release Secrets, so
Flux is a gap only on a cluster with no Helm release at all, and not when its
HelmRelease list is served and empty (Flux used for Kustomizations only), nor
when every HelmRelease deploys elsewhere (a spec.kubeConfig, or a target that
is not a namespace name) and so is not counted. When
API discovery itself fails, helm is partial and names both tools, because it
is not known whether either is installed.
Because a partial helm capability makes the add-on and Helm-release findings
count as unassessed when scans are compared (for deltas and notifications),
a cluster that serves either CRD without rbac.gitops.* set shows helm as
partial for good. Enable the flag for the tool you run to clear it. This
includes upgrading the chart on a Flux cluster whose releases were read in full
before this version.
The chart references are free text written by whoever can create a HelmRelease
in a namespace they own (or an Application, where Argo CD allows Applications in
any namespace). Such a principal can name ingress-nginx and produce an
end-of-life add-on finding for the cluster. The impact is a false finding, not
access; if that matters, restrict who may create these resources.
Reading the custom resources needs permission the agent does not have by default. Enable what you run:
rbac:
gitops:
argocd: true # get, list on applications.argoproj.io
flux: true # get, list on helmreleases.helm.toolkit.fluxcd.io and ocirepositories.source.toolkit.fluxcd.io
Without them, with the tool installed, the agent reports helm as partial,
with the forbidden list as the reason; nothing fails. The charts that were read
count as add-on evidence even where nothing else could be: with the pod list
forbidden and no Helm release or IngressClass readable, addons is partial
(not unavailable) and says it was detected from the GitOps chart sources, and
when the Helm release storage itself is unreadable (Secrets and ConfigMaps
forbidden) helm is unavailable, its reason saying the chart sources were read
and still feed add-on detection. A CLI scan uses your own
credentials and needs no values. Lists are paged (50 per page), because an
Application's status can be large.
The ClusterReadiness object in Git¶
The agent creates its ClusterReadiness (named cluster by default,
agent.crName) and only ever writes its status, plus spec.targets when
the chart's agent.targets is set. To own the spec from Git, commit the
object and leave agent.targets empty:
apiVersion: upgradescope.basit.engineer/v1alpha1
kind: ClusterReadiness
metadata:
name: cluster
spec:
targets: ["1.38"]
ignore:
- key: eol-addon/ingress-nginx
reason: migrating to Gateway API, tracked in PLAT-123
expires: "2099-12-31" # a date inside your migration window
If agent.targets is set as well, the agent resets spec.targets to it on
every tick and the two fight. spec.ignore takes the same rules as the
CLI's .upgradescope.yaml (suppressions).
The status carries standard Ready and AllTargetsReady conditions and
observedGeneration (status reference),
which is what health checks read. Ready is the first target's verdict.
AllTargetsReady covers every target: False when any is blocked (the
message names it, such as 1.38 blocked (3 blockers)), Unknown when none is
blocked but one was not assessed, True only when all are ready. For a plan
of several targets, gate on AllTargetsReady, not Ready: with
spec.targets: ["1.37","1.38"], a ready 1.37 and a blocked 1.38 leave
Ready True.
A spec edit is applied at the next tick¶
The agent reads the spec only inside a tick, so a commit that changes
spec.targets or adds a spec.ignore rule (often to accept a blocker and
unblock a gate) shows the old verdict until the next tick: up to
agent.interval plus 10% jitter, about 11 minutes with the 10m default.
Until then status.observedGeneration is lower than metadata.generation;
that is how a health check or a script tells an old verdict from a current
one. To wait for the edit to be applied (here generation 5):
kubectl wait ucr/cluster --for=jsonpath='{.status.observedGeneration}'=5 --timeout=15m
or lower agent.interval (1m at least) so the lag is short. The Argo CD
health check below reports Progressing while the generations differ, and the
Flux rule below treats the object as in progress. Allow the lag in any
timeout of a sync wait or a Kustomization.
Argo CD¶
Tested in part
CI runs this script, read from this page, under gopher-lua (the Lua
engine Argo CD embeds) over objects the agent's status writer produced:
ready, blocked, unknown, no target, stale, a spec edited since the last
tick, and no status
(TestArgoHealthScriptAgainstWrittenStatus in internal/crd). It has
not been run inside a live Argo CD.
Argo CD applies health checks to the resources an Application tracks. To
gate a sync or a promotion on readiness, commit the ClusterReadiness
object (with spec.targets) to the Application's repository and leave the
chart's agent.targets empty, so Git owns the spec and the agent fills the
status. Then add this to argocd-cm:
data:
# The script needs the string, math and os (os.time) libraries.
resource.customizations.useOpenLibs.upgradescope.basit.engineer_ClusterReadiness: "true"
resource.customizations.health.upgradescope.basit.engineer_ClusterReadiness: |
-- Treat a status older than this as stale: three default 10m intervals.
local staleAfterSeconds = 30 * 60
local hs = { status = "Progressing", message = "Waiting for the upgradescope agent's first evaluation" }
if obj.status == nil or obj.status.conditions == nil then
return hs
end
if obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
and obj.status.observedGeneration < obj.metadata.generation then
hs.message = "spec changed; waiting for the agent's next tick"
return hs
end
-- lastEvaluated is RFC 3339 UTC ("2026-10-02T12:00:00Z"). Convert it to
-- Unix time arithmetically: os.time on a table would read it as the
-- controller's local time.
local function unixUTC(y, m, d, h, mi, s)
if m <= 2 then y = y - 1 end
local era = math.floor(y / 400)
local yoe = y - era * 400
local doy = math.floor((153 * ((m + 9) % 12) + 2) / 5) + d - 1
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
return (era * 146097 + doe - 719468) * 86400 + h * 3600 + mi * 60 + s
end
local last = obj.status.lastEvaluated
if last ~= nil then
local y, mo, d, h, mi, s = string.match(last, "^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
if y ~= nil then
local at = unixUTC(tonumber(y), tonumber(mo), tonumber(d), tonumber(h), tonumber(mi), tonumber(s))
if os.time() - at > staleAfterSeconds then
hs.status = "Degraded"
hs.message = "status is stale: last evaluated " .. last .. "; is the upgradescope agent running?"
return hs
end
end
end
-- Ready is the first target's verdict. AllTargetsReady covers every
-- target of a multi-target plan; an agent older than v0.2.0 does not
-- write it, and then only Ready decides.
local ready, all
for _, c in ipairs(obj.status.conditions) do
if c.type == "Ready" then ready = c end
if c.type == "AllTargetsReady" then all = c end
end
if ready == nil then
return hs
end
hs.message = ready.message
if ready.status ~= "True" then
-- False (Blocked) or Unknown (NotAssessed): not safe to upgrade.
hs.status = "Degraded"
hs.message = ready.reason .. ": " .. ready.message
return hs
end
if all ~= nil and all.status ~= "True" then
-- The first target is ready, a later one is blocked or not assessed.
hs.status = "Degraded"
hs.message = "AllTargetsReady " .. all.reason .. ": " .. all.message
return hs
end
hs.status = "Healthy"
return hs
| State | Health |
|---|---|
| no status yet | Progressing |
observedGeneration behind metadata.generation (spec just edited) |
Progressing |
lastEvaluated older than staleAfterSeconds |
Degraded |
Ready=True (and AllTargetsReady=True, where the agent writes it) |
Healthy |
Ready=False (Blocked) or Unknown (NotAssessed) |
Degraded |
Ready=True but AllTargetsReady False or Unknown (a later target is blocked or not assessed) |
Degraded |
Raise staleAfterSeconds if you run the agent with a longer --interval.
The agent Deployment's own health in Argo CD follows its readiness probe.
Flux¶
Not tested by this project
No CI job runs Flux against upgradescope. The rule below follows the
conditions the agent writes, which are tested (TestReadyCondition and
TestAllTargetsReadyCondition in internal/crd); the Flux side is
untested. That its health check honours observedGeneration was read in
Flux's source (runtime/cel/status_evaluator.go in fluxcd/pkg, on
2026-10-11), not run.
Flux's kustomize-controller can wait on custom resources it applies. A
Kustomization that applies the ClusterReadiness object above can state
its health rule with CEL (healthCheckExprs, Flux 2.5 or later):
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: upgrade-readiness
namespace: flux-system
spec:
interval: 10m
path: ./clusters/prod/upgradescope
prune: true
sourceRef: {kind: GitRepository, name: platform}
wait: true
timeout: 15m
healthCheckExprs:
- apiVersion: upgradescope.basit.engineer/v1alpha1
kind: ClusterReadiness
current: status.conditions.exists(e, e.type == 'AllTargetsReady' && e.status == 'True')
failed: status.conditions.exists(e, e.type == 'AllTargetsReady' && e.status == 'False')
The rule reads AllTargetsReady, so a blocked later target of a plan holds
the rollout; for a single target it is the same as Ready. Use Ready in
both expressions to follow the first target alone. The expressions use
exists, not filter(...).all(...), because all is true over an empty
list: with no matching condition, they stay in progress instead of passing.
Flux's health check for custom resources compares status.observedGeneration
with metadata.generation before it evaluates current and failed: while
the status lags the spec, the object is in progress, whatever the old
conditions say (kstatus does the same). So a spec edit does not pass on the
old verdict; the Kustomization waits for the next tick, up to about 11
minutes with the default interval, and its timeout should allow that. An
agent older than v0.2.0 does not write AllTargetsReady: neither expression
then matches and the Kustomization stays in progress until its timeout; use
Ready there.
AllTargetsReady=Unknown (no blocker found, but a required check was not assessed)
matches neither expression, so the Kustomization stays in progress until
its timeout. Downstream Kustomizations can then dependsOn this one to
hold a rollout until the cluster reads ready.