Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ scope. It does not GET Live objects, and it does not call Create,
Update, Patch, or Delete, including server-side or mutating dry-run
forms of those writes.

Drift performs only GET and LIST against Live Kubernetes resources.
It performs no server-side or mutating dry-run and no write
operation. The existing Helm client-side dry-run rendering used to
construct Desired remains unchanged.

These concerns are outside the semantic plan:

- server-side apply and server dry-run prediction
Expand Down
43 changes: 43 additions & 0 deletions docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,49 @@ are drift regardless of who changed them. A field intentionally
absent from Desired that existed in Previous is a declarative
removal: a Resource Change, not Drift by itself.

Drift actions are Modified, Missing, and Unexpected. They are not
create, update, or delete.

- Modified: Previous and Live both exist and the declared surface
differs. Both snapshots are present and Fields is non-empty.
- Missing: Previous exists and Live does not. Previous is present,
Live is absent, and Fields is empty.
- Unexpected: a release-owned Live object has no Previous logical
identity. Previous is absent, Live is present, and Fields is empty.

Modified and Missing name the Previous declaration: its apiVersion,
kind, effective namespace, and name. Unexpected names the observed
Live object. An apiVersion transition alone is not Drift, and it is
not Missing plus Unexpected. A modified or missing entry keeps the
Previous apiVersion even when Live was read through another version.

A fresh install has no Previous. It reads no Live, and its Drift is
empty. A Live object that already uses a Desired name is not Drift
on a fresh install.

On an existing release, each named Previous declaration is read with
GET through that declaration's own REST mapping, effective namespace,
and name. Identity is not resolved through Desired.

Unexpected objects are listed only in buckets taken from Previous:
group, kind, and effective namespace. Each unambiguous bucket is
listed once, through the highest Previous version in that bucket,
with the label selector `deployah.dev/instance` equal to the release
name. An object is Unexpected only when all of these hold: the
instance label matches the release, `deployah.dev/source` is `spec`
or `manifests`, there is no `helm.sh/hook` annotation,
`meta.helm.sh/release-name` and `meta.helm.sh/release-namespace`
match the release, the logical identity is not in Previous, and the
object is not the target Namespace. Chart CRDs and Helm hook runtime
resources are not Previous declarations, so they are outside this
scope. A bucket that contains a generateName-only Previous
declaration is not listed. Named resources in that bucket are still
read with GET.

Drift does not change HelmAction, Resource Changes, tasks, chart
CRDs, Summary, or HasEffects. A plan can be a no-op and still list
Drift.

Do not report API-server bookkeeping as Resource Changes or drift:
status, uid, resourceVersion, generation, managedFields, creation
timestamps, and similar server-maintained metadata. This is not a
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0006-semantic-plan-follows-helm-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ hooks still run (ADR-0014).
CRD lifecycle is ADR-0008.

The target namespace is an execution prerequisite created by Helm
install, outside the release. It is never a Resource Change. Planning
does not read Live to decide whether that namespace exists. A missing
namespace is not a planning failure.
install, outside the release. It is never a Resource Change and never
a Drift entry. Planning does not read Live to decide whether that
namespace exists. A missing namespace is not a planning failure.

Neither the Desired render nor the Previous release baseline may
declare that target namespace. If either does, planning fails with a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ It does not inspect CRD spec semantics, infer scope, or treat those
files as Kubernetes objects it owns. Raw bytes are preserved for Helm.
Kubernetes acceptance belongs to Helm and Kubernetes.

If the planner needs discovery, REST mapping, or a Live GET to
If the planner needs discovery, REST mapping, or a Live GET or LIST to
determine current state and the read fails, planning fails. That is
missing information, not prediction. Do not use managedFields as the
semantic source of truth for Deployah ownership.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ HelmAction and Resource Changes.
## Decision

Resource Changes require enough release state to construct Previous,
and discovery to resolve scope (ADR-0013). Drift requires Live as
well. Deployah does not support offline semantic planning.
and discovery to resolve scope (ADR-0013). Drift on an existing
release requires GET and LIST of Live. A fresh install needs no Live
access and reports empty Drift. A failed Live read fails planning.
There is no partial or incomplete Drift. Drift never writes to Live.
Deployah does not support offline semantic planning of an existing
release.

When that information is unavailable, the planner must not fabricate
Previous, Drift, HelmAction, or Resource Changes.
Expand All @@ -37,5 +41,7 @@ separate capabilities. They are not semantic planning.

- Operators without release access and discovery cannot obtain a
semantic plan.
- Drift also needs Live, so it cannot be reported from the release
baseline alone.
- Drift on an existing release needs Live, so it cannot be reported
from the release baseline alone.
- A failed Live read fails the whole plan. There is no incomplete
Drift result.
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ The declared surface is ADR-0005.

Semantic comparison may treat two representations as equal only when
Deployah has an explicit, deterministic, semantics-preserving
equivalence rule. Example: Kubernetes resource quantities whose
canonical forms are equal (`1000m` and `1`).
equivalence rule. Kubernetes resource quantities such as `1000m` and
`1` are not one of those rules. DiffFields does not treat them as
equal, so a non-canonical quantity is a difference.

Normalization is comparison-only. It must not rewrite user manifests,
rewrite Previous Helm manifests, mutate Live objects, become generic
Expand All @@ -29,6 +30,30 @@ normalized between equivalent effective representations so raw
rendered YAML and Helm's applied representation do not produce
artificial release changes.

Drift compares the Previous-declared surface with Live. Maps keep
only keys declared in Previous. An empty Previous map declares no
keys, so Live keys under it are not drift. Lists are compared by
position, and extra Live elements are kept whole. Dropping those
elements would hide drift without schema-aware list semantics.
Previous `[a]` against Live `[a, b]` is Modified. A reordered list is
Modified. There is no strategic-merge, list-map-key, or OpenAPI list
semantics. `/apiVersion` is ignored for Drift.

The only absence equivalences are explicit and path-aware. An empty
`resources.limits` map on a container of an apps Deployment, an apps
StatefulSet, or a batch CronJob equals a Live object that omits that
key. A core v1 Secret folds `stringData` into `data` on the
comparison copy only: each string value is the base64 of its UTF-8
bytes, and `stringData` overrides the same `data` key. Non-string
`stringData` values stay in `stringData`. There is no generic rule
that `null`, `{}`, or `[]` equals absent for other paths, other
kinds, or CRDs.

Snapshots stay separate from comparison copies. The stored Previous
snapshot is never rewritten. Field paths and values may come from the
normalized comparison copies. Presentation renders those paths from
the field values and does not rewrite the stored Previous snapshot.

The plan needs meaningful structural comparison: changing one field
inside an object or list should not force the whole resource to look
opaque. Live-only undeclared state must not become synthetic removals
Expand All @@ -45,10 +70,15 @@ not this decision.

### Positive

- Equivalent quantity encodings and Helm ownership metadata do not
appear as fake release changes.
- Helm ownership metadata does not appear as a fake release change.
- Drift shows the declared surface. Live-only keys under a declared
map do not become removals.

### Negative

- Only listed, deterministic rules count as equal. Unknown encodings
still show as differences.
- An empty `resources.limits` map does not declare keys, so a Live
value under that map is not drift.
- A cluster-scoped object whose declared `metadata.namespace` the API
server drops is Modified, because that field stays in Previous.
91 changes: 79 additions & 12 deletions internal/e2e/e2e_semantic_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (c *semanticPlanClient) RenderManifestsWithPrep(
return c.result, c.prep, func() {}, nil
}

func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
func (s *E2ESuite) TestSemanticPlanDoesNotMutateLive() {
t := s.T()
resolved := &spec.ResolvedSpec{
Spec: &spec.Spec{Project: "shop"},
Expand All @@ -70,7 +70,7 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
},
prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1},
}
p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{
p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, nil, plan.SemanticBuildInput{
ClusterContext: kindContext,
Resolved: resolved,
})
Expand All @@ -91,20 +91,20 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
require.True(t, apierrors.IsNotFound(err), "namespace %s should still be absent: %v", ns, err)
_, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{})
require.True(t, apierrors.IsNotFound(err), "configmap app should still be absent: %v", err)
assert.Empty(t, p.Drift)
})

t.Run("upgrade ignores live drift", func(t *testing.T) {
t.Run("upgrade reads live and does not mutate", func(t *testing.T) {
ns := fixtureNamespace("semantic-plan-upgrade")
manifest := configMapManifest(ns, "app", "same") + "---\n" + configMapManifest(ns, "other", "same")
cs, _ := s.kubeClients(t)
cs, dyn := s.kubeClients(t)
_, err := cs.CoreV1().Namespaces().Create(t.Context(), &corev1.Namespace{Name: ns}, metav1.CreateOptions{})
require.NoError(t, err)
t.Cleanup(func() { s.deleteNamespace(t, ns) })
for _, name := range []string{"app", "other"} {
_, err = cs.CoreV1().ConfigMaps(ns).Create(t.Context(), &corev1.ConfigMap{
Name: name,
Namespace: ns,
Data: map[string]string{"key": "same"},
Name: name, Namespace: ns,
Data: map[string]string{"key": "same"},
}, metav1.CreateOptions{})
require.NoError(t, err)
}
Expand All @@ -114,6 +114,47 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
_, err = cs.CoreV1().ConfigMaps(ns).Update(t.Context(), live, metav1.UpdateOptions{})
require.NoError(t, err)
require.NoError(t, cs.CoreV1().ConfigMaps(ns).Delete(t.Context(), "other", metav1.DeleteOptions{}))
for _, cm := range []*corev1.ConfigMap{
{
Name: "extra",
Namespace: ns,
Labels: map[string]string{spec.LabelInstance: "web"},
Annotations: map[string]string{
spec.AnnotationSource: spec.SourceSpec,
"meta.helm.sh/release-name": "web",
"meta.helm.sh/release-namespace": ns,
},
Data: map[string]string{"key": "extra"},
},
{
Name: "label-only",
Namespace: ns,
Labels: map[string]string{spec.LabelInstance: "web"},
Data: map[string]string{"key": "extra"},
},
{
Name: "no-helm",
Namespace: ns,
Labels: map[string]string{spec.LabelInstance: "web"},
Annotations: map[string]string{
spec.AnnotationSource: spec.SourceSpec,
},
Data: map[string]string{"key": "extra"},
},
} {
_, err = cs.CoreV1().ConfigMaps(ns).Create(t.Context(), cm, metav1.CreateOptions{})
require.NoError(t, err)
}

versions := map[string]string{}
nsObj, err := cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{})
require.NoError(t, err)
versions["namespace"] = nsObj.ResourceVersion
for _, name := range []string{"app", "extra", "label-only", "no-helm"} {
got, getErr := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), name, metav1.GetOptions{})
require.NoError(t, getErr)
versions[name] = got.ResourceVersion
}

release := &v1.Release{
Name: "web",
Expand All @@ -136,7 +177,7 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
NextRevision: 2,
},
}
p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{
p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.NewDynamicLiveReader(dyn), plan.SemanticBuildInput{
ClusterContext: kindContext,
Resolved: resolved,
})
Expand All @@ -145,10 +186,36 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() {
require.NoError(t, err)
assert.Equal(t, semantic.HelmNone, p.HelmAction)
assert.Empty(t, p.Changes)

got, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{})
require.NoError(t, err)
assert.Equal(t, "drifted", got.Data["key"])
require.Len(t, p.Drift, 3)
byName := map[string]semantic.DriftChange{}
for _, d := range p.Drift {
byName[d.Resource.Name] = d
}
modified := byName["app"]
assert.Equal(t, semantic.DriftModified, modified.Action)
require.Len(t, modified.Fields, 1)
assert.Equal(t, "/data/key", modified.Fields[0].Path)
assert.Equal(t, semantic.DriftMissing, byName["other"].Action)
assert.Equal(t, semantic.DriftUnexpected, byName["extra"].Action)
_, hasLabelOnly := byName["label-only"]
assert.False(t, hasLabelOnly)
_, hasNoHelm := byName["no-helm"]
assert.False(t, hasNoHelm)

for name, rv := range versions {
if name == "namespace" {
got, getErr := cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{})
require.NoError(t, getErr)
assert.Equal(t, rv, got.ResourceVersion)
continue
}
got, getErr := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), name, metav1.GetOptions{})
require.NoError(t, getErr)
assert.Equal(t, rv, got.ResourceVersion)
if name == "app" {
assert.Equal(t, "drifted", got.Data["key"])
}
}
_, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "other", metav1.GetOptions{})
require.True(t, apierrors.IsNotFound(err), "deleted configmap should stay absent: %v", err)
})
Expand Down
3 changes: 2 additions & 1 deletion internal/plan/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
// upgrade, or none from the Helm operation and from comparing the
// previous release with the render and its hooks. Resource changes
// compare the previous manifest with the rendered Desired manifest.
// Discovery supplies scope only. Chart CRDs pass through to Helm.
// Discovery supplies scope only. An existing release also reads Live
// with GET and LIST to compute Drift. Chart CRDs pass through to Helm.
// The result is a [deployah.dev/deployah/internal/plan/semantic.Plan].
// Those types live in plan/semantic. Their rendering lives in plan/view.
//
Expand Down
Loading
Loading