diff --git a/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md b/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md index 9badc80..d02331c 100644 --- a/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md +++ b/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md @@ -6,44 +6,44 @@ Accepted ## Context -Deployah's plan was treated as a preview of the Kubernetes state a -deploy would produce. That mixed two questions: what Deployah intends -to do, and whether the API server, admission, quotas, RBAC, and -controllers would accept or mutate it. - -Answering the second question pulled planning onto write dry-runs, -managedFields reconstruction, and partial results when prediction was -uncertain. A correct statement of intent then looked incomplete -whenever Kubernetes might later refuse the write. +A semantic plan can answer what the release declares, or whether +Kubernetes would accept the write. Those are different questions. +Admission, quotas, RBAC, webhooks, and field ownership decide the +second one. Pulling that into the plan turns a statement of intent +into a guess about a later deploy. ## Decision -The semantic plan describes Deployah's intended Kubernetes operations -if this deployment runs. It is not an execution-feasibility engine and -not a Kubernetes state prediction engine. A plan may be correct even -when the later deploy fails. +The semantic plan describes release intent. It is not an +execution-feasibility engine and not a prediction of the API server's +final state. A plan may be correct even when the later deploy fails. + +Resource Changes compare Previous with Desired (ADR-0011). Drift +compares Previous with Live (ADR-0005). Live does not change a +Resource Change. -These concerns are outside semantic planning: +Rendering Desired uses Helm's client-side dry-run and release +history. Helm discovery during that render is valid. After that +render, classifying a Resource Change uses discovery only to resolve +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. -- admission controllers and validating or mutating webhooks -- quota availability and write RBAC -- server-side apply ownership conflicts and immutable-field rejection -- Kubernetes defaulting, controller reconciliation, and final generated - names -- the exact final API-server state +These concerns are outside the semantic plan: -A later prediction, preflight, or feasibility capability may cover some -of those. It is not part of this contract, including under a renamed -prediction abstraction. +- server-side apply and server dry-run prediction +- managedFields migration +- ownership and adoption feasibility +- admission, quota, RBAC, and conflict prediction +- pre-flight checks +- how Helm writes or deletes objects -Semantic planning is read-only toward the cluster. GET, discovery, and -REST mapping are allowed. CREATE, UPDATE, PATCH, DELETE, and any -dry-run form of those writes are not. Dry-run mutation is not part of -Previous, Live, or Desired. +A later pre-flight capability may cover some of those. It is not part +of this contract. -Required release and cluster state is ADR-0010. Resource consequences -are ADR-0011. API constructibility and migration are ADR-0013. Secret -presentation is ADR-0009. Deployah contract validation is ADR-0007. +Required release state is ADR-0010. API constructibility is ADR-0013. +Secret presentation is ADR-0009. Deployah contract validation is +ADR-0007. Ownership is ADR-0012. ## Consequences @@ -51,14 +51,13 @@ presentation is ADR-0009. Deployah contract validation is ADR-0007. - Plan output stays a statement of intent, not a guess at API-server success. -- Planning cannot depend on write dry-runs or managedFields - reconstruction. +- A Resource Change does not depend on Live or on a write dry-run. ### Negative -- Live replicas `4` and Desired replicas `3` may correctly show - `4 -> 3` even if another field manager owns the field and runtime - server-side apply fails. +- Previous replicas `3`, Desired replicas `5`, and Live replicas `4` + report a Resource Change of `3 -> 5`. Live `4` is Drift, not the + change. - Desired replicas `100` remains valid intent even if admission later rejects values above `20`. - Operators who want "will this deploy work?" need a different diff --git a/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md b/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md index 53f2a7c..986d839 100644 --- a/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md +++ b/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md @@ -12,62 +12,68 @@ renders. Collapsing those into one "before -> after" hides whether a difference is a config edit, cluster drift, or both. Field-level Live noise also looks like intent if the plan treats -"absent from Desired" as a removal. +"absent from Desired" as a removal of Live state. ## Decision Semantic planning uses three distinct states. -- Previous is the intent recorded by the Helm release baseline selected - by Deployah's release-preparation semantics. +- Previous is the intent recorded by the Helm release baseline + selected by Deployah's release-preparation semantics. On a fresh + install, Previous is empty. - Live is the actual current Kubernetes state. - Desired is the deterministic Kubernetes intent generated from the current Deployah configuration and Helm render. -Those states answer different questions and are independent: +Those states answer different questions: -- Previous -> Desired: declarative / release intent change -- Previous -> Live: drift -- Live -> Desired: visible resource consequence when the semantic - plan includes the corresponding write (ADR-0011) +- Previous -> Desired: Resource Changes (ADR-0011) +- Previous -> Live: Drift -Do not infer a release change merely by comparing Live and Desired. -Ordinary drift alone is not a release change (ADR-0006). Whether a -requested deployment runs is ADR-0016. +Live -> Desired is not a semantic comparison. Do not infer a +Resource Change or Drift by comparing Live and Desired. Ordinary +drift alone is not a release change (ADR-0006). Whether a requested +deployment runs is ADR-0016. -Drift is intrinsic. If Previous and Live are available, Previous -> -Live is always evaluated. There is no opt-in drift mode. When there -is no drift, presentation may omit an empty Drift section. +Drift needs Previous. A fresh install has no Previous, so it reports +no Drift. Unexpected-resource drift applies only to an existing +release: a Live object with no Previous record. A field present only +in Live and absent from Previous and Desired does not automatically +become field drift. Fields declared in Previous that differ in Live +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. -A field present only in Live and absent from Previous and Desired does -not automatically become field drift. Fields declared in Previous that -differ in Live are drift regardless of who changed them. A field -intentionally absent from Desired that existed in Previous is a real -declarative removal. - -Do not report API-server bookkeeping as resource changes or 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 statement about Kubernetes managed-field ownership. -Previous absent, Live present, Desired present is resource-level -drift: the Live logical resource is unexpected relative to Previous. -That is distinct from field-level Live-only state on an expected -resource. Previous present and Live absent is missing-resource drift, -not synthetic field removals. Hook runtime artifacts are outside this -rule (ADR-0014). Resource consequences for those states are ADR-0011. - -Resources with the same group, kind, effective namespace, and name are -the same logical resource. apiVersion is not part of that identity. An +A named resource's logical identity is group, kind, effective +namespace, and name. apiVersion is not part of that identity. An apiVersion transition is not Delete plus Create. Reading the same logical object through another served apiVersion must not produce -drift solely because `/apiVersion` differs. For a cluster-scoped -resource, effective namespace is empty. Duplicate Desired logical -identities fail planning. +drift solely because `/apiVersion` differs. + +Effective namespace is part of identity. Snapshots keep the declared +content unmodified. For a namespaced resource, an omitted +`metadata.namespace` and an explicit release namespace are the same +effective namespace. For a cluster-scoped resource, effective +namespace is empty. A declared `metadata.namespace` on that object +stays in the snapshot. It is ordinary declared content: it is not +stripped, and it is not part of identity. + +`metadata.generateName` without `metadata.name` has no logical +identity. It is a fallback declaration key for pairing Previous with +Desired: group, kind, effective namespace, and generateName. That key +cannot locate a Live object. Duplicate logical identities on one side +fail planning. Duplicate generateName keys on one side fail planning +because pairing is ambiguous. Setting both name and generateName +fails planning. Setting neither fails planning. Labels and annotations in Previous or Desired are ordinary declared fields. Comparison normalization is ADR-0015. Secret presentation is -ADR-0009. +ADR-0009. Hook runtime artifacts are outside Drift (ADR-0014). ## Consequences @@ -75,10 +81,12 @@ ADR-0009. - Operators can tell a config edit from cluster drift on the same object. +- A fresh install reports Resource Changes and no Drift. - Live-only noise does not show up as Deployah removals. ### Negative -- Resource consequences and Drift must both be read. -- Declared-surface filtering can hide Live fields the operator cares - about until they appear in Previous or Desired. +- On an existing release, Resource Changes and Drift must both be + read. +- A generateName declaration cannot be matched to a Live object by + that key alone. diff --git a/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md b/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md index de9346d..52b8227 100644 --- a/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md +++ b/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md @@ -7,66 +7,56 @@ Accepted ## Context Deployah ships a generated umbrella chart and extra manifests through -Helm. A parallel Kubernetes lifecycle (forced CRD upgrades, install-time -namespace creation on upgrade, planner-invented recreates) makes the -plan describe operations Helm would not attempt. +Helm. The plan should describe the release Helm records, not a +parallel Kubernetes lifecycle. -Deployah's Helm 4.3.0 install path sets `CreateNamespace` and -server-side apply. Helm then creates that implicit Namespace through -an apply PATCH. An existing target namespace can therefore change on -install. +Helm install creates the target namespace before the release, with +CreateNamespace. That namespace is an execution prerequisite. It is +not a release object. ## Decision Deployah follows the lifecycle Helm actually performs. The semantic plan uses Helm lifecycle semantics to describe release changes and -their resource and task consequences. The plan does not decide +their Resource Changes and task changes. The plan does not decide whether a requested deployment runs (ADR-0016). The plan reports a release change when the desired release differs from the release Helm last recorded, including hook definitions. Ordinary live drift, including a missing live object, is not by itself a release change. A plan with no release change does not -invent resource consequences (ADR-0011). Drift stays independent +invent Resource Changes (ADR-0011). Drift stays independent (ADR-0005). When Helm upgrades, unchanged preDeploy and postDeploy hooks still run (ADR-0014). CRD lifecycle is ADR-0008. -Namespace lifecycle comes from Helm install `CreateNamespace`, not a -chart Namespace manifest. Deployah enables that flag together with -server-side apply. Helm applies an implicit Namespace (name, plus a -`name` label) through the server-side apply Create path. +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. -On install: missing Namespace is Create; existing Namespace with -differing declared implicit fields is Update; already matching is no -visible change. Live-only fields Helm did not declare must not become -removals (ADR-0005). +Neither the Desired render nor the Previous release baseline may +declare that target namespace. If either does, planning fails with a +Deployah contract error (ADR-0007). Deployah does not filter, drop, +or rewrite the stored release or the rendered manifest. Other +Namespace names are ordinary Resource Changes. -On a real upgrade Helm does not run CreateNamespace. Do not invent a -Namespace Create or Update from it. A missing release Namespace on -upgrade is not a planning failure. A later Helm or Kubernetes failure -is outside semantic planning (ADR-0004). - -Raw or custom manifests must not define Namespace resources. That is a -Deployah contract violation (ADR-0007). Do not merge a raw Namespace -with the implicit Helm namespace operation. - -A Desired resource with `metadata.generateName` is a Create that uses -that generateName. Do not fabricate the final runtime name. The -unknown final name does not make the semantic plan partial. +A resource with `metadata.generateName` and no `metadata.name` is +paired by its declaration key (ADR-0005). Do not fabricate the final +runtime name. ## Consequences ### Positive -- Planned release changes and their consequences follow Helm's - install, upgrade, and uninstall lifecycle, including server-side - apply on an install Create. +- Resource Changes follow the release Helm records. +- The target namespace stays an execution prerequisite, not a release + object. ### Negative -- Implicit Namespace fields can change on install through Helm - server-side apply. They do not change on upgrade. -- Raw Namespace manifests are rejected even when Kubernetes would - accept them. +- A chart or stored release that declares the target namespace fails + planning, even when Kubernetes would accept the object. +- An existing release that already claims the target namespace fails + planning. Deployah does not rewrite the stored manifest. diff --git a/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md b/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md index 5f65641..096f89d 100644 --- a/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md +++ b/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md @@ -28,8 +28,9 @@ behavior ambiguous: - duplicate logical resource identities (ADR-0005) - a raw resource that collides with a resource Deployah generates or manages -- raw Namespace resources; Namespace lifecycle belongs to Deployah's - Helm configuration (ADR-0006) +- a Namespace that names the release target, in the Desired render + or the Previous baseline, because that namespace is Helm install's + execution prerequisite (ADR-0006) - mutually incompatible Deployah configuration, including combinations the spec itself defines as exclusive (for example `replicas` with `autoscaling.enabled`) diff --git a/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md b/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md index cc064c3..b2d1598 100644 --- a/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md +++ b/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md @@ -6,19 +6,22 @@ Accepted ## Context -A semantic plan is built from Previous and Live (ADR-0005). Rendering -Desired YAML without those states can still validate a spec. Treating -that render as a semantic plan fabricates Drift, HelmAction, and -resource consequences. +A semantic plan is built from Previous and Desired (ADR-0005). +Rendering Desired YAML without the release baseline can still +validate a spec. Treating that render as a semantic plan fabricates +HelmAction and Resource Changes. ## Decision -A semantic plan requires enough release state to construct Previous -and enough cluster state to construct Live. Deployah does not support -offline semantic planning. +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. When that information is unavailable, the planner must not fabricate -Previous, Live, Drift, HelmAction, or resource consequences. +Previous, Drift, HelmAction, or Resource Changes. + +A fresh install has an empty Previous. That is known release state, +not a missing baseline. Cluster-independent validation and configuration resolution are separate capabilities. They are not semantic planning. @@ -27,10 +30,12 @@ separate capabilities. They are not semantic planning. ### Positive -- Plan output cannot invent a HelmAction or resource consequence from +- Plan output cannot invent a HelmAction or Resource Change from Desired YAML alone. ### Negative -- Operators without release and cluster access cannot obtain a +- 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. diff --git a/docs/adr/0011-resource-changes-compare-previous-with-desired.md b/docs/adr/0011-resource-changes-compare-previous-with-desired.md new file mode 100644 index 0000000..9fff1a4 --- /dev/null +++ b/docs/adr/0011-resource-changes-compare-previous-with-desired.md @@ -0,0 +1,76 @@ +# ADR-0011: Resource Changes compare Previous with Desired + +## Status + +Accepted + +## Context + +A release edit, cluster drift, and a later Helm write are three +different facts. Collapsing them into one action makes the plan +describe Live, or promise a Kubernetes delete Helm may not perform. + +The three states are defined in ADR-0005. This ADR owns Resource +Changes: the declarative difference between Previous and Desired. + +## Decision + +Resource Changes use this vocabulary: + +- Create: Previous is absent and Desired is present. +- Update: both are present and the declared content differs. +- Delete: Previous is present and Desired is absent. The object + leaves the Desired release state. This does not promise a + Kubernetes DELETE. `helm.sh/resource-policy: keep` is execution + behavior, not a planning action. + +Do not add Replace. Do not add Retain. Immutable-field rejection, +write method, field manager, force-conflicts, and delete propagation +are outside the semantic plan (ADR-0004, ADR-0012). + +None is the absence of a Resource Change. It is not a stored action. + +Human-readable Create and Delete output show the declared snapshot. +An Update shows Previous to Desired. + +Resource Changes preserve Helm's deploy/upgrade resource sequence. +Create and Update changes follow Helm's install/upgrade kind +ordering, keeping manifest order for equal kinds. Resources removed +during an upgrade are listed afterward in Previous release order, +matching Helm's originals.Difference(targets) deletion pass. Helm +uninstall is outside this semantic deploy plan. Deployah adds no +independent ordering policy of its own. It mirrors Helm's +target-first, deletion-afterward upgrade sequence and does not add +alphabetical, UI-specific, or additional action-based sorting. + +Representative combinations: + +1. Previous equals Desired, and Live differs: there is no Resource + Change. Drift exists. Drift does not create a Resource Change. +2. Previous differs from Desired, and Live already equals Desired: + the Resource Change is still Update. Drift may also exist. Do not + drop the Update because Live already matches. +3. Previous present, Desired absent, Live absent: the Resource Change + is Delete. Missing-resource drift may also exist. Delete does not + require the object to exist Live. +4. A fresh install has no Previous. Each Desired resource is Create. + A Live object that already occupies that identity is not Drift, + and it does not turn the Create into something else (ADR-0012). + +Adoption and ownership are ADR-0012. Drift is ADR-0005. + +## Consequences + +### Positive + +- Operators see the release edit even when Live already matches + Desired. +- Delete means the object leaves the Desired release, without + claiming Helm will remove it. + +### Negative + +- A Delete can name an object that is already absent Live, or that + Helm will keep because of resource policy. +- Live can differ from both Previous and Desired without changing + the Resource Change. diff --git a/docs/adr/0011-resource-consequences-are-distinct-from-release-change-and-drift.md b/docs/adr/0011-resource-consequences-are-distinct-from-release-change-and-drift.md deleted file mode 100644 index 3d72324..0000000 --- a/docs/adr/0011-resource-consequences-are-distinct-from-release-change-and-drift.md +++ /dev/null @@ -1,75 +0,0 @@ -# ADR-0011: Resource consequences are distinct from release change and drift - -## Status - -Accepted - -## Context - -Release change, Drift, and planned write consequences can disagree. -Collapsing them into one action invents Updates because a planned -Helm lifecycle transition exists, or Deletes of objects that no -longer exist. - -The three states are defined in ADR-0005. This ADR owns the visible -resource consequence when the semantic plan includes the -corresponding write. - -## Decision - -Visible resource consequences use this vocabulary: - -- Create: the relevant object does not exist Live, and the planned - Helm lifecycle transition includes that create. -- Update: the relevant Live object exists, differs from Desired, and - the planned Helm lifecycle transition includes that write. -- Delete: the object exists Live, leaves Desired/release state, and - the planned Helm lifecycle transition includes that delete. -- Retain: the object leaves Desired/release state, but Helm lifecycle - policy intentionally leaves it Live. - -None is the absence of a consequence. It is not a stored action or a -required output row. Do not add Replace. Immutable-field rejection and -replacement feasibility are outside semantic planning (ADR-0004). - -Human-readable Create output shows the complete intended manifest. An -Update starts from Live when the semantic plan includes that write. - -Retain's primary case is Live `helm.sh/resource-policy: keep`. When -that policy suppresses deletion, report Retain, not Delete. If -Previous declared keep and Live no longer has it, that removal is -drift. If keep exists only in Live, that Live-only field itself is not -drift (ADR-0005); Helm still honors it, so a leaving resource is -Retain. - -Representative combinations: - -1. Previous differs from Desired, Live already equals Desired: release - change exists, Drift may exist, no resource consequence. Do not - invent an Update merely because the plan includes a Helm lifecycle - transition. -2. Previous present, Desired absent, Live absent: release change and - missing-resource drift, no Delete. -3. Previous present, Live absent, Desired unchanged: missing-resource - drift only. Missing Live state alone is not a release change - (ADR-0006). It does not decide whether a requested deployment - runs (ADR-0016). -4. When the planned Helm lifecycle transition includes a write for a - release change, and a Desired resource that existed in Previous is - missing Live, show Create plus missing-resource drift. - -Adoption is ADR-0012. - -## Consequences - -### Positive - -- Operators can see Helm recording release intent even when Live - already matches Desired. -- Keep policy is Retain: the object leaves the release and stays Live. - -### Negative - -- The plan does not show a recreate for a missing Live object - unless there is a release change. A requested deploy of an - existing release still follows Helm upgrade (ADR-0016). diff --git a/docs/adr/0012-resource-ownership-and-adoption-are-outside-the-semantic-plan.md b/docs/adr/0012-resource-ownership-and-adoption-are-outside-the-semantic-plan.md new file mode 100644 index 0000000..77fbd98 --- /dev/null +++ b/docs/adr/0012-resource-ownership-and-adoption-are-outside-the-semantic-plan.md @@ -0,0 +1,41 @@ +# ADR-0012: Resource ownership and adoption are outside the semantic plan + +## Status + +Accepted + +## Context + +A Live object can already exist when it first enters Desired, with no +Previous record. Helm's default install uses TakeOwnership=false, so +that collision can fail at deploy time. Predicting whether adoption +would succeed pulls ownership feasibility into the semantic plan +(ADR-0004). + +## Decision + +Ownership and adoption are future pre-flight work. They are not +Resource Changes. + +Previous absent and Desired present is Create, whether or not a Live +object already occupies that identity (ADR-0011). The semantic plan +does not report an adoption candidate, and it does not take +ownership. + +Deployah's default Helm lifecycle uses TakeOwnership=false. Explicit +ownership takeover is future work: GitHub issue +[#142](https://github.com/deployah-dev/deployah/issues/142). + +## Consequences + +### Positive + +- Create stays a statement of Desired entering the release, not a + guess about who owns the Live object. + +### Negative + +- Default deploy still fails later if Helm requires ownership that + Deployah did not take. +- The plan does not warn that a Live object already occupies a fresh + install's identity. diff --git a/docs/adr/0012-semantic-plan-reports-adoption-candidates-without-taking-ownership.md b/docs/adr/0012-semantic-plan-reports-adoption-candidates-without-taking-ownership.md deleted file mode 100644 index 121c6ca..0000000 --- a/docs/adr/0012-semantic-plan-reports-adoption-candidates-without-taking-ownership.md +++ /dev/null @@ -1,45 +0,0 @@ -# ADR-0012: Semantic plan reports adoption candidates without taking ownership - -## Status - -Accepted - -## Context - -A Live object can already exist when it first enters Desired, with no -Previous record. Treating that as Create is wrong. Treating it as -automatic Helm ownership is also wrong. Resource consequences are -ADR-0011. - -## Decision - -Adoptable is an informational ownership condition. It is not Create, -Update, Delete, or Retain. It does not mean deployment will succeed. -It does not take ownership. - -The relevant state is Previous absent, Live present, Desired present. -Determine the ordinary resource consequence independently -(ADR-0011): Live differs from Desired is Update; Live already equals -Desired is no resource consequence. Independently evaluate Helm -ownership metadata. - -If the Live object is already correctly owned by this release, it is -not Adoptable merely because Previous is absent. If ownership is -missing or belongs elsewhere, surface Adoptable. - -Do not predict that adoption is safe. Do not automatically take -ownership. Deployah's default Helm lifecycle uses TakeOwnership=false. -Explicit ownership takeover is future work: GitHub issue -[#142](https://github.com/deployah-dev/deployah/issues/142). - -## Consequences - -### Positive - -- Operators can see that a Live object is entering the release - footprint without Deployah taking it. - -### Negative - -- Default deploy still fails later if Helm requires ownership that - Deployah did not take. diff --git a/docs/adr/0013-semantic-planning-requires-helm-constructible-api-representations.md b/docs/adr/0013-semantic-planning-requires-helm-constructible-api-representations.md index e0e71e9..98714cb 100644 --- a/docs/adr/0013-semantic-planning-requires-helm-constructible-api-representations.md +++ b/docs/adr/0013-semantic-planning-requires-helm-constructible-api-representations.md @@ -7,35 +7,34 @@ Accepted ## Context Logical identity can cross apiVersions (ADR-0005). Helm still has to -REST-map the actual GVKs it builds. Live lookup through another served -version does not make an unmappable Previous or Desired GVK -constructible. Chart CRD lifecycle is ADR-0008. +REST-map the actual GVKs it builds. Chart CRD lifecycle is ADR-0008. ## Decision -Logical identity, Live lookup, and Helm constructibility are separate. +Logical identity and Helm constructibility are separate. Resource +Change classification never depends on Live lookup. -Live lookup for a custom resource may use another currently served -version of the same group and kind. Do not classify the object as -Create solely because the Desired apiVersion is not currently -mappable. +Every Previous and Desired GVK is resolved through current discovery. +That mapping decides scope and whether Helm can construct the +object. It is not admission, webhook, quota, or write-feasibility +prediction (ADR-0004). If mapping fails, planning fails. The error +names the GVK and the resource. Do not classify the object by +guessing another served version. -Helm must REST-map the GVK it actually builds. That is required to -construct the operation. It is not admission, webhook, quota, or -write-feasibility prediction (ADR-0004). Semantic planning does not -parse `.deployah/crds/` to invent API availability. +Semantic planning does not parse `.deployah/crds/` to invent API +availability. A Desired custom resource whose CRD arrives only from +`.deployah/crds/` in the same install fails planning, because +discovery does not yet serve that GVK. On a real upgrade, chart CRDs are not applied (ADR-0008). They cannot invent new API availability. Helm must be able to build: -1. the Previous/current release representation -2. the Desired target representation at the point Helm builds it +1. the Previous release representation +2. the Desired target representation -If a GVK in the current Helm release manifest is no longer served and -Helm cannot build that current resource, planning fails, even if Live -is readable through another apiVersion. If the Desired GVK cannot be -mapped at the point Helm builds it, planning fails. Do not show a CRD -Update on upgrade to paper over that. +If a GVK in the current Helm release manifest is no longer served, +planning fails. If the Desired GVK cannot be mapped, planning fails. +Do not show a CRD Update on upgrade to paper over that. Do not rewrite Previous. Do not migrate stored Helm release manifests. Do not mutate the cluster. The planning error must identify enough @@ -50,8 +49,10 @@ Recovery of unserved stored APIs is future work: GitHub issue ### Positive - Logical identity and Helm GVK mapping stay separate. +- Classification does not depend on a Live read. ### Negative -- On a real upgrade, an unconstructible Previous or Desired GVK fails - planning even if Live is readable through another served version. +- An unconstructible Previous or Desired GVK fails planning. +- A custom resource introduced in the same install as its chart CRD + fails planning, because that CRD is not served yet. diff --git a/docs/adr/0014-task-planning-follows-the-helm-managed-footprint.md b/docs/adr/0014-task-planning-follows-the-helm-managed-footprint.md index ba17ea2..24f409f 100644 --- a/docs/adr/0014-task-planning-follows-the-helm-managed-footprint.md +++ b/docs/adr/0014-task-planning-follows-the-helm-managed-footprint.md @@ -11,7 +11,7 @@ one-time operation outside the release. Treating leftover hook Jobs as declarative Drift, or listing a current manual task as a deploy resource, invents a lifecycle Helm does not run. The lifecycle the plan describes is ADR-0006. Whether a requested deployment runs is -ADR-0016. Resource consequences are ADR-0011. +ADR-0016. Resource Changes are ADR-0011. ## Decision @@ -38,10 +38,11 @@ install or upgrade it runs (ADR-0016). Changing phase between Helm-managed phases is a Task Update. -- schedule to preDeploy: previous CronJob is Resource Delete, new hook - definition is Create, task is Update. -- preDeploy to schedule: previous hook definition leaves the release, - new CronJob is Resource Create, task is Update. +- schedule to preDeploy: the previous CronJob is a Delete Resource + Change, the new hook definition is Create, and the task is Update. +- preDeploy to schedule: the previous hook definition leaves the + release, the new CronJob is a Create Resource Change, and the task + is Update. Do not treat removal of an old hook definition as a Kubernetes Delete of leftover runtime hook Jobs. diff --git a/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md b/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md index 04ab758..63ca7d9 100644 --- a/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md +++ b/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md @@ -36,7 +36,7 @@ opaque. Live-only undeclared state must not become synthetic removals comparison. Do not invent extra semantic interpretation. Human-readable Drift uses the same YAML-oriented diff style as -resource consequences. JSON Pointer paths are not the primary human +Resource Changes. JSON Pointer paths are not the primary human UX. Machine-readable output may use structural paths. Exact diff library, list-alignment algorithm, and implementation mechanism are not this decision. diff --git a/docs/adr/0016-separate-semantic-planning-from-deployment-execution.md b/docs/adr/0016-separate-semantic-planning-from-deployment-execution.md index 711ba18..9c9e7a6 100644 --- a/docs/adr/0016-separate-semantic-planning-from-deployment-execution.md +++ b/docs/adr/0016-separate-semantic-planning-from-deployment-execution.md @@ -7,16 +7,16 @@ Accepted ## Context Deployah both inspects a deployment and carries one out. Inspection -describes release changes and their consequences. Execution carries -out a deployment the operator asked for. Those are different jobs. -Inspection must not decide whether that deployment runs. +describes release changes and their Resource Changes. Execution +carries out a deployment the operator asked for. Those are different +jobs. Inspection must not decide whether that deployment runs. ## Decision Planning and inspection are read-only. They describe release changes -and the resource and task consequences of those changes. Cluster -state informs drift and those consequences. It is not itself a -release change. +and the Resource Changes and task changes that follow. Cluster state +informs Drift only and never changes Resource Changes. It is not +itself a release change. A requested deployment runs after validation and guards succeed, including any volume preparation that must happen before Helm. It diff --git a/internal/e2e/e2e_helpers_test.go b/internal/e2e/e2e_helpers_test.go index fd4b705..297c748 100644 --- a/internal/e2e/e2e_helpers_test.go +++ b/internal/e2e/e2e_helpers_test.go @@ -26,12 +26,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" apierrors "k8s.io/apimachinery/pkg/api/errors" ) +func (s *E2ESuite) kubeClients(t *testing.T) (*kubernetes.Clientset, dynamic.Interface) { + t.Helper() + cs, err := kubernetes.NewForConfig(s.client.RESTConfig()) + require.NoError(t, err) + dyn, err := dynamic.NewForConfig(s.client.RESTConfig()) + require.NoError(t, err) + return cs, dyn +} + func TestIsRetryableAPIError(t *testing.T) { t.Parallel() diff --git a/internal/e2e/e2e_predict_test.go b/internal/e2e/e2e_predict_test.go deleted file mode 100644 index ac33556..0000000 --- a/internal/e2e/e2e_predict_test.go +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build e2e - -package e2e_test - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (s *E2ESuite) predictCluster(t *testing.T) predict.Cluster { - t.Helper() - cluster, err := predict.NewCluster(s.client.RESTConfig()) - require.NoError(t, err) - return cluster -} - -func (s *E2ESuite) kubeClients(t *testing.T) (*kubernetes.Clientset, dynamic.Interface) { - t.Helper() - cs, err := kubernetes.NewForConfig(s.client.RESTConfig()) - require.NoError(t, err) - dyn, err := dynamic.NewForConfig(s.client.RESTConfig()) - require.NoError(t, err) - return cs, dyn -} - -func predictConfigMapYAML(name, namespace, value string) string { - return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + - "\n namespace: " + namespace + "\ndata:\n key: " + value + "\n" -} - -func applyJSON(t *testing.T, obj *unstructured.Unstructured) []byte { - t.Helper() - data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) - require.NoError(t, err) - return data -} - -func ownedConfigMapUnstructured(name, namespace, release, value string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - "labels": map[string]any{ - "app.kubernetes.io/managed-by": "Helm", - }, - "annotations": map[string]any{ - "meta.helm.sh/release-name": release, - "meta.helm.sh/release-namespace": namespace, - }, - }, - "data": map[string]any{"key": value}, - }} -} - -func ssaApplyOwnedConfigMap(t *testing.T, dyn dynamic.Interface, obj *unstructured.Unstructured, fieldManager string) { - t.Helper() - gvr := corev1.SchemeGroupVersion.WithResource("configmaps") - _, err := dyn.Resource(gvr).Namespace(obj.GetNamespace()).Patch( - t.Context(), obj.GetName(), types.ApplyPatchType, applyJSON(t, obj), metav1.PatchOptions{ - FieldManager: fieldManager, - FieldValidation: metav1.FieldValidationStrict, - }) - require.NoError(t, err) -} - -func findPredictResult(t *testing.T, results []predict.Result, name string) predict.Result { - t.Helper() - for _, r := range results { - if r.Identity.Name == name { - return r - } - } - t.Fatalf("Predict() missing result %q", name) - return predict.Result{} -} - -func (s *E2ESuite) TestPredictSSACreate() { - t := s.T() - ns := fixtureNamespace("predict-ssa-create") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - cluster := s.predictCluster(t) - cs, _ := s.kubeClients(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - assert.Equal(t, predict.ActionCreate, got.Action) - require.NotNil(t, got.Predicted) - key, found, nestErr := unstructured.NestedString(got.Predicted.Object, "data", "key") - require.NoError(t, nestErr) - require.True(t, found) - assert.Equal(t, "next", key) - - _, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) - require.True(t, apierrors.IsNotFound(err), "live object must still be missing after Predict: %v", err) -} - -func (s *E2ESuite) TestPredictSSAUpdate() { - t := s.T() - ns := fixtureNamespace("predict-ssa-update") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - cs, dyn := s.kubeClients(t) - ssaApplyOwnedConfigMap(t, dyn, ownedConfigMapUnstructured("app", ns, "web", "live"), kube.ManagedFieldsManager) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - assert.Equal(t, predict.ActionUpdate, got.Action) - require.NotNil(t, got.Predicted) - key, found, nestErr := unstructured.NestedString(got.Predicted.Object, "data", "key") - require.NoError(t, nestErr) - require.True(t, found) - assert.Equal(t, "next", key) - - live, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) - require.NoError(t, err) - assert.Equal(t, "live", live.Data["key"]) -} - -func (s *E2ESuite) TestPredictSSAConflict() { - t := s.T() - ns := fixtureNamespace("predict-ssa-conflict") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - _, dyn := s.kubeClients(t) - ssaApplyOwnedConfigMap(t, dyn, ownedConfigMapUnstructured("app", ns, "web", "other"), "other-manager") - - cluster := s.predictCluster(t) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsConflict(err), "Predict() = %v, want field-manager conflict", err) -} - -func (s *E2ESuite) TestPredictAPIServerDefaulting() { - t := s.T() - ns := fixtureNamespace("predict-api-default") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - desired := `apiVersion: v1 -kind: Service -metadata: - name: svc - namespace: ` + ns + ` -spec: - selector: - app: web - ports: - - port: 80 -` - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: desired, - }) - require.NoError(t, err) - got := findPredictResult(t, results, "svc") - require.NotNil(t, got.Predicted) - affinity, found, err := unstructured.NestedString(got.Predicted.Object, "spec", "sessionAffinity") - require.NoError(t, err) - require.True(t, found) - assert.Equal(t, string(corev1.ServiceAffinityNone), affinity) -} - -func (s *E2ESuite) TestPredictExternalAnnotationPreserved() { - t := s.T() - ns := fixtureNamespace("predict-ext-anno") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - cs, dyn := s.kubeClients(t) - ssaApplyOwnedConfigMap(t, dyn, ownedConfigMapUnstructured("app", ns, "web", "live"), kube.ManagedFieldsManager) - live, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) - require.NoError(t, err) - if live.Annotations == nil { - live.Annotations = map[string]string{} - } - live.Annotations["deployah.dev/external"] = "keep-me" - _, err = cs.CoreV1().ConfigMaps(ns).Update(t.Context(), live, metav1.UpdateOptions{ - FieldManager: "external-annotator", - }) - require.NoError(t, err) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - require.NotNil(t, got.Predicted) - assert.Equal(t, "keep-me", got.Predicted.GetAnnotations()["deployah.dev/external"]) -} - -func (s *E2ESuite) TestPredictOwnedAdoption() { - t := s.T() - ns := fixtureNamespace("predict-adopt") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - _, dyn := s.kubeClients(t) - ssaApplyOwnedConfigMap(t, dyn, ownedConfigMapUnstructured("app", ns, "web", "live"), kube.ManagedFieldsManager) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - assert.Equal(t, predict.ActionUpdate, got.Action) - assert.NotNil(t, got.Live) - assert.Empty(t, got.Limitation) -} - -func (s *E2ESuite) TestPredictDryRunDelete() { - t := s.T() - ns := fixtureNamespace("predict-dry-delete") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - cs, _ := s.kubeClients(t) - _, err := cs.CoreV1().ConfigMaps(ns).Create(t.Context(), &corev1.ConfigMap{ - Name: "old", - Namespace: ns, - Data: map[string]string{"key": "live"}, - }, metav1.CreateOptions{}) - require.NoError(t, err) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: ns, - Previous: predictConfigMapYAML("old", ns, "prev"), - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "old") - assert.Equal(t, predict.ActionDelete, got.Action) - - live, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "old", metav1.GetOptions{}) - require.NoError(t, err) - assert.Equal(t, "live", live.Data["key"]) -} - -func (s *E2ESuite) TestPredictManagedFieldsMigration() { - t := s.T() - ns := fixtureNamespace("predict-mf-migrate") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - cs, _ := s.kubeClients(t) - created, err := cs.CoreV1().ConfigMaps(ns).Create(t.Context(), &corev1.ConfigMap{ - Name: "app", - Namespace: ns, - Labels: map[string]string{"app.kubernetes.io/managed-by": "Helm"}, - Annotations: map[string]string{ - "meta.helm.sh/release-name": "web", - "meta.helm.sh/release-namespace": ns, - }, - Data: map[string]string{"key": "v1"}, - }, metav1.CreateOptions{}) - require.NoError(t, err) - created.Data["key"] = "v2" - updated, err := cs.CoreV1().ConfigMaps(ns).Update(t.Context(), created, metav1.UpdateOptions{ - FieldManager: kube.ManagedFieldsManager, - }) - require.NoError(t, err) - before, err := json.Marshal(updated.ManagedFields) - require.NoError(t, err) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "v2"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - assert.Equal(t, predict.LimitationManagedFieldsMigration, got.Limitation) - assert.Equal(t, predict.ActionUpdate, got.Action) - - afterLive, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) - require.NoError(t, err) - after, err := json.Marshal(afterLive.ManagedFields) - require.NoError(t, err) - assert.JSONEq(t, string(before), string(after)) -} - -func (s *E2ESuite) TestPredictManagedFieldsSSAAdoption() { - t := s.T() - ns := fixtureNamespace("predict-mf-ssa") - s.createNamespace(t, ns) - t.Cleanup(func() { s.deleteNamespace(t, ns) }) - - _, dyn := s.kubeClients(t) - ssaApplyOwnedConfigMap(t, dyn, ownedConfigMapUnstructured("app", ns, "web", "live"), kube.ManagedFieldsManager) - - cluster := s.predictCluster(t) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: ns, - Desired: predictConfigMapYAML("app", ns, "next"), - }) - require.NoError(t, err) - got := findPredictResult(t, results, "app") - assert.Empty(t, got.Limitation) - assert.Equal(t, predict.ActionUpdate, got.Action) -} diff --git a/internal/e2e/e2e_semantic_plan_test.go b/internal/e2e/e2e_semantic_plan_test.go index 77713f0..7a23415 100644 --- a/internal/e2e/e2e_semantic_plan_test.go +++ b/internal/e2e/e2e_semantic_plan_test.go @@ -18,6 +18,7 @@ package e2e_test import ( "context" + "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,6 +31,8 @@ import ( "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" + v1 "helm.sh/helm/v4/pkg/release/v1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -48,37 +51,110 @@ func (c *semanticPlanClient) RenderManifestsWithPrep( return c.result, c.prep, func() {}, nil } -func (s *E2ESuite) TestSemanticPlanPrerequisites() { +func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { t := s.T() - ns := fixtureNamespace("semantic-plan-prereq") - cluster := s.predictCluster(t) - client := &semanticPlanClient{ - result: &render.RenderResult{ - ReleaseName: "web", - Namespace: ns, - Manifest: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: " + ns + "\ndata:\n k: v\n", - Revision: 1, - }, - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, + resolved := &spec.ResolvedSpec{ + Spec: &spec.Spec{Project: "shop"}, + Env: spec.EnvIdentity{Original: "dev"}, } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, plan.SemanticBuildInput{ - ClusterContext: kindContext, - Resolved: &spec.ResolvedSpec{Spec: &spec.Spec{Project: "shop"}, Env: spec.EnvIdentity{Original: "dev"}}, - CRDs: []extras.RawFile{{Path: "widgets.yaml", Raw: []byte("not a crd\n")}}, + + t.Run("fresh install missing namespace", func(t *testing.T) { + ns := fixtureNamespace("semantic-plan-install") + manifest := configMapManifest(ns, "app", "v") + client := &semanticPlanClient{ + result: &render.RenderResult{ + ReleaseName: "web", + Namespace: ns, + Manifest: manifest, + Revision: 1, + }, + prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{ + ClusterContext: kindContext, + Resolved: resolved, + }) + require.NotNil(t, cleanup) + t.Cleanup(cleanup) + require.NoError(t, err) + + require.Len(t, p.Changes, 1) + assert.Equal(t, semantic.Create, p.Changes[0].Action) + assert.Equal(t, "ConfigMap", p.Changes[0].Resource.Kind) + assert.Equal(t, "app", p.Changes[0].Resource.Name) + for _, c := range p.Changes { + assert.NotEqual(t, "Namespace", c.Resource.Kind) + } + + cs, _ := s.kubeClients(t) + _, err = cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{}) + 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) }) - require.NotNil(t, cleanup) - t.Cleanup(cleanup) - require.NoError(t, err) - - require.GreaterOrEqual(t, len(p.Changes), 2) - assert.Equal(t, semantic.OriginNamespace, p.Changes[0].Origin.Kind) - assert.Equal(t, semantic.Create, p.Changes[0].Action) - assert.Equal(t, semantic.OriginHelm, p.Changes[1].Origin.Kind) - assert.Equal(t, "ConfigMap", p.Changes[1].Resource.Kind) - assert.Equal(t, semantic.Create, p.Changes[1].Action) - require.NotEmpty(t, p.Diagnostics) - - cs, _ := s.kubeClients(t) - _, err = cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{}) - require.True(t, apierrors.IsNotFound(err), "namespace %s should still be absent: %v", ns, err) + + t.Run("upgrade ignores live drift", func(t *testing.T) { + ns := fixtureNamespace("semantic-plan-upgrade") + manifest := configMapManifest(ns, "app", "same") + "---\n" + configMapManifest(ns, "other", "same") + cs, _ := 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"}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + } + live, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) + require.NoError(t, err) + live.Data["key"] = "drifted" + _, 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{})) + + release := &v1.Release{ + Name: "web", + Namespace: ns, + Manifest: manifest, + Version: 1, + } + client := &semanticPlanClient{ + result: &render.RenderResult{ + ReleaseName: "web", + Namespace: ns, + Manifest: manifest, + IsUpgrade: true, + Revision: 2, + }, + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: release, + Newest: release, + NextRevision: 2, + }, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{ + ClusterContext: kindContext, + Resolved: resolved, + }) + require.NotNil(t, cleanup) + t.Cleanup(cleanup) + 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"]) + _, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "other", metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "deleted configmap should stay absent: %v", err) + }) +} + +func configMapManifest(namespace, name, value string) string { + return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + + "\n namespace: " + namespace + "\ndata:\n key: " + value + "\n" } diff --git a/internal/plan/doc.go b/internal/plan/doc.go index 3a4f95a..09804f0 100644 --- a/internal/plan/doc.go +++ b/internal/plan/doc.go @@ -12,38 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package plan computes and renders read-only deployment plans. Planning -// inspects release changes and their consequences. It does not decide -// whether `deployah deploy` runs Helm. -// -// [BuildPlan] diffs the rendered manifest against the last successful -// Helm release. [BuildSemanticPlan] renders Desired state, derives the -// plan's Helm release-intent transition by comparing the previous release -// with that render, and records write consequences only when the plan has -// a Helm transition. -// -// [ComputeDiff] is the older diff: it parses two rendered Kubernetes -// manifests, matches resources by (apiVersion, kind, namespace, name), and -// runs [github.com/homeport/dyff] field by field on resources present on -// both sides. [Plan] is that model. [RenderText] prints it, and -// [NewJSONDocument] encodes it as JSON. [DeploymentIntent] records resize -// and hostname flags. It does not decide whether deploy runs Helm. -// [BuildPlan] and [ComputeDiff] stay while callers move to the semantic -// plan. +// Package plan computes and renders read-only deployment plans. +// Planning looks at release changes. It does not decide whether +// `deployah deploy` runs Helm. // // [BuildSemanticPlan] renders with -// [SemanticBuildClient.RenderManifestsWithPrep], then picks install, -// upgrade, or none from the Helm operation and a comparison of the -// previous release (prep.Current) with the rendered manifest and hooks. -// Live state and predictor output do not make that choice. If the plan -// has no Helm transition, Namespace and Helm write prediction are -// skipped, so the plan has no Helm writes. If the plan's transition is -// install or upgrade, Namespace prediction and -// [deployah.dev/deployah/internal/predict.Predict] still produce those -// writes. Chart CRDs are passed through to Helm. The result -// is a [deployah.dev/deployah/internal/plan/semantic.Plan]. Semantic types -// live in plan/semantic. New rendering lives in plan/view. +// [SemanticBuildClient.RenderManifestsWithPrep]. It picks install, +// 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. +// The result is a [deployah.dev/deployah/internal/plan/semantic.Plan]. +// Those types live in plan/semantic. Their rendering lives in plan/view. +// +// [BuildPlan] diffs the rendered manifest against the last successful +// Helm release. [ComputeDiff] is the older diff. It parses two rendered +// manifests, matches by apiVersion, kind, namespace, and name, and runs +// [github.com/homeport/dyff] on resources present on both sides. [Plan] +// is that model. [RenderText] prints it, and [NewJSONDocument] encodes +// it as JSON. [DeploymentIntent] records resize and hostname flags. It +// does not decide whether deploy runs Helm. [BuildPlan] and [ComputeDiff] +// remain while callers move to the semantic plan. // // Chart rendering is on [deployah.dev/deployah/internal/helm.Client]. -// `deployah plan` and `deployah deploy` share that engine. +// `deployah plan` and `deployah deploy` share it. package plan diff --git a/internal/plan/apply_order.go b/internal/plan/helm_order.go similarity index 58% rename from internal/plan/apply_order.go rename to internal/plan/helm_order.go index fd9fe81..5bdaff2 100644 --- a/internal/plan/apply_order.go +++ b/internal/plan/helm_order.go @@ -23,70 +23,62 @@ import ( "deployah.dev/deployah/internal/plan/semantic" ) -// stampHelmApplyOrder sets [semantic.ResourceChange.ApplyOrder] from Helm's -// exported kind ordering on OriginHelm changes only. Create, update, and -// replace use InstallOrder. Deletes use UninstallOrder and sort after -// applies. Identity remains the [semantic.New] tie-break. -func stampHelmApplyOrder(changes []semantic.ResourceChange) error { +// stampHelmOrder sets HelmOrder from Helm's deploy and upgrade sequence. +// Create and Update follow install kind order, and the same kind keeps +// input order. helm.sh/hook objects are not in that order. They take +// the next ranks, in slice order. Deletes come after, in Previous +// order, matching originals.Difference(targets), not uninstall order. +// The number does not say how Helm writes or deletes. +func stampHelmOrder(changes []semantic.ResourceChange) error { if len(changes) == 0 { return nil } applyFiles := make(map[string]string, len(changes)) - deleteFiles := make(map[string]string, len(changes)) applyIdx := make(map[string]int, len(changes)) - deleteIdx := make(map[string]int, len(changes)) + deleteIdx := make([]int, 0) for i, c := range changes { - if c.Origin.Kind != semantic.OriginHelm { - continue - } - obj := snapshotMap(c.After) if c.Action == semantic.Delete { - obj = snapshotMap(c.Before) + deleteIdx = append(deleteIdx, i) + continue } - raw, err := yaml.Marshal(obj) + raw, err := yaml.Marshal(snapshotMap(c.After)) if err != nil { return fmt.Errorf("encode %s for helm order: %w", c.Resource, err) } name := fmt.Sprintf("%010d.yaml", i) - if c.Action == semantic.Delete { - deleteFiles[name] = string(raw) - deleteIdx[name] = i - continue - } applyFiles[name] = string(raw) applyIdx[name] = i } order := 1 stamped := make(map[int]struct{}, len(changes)) - n, err := stampSorted(applyFiles, applyIdx, util.InstallOrder, changes, stamped, order) - if err != nil { - return err - } - order = n - n, err = stampSorted(deleteFiles, deleteIdx, util.UninstallOrder, changes, stamped, order) + n, err := stampSorted(applyFiles, applyIdx, changes, stamped, order) if err != nil { return err } order = n for i := range changes { - if _, ok := stamped[i]; ok { + if changes[i].Action == semantic.Delete { continue } - if changes[i].Origin.Kind != semantic.OriginHelm { + if _, ok := stamped[i]; ok { continue } - changes[i].ApplyOrder = order + changes[i].HelmOrder = order + order++ + } + for _, i := range deleteIdx { + changes[i].HelmOrder = order order++ } return nil } -func stampSorted(files map[string]string, idx map[string]int, ordering util.KindSortOrder, changes []semantic.ResourceChange, stamped map[int]struct{}, start int) (int, error) { +func stampSorted(files map[string]string, idx map[string]int, changes []semantic.ResourceChange, stamped map[int]struct{}, start int) (int, error) { if len(files) == 0 { return start, nil } - _, manifests, err := util.SortManifests(files, nil, ordering) + _, manifests, err := util.SortManifests(files, nil, util.InstallOrder) if err != nil { return start, fmt.Errorf("sort helm manifests: %w", err) } @@ -96,7 +88,7 @@ func stampSorted(files map[string]string, idx map[string]int, ordering util.Kind if !ok { continue } - changes[i].ApplyOrder = order + changes[i].HelmOrder = order stamped[i] = struct{}{} order++ } diff --git a/internal/plan/helm_order_test.go b/internal/plan/helm_order_test.go new file mode 100644 index 0000000..aae76de --- /dev/null +++ b/internal/plan/helm_order_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/plan/semantic" +) + +func TestStampHelmOrder(t *testing.T) { + t.Parallel() + kinds := []string{"Service", "ConfigMap", "Deployment", "Ingress", "Secret", "ServiceAccount"} + install := make([]semantic.ResourceChange, 0, len(kinds)) + for _, kind := range kinds { + api := "v1" + switch kind { + case "Deployment": + api = "apps/v1" + case "Ingress": + api = "networking.k8s.io/v1" + } + install = append(install, orderChange(api, kind, "app", semantic.Create)) + } + tests := []struct { + name string + changes []semantic.ResourceChange + want []string + }{ + { + name: "install order", + changes: install, + want: []string{ + "ServiceAccount/app/create", + "Secret/app/create", + "ConfigMap/app/create", + "Service/app/create", + "Deployment/app/create", + "Ingress/app/create", + }, + }, + { + name: "deletes follow targets", + changes: []semantic.ResourceChange{ + orderChange("v1", "ServiceAccount", "gone", semantic.Delete), + orderChange("networking.k8s.io/v1", "Ingress", "app", semantic.Create), + orderChange("v1", "ConfigMap", "app", semantic.Update), + }, + want: []string{ + "ConfigMap/app/update", + "Ingress/app/create", + "ServiceAccount/gone/delete", + }, + }, + { + name: "deletes keep previous order", + changes: []semantic.ResourceChange{ + orderChange("apps/v1", "Deployment", "api", semantic.Delete), + orderChange("v1", "Service", "api", semantic.Delete), + orderChange("v1", "ConfigMap", "api", semantic.Delete), + orderChange("v1", "ServiceAccount", "api", semantic.Delete), + }, + want: []string{ + "Deployment/api/delete", + "Service/api/delete", + "ConfigMap/api/delete", + "ServiceAccount/api/delete", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + changes := append([]semantic.ResourceChange(nil), tt.changes...) + require.NoError(t, stampHelmOrder(changes)) + assert.Equal(t, tt.want, orderByRank(changes)) + }) + } +} + +func TestStampHelmOrder_SameKindKeepsInputOrder(t *testing.T) { + t.Parallel() + const n = 4 + changes := make([]semantic.ResourceChange, 0, n) + for i := range n { + name := fmt.Sprintf("n%d", n-1-i) + changes = append(changes, orderChange("v1", "ConfigMap", name, semantic.Create)) + } + require.NoError(t, stampHelmOrder(changes)) + for i, c := range changes { + assert.Equal(t, i+1, c.HelmOrder) + assert.Equal(t, fmt.Sprintf("n%d", n-1-i), c.Resource.Name) + } +} + +func orderChange(api, kind, name string, action semantic.Action) semantic.ResourceChange { + obj := map[string]any{ + "apiVersion": api, + "kind": kind, + "metadata": map[string]any{"name": name, "namespace": "prod"}, + } + change := semantic.ResourceChange{ + Resource: semantic.ResourceRef{APIVersion: api, Kind: kind, Namespace: "prod", Name: name}, + Action: action, + } + snap := &semantic.ResourceSnapshot{Object: obj} + if action == semantic.Delete { + change.Before = snap + return change + } + change.After = snap + if action == semantic.Update { + change.Before = &semantic.ResourceSnapshot{Object: map[string]any{ + "apiVersion": api, + "kind": kind, + "metadata": map[string]any{"name": name, "namespace": "prod"}, + "data": map[string]any{"key": "old"}, + }} + change.Fields = []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "old", After: "new"}} + } + return change +} + +func orderByRank(changes []semantic.ResourceChange) []string { + ranked := append([]semantic.ResourceChange(nil), changes...) + for i := range ranked { + for j := i + 1; j < len(ranked); j++ { + if ranked[j].HelmOrder < ranked[i].HelmOrder { + ranked[i], ranked[j] = ranked[j], ranked[i] + } + } + } + out := make([]string, 0, len(ranked)) + for _, c := range ranked { + out = append(out, c.Resource.Kind+"/"+c.Resource.Name+"/"+c.Action.String()) + } + return out +} diff --git a/internal/plan/namespace.go b/internal/plan/namespace.go index 2bf7d40..abe24ea 100644 --- a/internal/plan/namespace.go +++ b/internal/plan/namespace.go @@ -15,96 +15,29 @@ package plan import ( - "context" "fmt" - "helm.sh/helm/v4/pkg/kube" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" ) -func predictNamespace(ctx context.Context, cluster predict.Cluster, op helm.Operation, name string) (*semantic.ResourceChange, bool, error) { - id := predict.Identity{Version: "v1", Kind: "Namespace", Name: name} - if op == helm.OperationUpgrade { - _, err := cluster.Get(ctx, id) - if apierrors.IsNotFound(err) { - return nil, false, fmt.Errorf("upgrade requires namespace %q to exist", name) - } - if err != nil { - return nil, false, fmt.Errorf("get namespace %s: %w", name, err) +// checkTargetNamespaceOwnership returns an error if objs include the +// release Namespace. side names the manifest. Helm install creates that +// namespace outside the release, so the release must not claim it. +// Other Namespace names stay. Nothing is read live or rewritten. +func checkTargetNamespaceOwnership(side string, objs []*unstructured.Unstructured, targetNamespace string) error { + for _, obj := range objs { + if !declaresTargetNamespace(obj, targetNamespace) { + continue } - return nil, false, nil - } - if op != helm.OperationInstall { - return nil, false, nil - } - - live, err := cluster.Get(ctx, id) - missing := apierrors.IsNotFound(err) - if missing { - live = nil - err = nil - } - if err != nil { - return nil, false, fmt.Errorf("get namespace %s: %w", name, err) - } - - desired := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": name, - "labels": map[string]any{"name": name}, - }, - }} - predicted, err := cluster.Apply(ctx, desired, predict.ApplyOptions{ - FieldManager: kube.ManagedFieldsManager, - ForceConflicts: false, - }) - if err != nil { - return nil, missing, fmt.Errorf("predict namespace %s: %w", name, err) - } - - ref := semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: name} - origin := semantic.ResourceOrigin{Kind: semantic.OriginNamespace} - if live == nil { - return &semantic.ResourceChange{ - Resource: ref, - Origin: origin, - Action: semantic.Create, - After: snapshotOf(predicted), - Apply: writeApply(), - ApplyOrder: 1, - }, true, nil + return fmt.Errorf("%s declares target namespace %q: the target namespace is an execution prerequisite created by helm install outside the release, and the release must not claim it", side, targetNamespace) } - if predict.EqualPredictedState(live, predicted) { - return nil, false, nil - } - return &semantic.ResourceChange{ - Resource: ref, - Origin: origin, - Action: semantic.Update, - Before: snapshotOf(live), - After: snapshotOf(predicted), - Apply: writeApply(), - ApplyOrder: 1, - }, false, nil + return nil } -func checkInstallNamespaceOverlap(manifest, namespace string) error { - objs, err := predict.FlattenManifest(manifest) - if err != nil { - return err +func declaresTargetNamespace(obj *unstructured.Unstructured, targetNamespace string) bool { + if obj == nil { + return false } - for _, obj := range objs { - if obj.GetAPIVersion() == "v1" && obj.GetKind() == "Namespace" && obj.GetName() == namespace { - return fmt.Errorf("install cannot plan target namespace %q: helm CreateNamespace and the rendered manifest both write it, and those sequential server-side applies cannot be composed", namespace) - } - } - return nil + gvk := obj.GroupVersionKind() + return gvk.Group == "" && gvk.Kind == "Namespace" && obj.GetName() == targetNamespace } diff --git a/internal/plan/namespace_test.go b/internal/plan/namespace_test.go index e89d917..ab7a78a 100644 --- a/internal/plan/namespace_test.go +++ b/internal/plan/namespace_test.go @@ -19,50 +19,83 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -func TestCheckInstallNamespaceOverlap_Errors(t *testing.T) { +func TestCheckTargetNamespaceOwnership(t *testing.T) { t.Parallel() - tests := []struct { - name string - manifest string - namespace string - wantErr string + ns := func(apiVersion, name string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiVersion, + "kind": "Namespace", + "metadata": map[string]any{"name": name}, + }} + } + rejected := []struct { + name string + side string + objs []*unstructured.Unstructured + target string + wantErr string }{ { - name: "bare target namespace", - manifest: "apiVersion: v1\nkind: Namespace\nmetadata:\n name: prod\n", - namespace: "prod", - wantErr: "cannot plan target namespace", - }, - { - name: "list item target namespace", - manifest: "apiVersion: v1\nkind: List\nitems:\n- apiVersion: v1\n kind: Namespace\n metadata:\n name: prod\n", - namespace: "prod", - wantErr: "cannot plan target namespace", + name: "core v1 target", + side: "rendered manifest", + objs: []*unstructured.Unstructured{ns("v1", "prod")}, + target: "prod", + wantErr: `rendered manifest declares target namespace "prod"`, }, { - name: "invalid yaml", - manifest: "not: [valid", - namespace: "prod", - wantErr: "parse manifest", + name: "other core version", + side: "previous release revision 3", + objs: []*unstructured.Unstructured{ns("v1beta1", "prod")}, + target: "prod", + wantErr: `previous release revision 3 declares target namespace "prod"`, }, } - for _, tt := range tests { + for _, tt := range rejected { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := checkInstallNamespaceOverlap(tt.manifest, tt.namespace) + err := checkTargetNamespaceOwnership(tt.side, tt.objs, tt.target) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) + assert.ErrorContains(t, err, "must not claim it") }) } -} -func TestCheckInstallNamespaceOverlap_OtherNamespace(t *testing.T) { - t.Parallel() - err := checkInstallNamespaceOverlap( - "apiVersion: v1\nkind: Namespace\nmetadata:\n name: extra\n", - "prod", - ) - require.NoError(t, err) + allowed := []struct { + name string + side string + objs []*unstructured.Unstructured + target string + }{ + { + name: "other name", + side: "rendered manifest", + objs: []*unstructured.Unstructured{ns("v1", "extra")}, + target: "prod", + }, + { + name: "grouped namespace is not the target", + side: "rendered manifest", + objs: []*unstructured.Unstructured{ns("example.com/v1", "prod")}, + target: "prod", + }, + } + for _, tt := range allowed { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := checkTargetNamespaceOwnership(tt.side, tt.objs, tt.target) + require.NoError(t, err) + }) + } + + t.Run("list is flattened by the caller", func(t *testing.T) { + t.Parallel() + flat, err := flattenManifest("apiVersion: v1\nkind: List\nitems:\n- apiVersion: v1\n kind: Namespace\n metadata:\n name: prod\n") + require.NoError(t, err) + err = checkTargetNamespaceOwnership("rendered manifest", flat, "prod") + require.Error(t, err) + assert.ErrorContains(t, err, "target namespace") + }) } diff --git a/internal/plan/prediction.go b/internal/plan/prediction.go deleted file mode 100644 index c3d65a2..0000000 --- a/internal/plan/prediction.go +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "fmt" - - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" -) - -func mapPredictResults(origin semantic.ResourceOrigin, results []predict.Result) ([]semantic.ResourceChange, []semantic.Diagnostic, error) { - changes := make([]semantic.ResourceChange, 0, len(results)) - diags := make([]semantic.Diagnostic, 0) - for i, r := range results { - change, diag, err := mapResult(origin, r) - if err != nil { - return nil, nil, fmt.Errorf("result %d: %w", i, err) - } - if diag != nil { - diags = append(diags, *diag) - } - if change != nil { - changes = append(changes, *change) - } - } - return changes, diags, nil -} - -func mapResult(origin semantic.ResourceOrigin, r predict.Result) (*semantic.ResourceChange, *semantic.Diagnostic, error) { - switch r.Action { - case predict.ActionNoOp: - if r.Limitation == "" { - return nil, nil, nil - } - ref := resourceRef(r.Identity, firstObject(r.Live, r.Predicted)) - d := limitationDiagnostic(ref, r.Limitation) - return nil, &d, nil - case predict.ActionCreate: - if r.Predicted == nil { - return nil, nil, fmt.Errorf("create requires a predicted object") - } - ref := resourceRef(r.Identity, r.Predicted) - change := semantic.ResourceChange{ - Resource: ref, - Origin: origin, - Action: semantic.Create, - After: snapshotOf(r.Predicted), - Apply: writeApply(), - } - return &change, limitationIfSet(ref, r.Limitation), nil - case predict.ActionUpdate: - if r.Live == nil { - return nil, nil, fmt.Errorf("update requires a live object") - } - ref := resourceRef(r.Identity, firstObject(r.Live, r.Predicted)) - change := semantic.ResourceChange{ - Resource: ref, - Origin: origin, - Action: semantic.Update, - Before: snapshotOf(r.Live), - Apply: writeApply(), - } - if r.Predicted != nil { - change.After = snapshotOf(r.Predicted) - } - return &change, limitationIfSet(ref, r.Limitation), nil - case predict.ActionDelete: - if r.Live == nil { - return nil, nil, fmt.Errorf("delete requires a live object") - } - ref := resourceRef(r.Identity, r.Live) - change := semantic.ResourceChange{ - Resource: ref, - Origin: origin, - Action: semantic.Delete, - Before: snapshotOf(r.Live), - Apply: deleteApply(), - } - return &change, limitationIfSet(ref, r.Limitation), nil - default: - return nil, nil, fmt.Errorf("invalid predict action %s", r.Action) - } -} - -func limitationIfSet(ref semantic.ResourceRef, limitation string) *semantic.Diagnostic { - if limitation == "" { - return nil - } - d := limitationDiagnostic(ref, limitation) - return &d -} - -func limitationDiagnostic(ref semantic.ResourceRef, limitation string) semantic.Diagnostic { - return semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: fmt.Sprintf("prediction is not exact: %s", limitation), - Resource: &ref, - } -} - -func resourceRef(id predict.Identity, obj *unstructured.Unstructured) semantic.ResourceRef { - gv := schema.GroupVersion{Group: id.Group, Version: id.Version} - ref := semantic.ResourceRef{ - APIVersion: gv.String(), - Kind: id.Kind, - Namespace: id.Namespace, - Name: id.Name, - } - if ref.Name == "" && obj != nil { - ref.GenerateName = obj.GetGenerateName() - } - return ref -} - -func firstObject(a, b *unstructured.Unstructured) *unstructured.Unstructured { - if a != nil { - return a - } - return b -} - -func snapshotOf(obj *unstructured.Unstructured) *semantic.ResourceSnapshot { - if obj == nil { - return nil - } - if obj.Object == nil { - return &semantic.ResourceSnapshot{} - } - // [semantic.New] copies the map. Passing Object here avoids - // runtime.DeepCopyJSON, which panics on ordinary Go int values. - return &semantic.ResourceSnapshot{Object: obj.Object} -} - -func writeApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{ - Method: semantic.WriteServerSide, - FieldManager: kube.ManagedFieldsManager, - ForceConflicts: false, - }, - } -} - -func deleteApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Delete: &semantic.DeleteSemantics{ - Propagation: semantic.PropagationBackground, - }, - } -} diff --git a/internal/plan/prediction_test.go b/internal/plan/prediction_test.go deleted file mode 100644 index a88ebf4..0000000 --- a/internal/plan/prediction_test.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" - - // Pin Helm's kube.ManagedFieldsManager to "deployah". - _ "deployah.dev/deployah/internal/helm" -) - -// semanticPlanFromResults maps predict results onto a semantic plan for -// tests. HelmAction is fixed from the header: install when FreshInstall -// is set, otherwise upgrade. It is not derived from the results. -func semanticPlanFromResults(header semantic.Header, results []predict.Result) (semantic.Plan, error) { - origin := semantic.ResourceOrigin{ - Kind: semantic.OriginHelm, - Helm: &semantic.HelmOrigin{ - Release: header.Release, - Namespace: header.Namespace, - }, - } - changes, diags, err := mapPredictResults(origin, results) - if err != nil { - return semantic.Plan{}, err - } - if orderErr := stampHelmApplyOrder(changes); orderErr != nil { - return semantic.Plan{}, orderErr - } - helmAction := semantic.HelmUpgrade - if header.FreshInstall { - helmAction = semantic.HelmInstall - } - return semantic.New(header, helmAction, changes, nil, diags) -} - -func TestSemanticPlanFromResults_ActionMapping(t *testing.T) { - t.Parallel() - header := semantic.Header{Release: "web", Namespace: "prod"} - live := predictionConfigMap("app", "prod", "old") - predicted := predictionConfigMap("app", "prod", "new") - - p, err := semanticPlanFromResults(header, []predict.Result{ - { - Identity: predictionIdentity("app"), - Action: predict.ActionCreate, - Predicted: predicted, - }, - { - Identity: predictionIdentity("keep"), - Action: predict.ActionNoOp, - Live: predictionConfigMap("keep", "prod", "same"), - Predicted: predictionConfigMap("keep", "prod", "same"), - }, - { - Identity: predictionIdentity("app-up"), - Action: predict.ActionUpdate, - Live: predictionConfigMap("app-up", "prod", "old"), - Predicted: predictionConfigMap("app-up", "prod", "new"), - }, - { - Identity: predictionIdentity("gone"), - Action: predict.ActionDelete, - Live: predictionConfigMap("gone", "prod", "old"), - }, - }) - require.NoError(t, err) - require.Len(t, p.Changes, 3) - assert.NotNil(t, p.Tasks) - assert.Empty(t, p.Tasks) - - byName := map[string]semantic.ResourceChange{} - for _, c := range p.Changes { - byName[c.Resource.Name] = c - } - require.Contains(t, byName, "app") - assert.Equal(t, semantic.Create, byName["app"].Action) - assert.Nil(t, byName["app"].Before) - require.NotNil(t, byName["app"].After) - assert.NotNil(t, byName["app"].Apply.Write) - assert.Equal(t, kube.ManagedFieldsManager, byName["app"].Apply.Write.FieldManager) - assert.NotEmpty(t, byName["app"].Apply.Write.FieldManager) - assert.False(t, byName["app"].Apply.Write.ForceConflicts) - assert.Nil(t, byName["app"].Apply.Delete) - - require.Contains(t, byName, "app-up") - assert.Equal(t, semantic.Update, byName["app-up"].Action) - require.NotNil(t, byName["app-up"].Before) - require.NotNil(t, byName["app-up"].After) - assert.NotNil(t, byName["app-up"].Apply.Write) - assert.Nil(t, byName["app-up"].Apply.Delete) - - require.Contains(t, byName, "gone") - assert.Equal(t, semantic.Delete, byName["gone"].Action) - require.NotNil(t, byName["gone"].Before) - assert.Nil(t, byName["gone"].After) - assert.Nil(t, byName["gone"].Apply.Write) - require.NotNil(t, byName["gone"].Apply.Delete) - assert.Equal(t, semantic.PropagationBackground, byName["gone"].Apply.Delete.Propagation) - - assert.Equal(t, "old", predictionObjectString(t, live.Object, "data", "key")) - assert.Equal(t, "new", predictionObjectString(t, predicted.Object, "data", "key")) -} - -func TestSemanticPlanFromResults_NoOpOmitted(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionNoOp, - Live: predictionConfigMap("app", "prod", "same"), - Predicted: predictionConfigMap("app", "prod", "same"), - }}) - require.NoError(t, err) - assert.Empty(t, p.Changes) -} - -func TestSemanticPlanFromResults_LimitationKeepsPredicted(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionUpdate, - Live: predictionConfigMap("app", "prod", "old"), - Predicted: predictionConfigMap("app", "prod", "new"), - Limitation: predict.LimitationManagedFieldsMigration, - }}) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - assert.Equal(t, semantic.Update, p.Changes[0].Action) - require.NotNil(t, p.Changes[0].After) - assert.Equal(t, "new", predictionObjectString(t, p.Changes[0].After.Object, "data", "key")) - require.Len(t, p.Diagnostics, 1) - assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) - assert.Contains(t, p.Diagnostics[0].Message, predict.LimitationManagedFieldsMigration) -} - -func TestSemanticPlanFromResults_LimitationDoesNotFabricateAfter(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionUpdate, - Live: predictionConfigMap("app", "prod", "old"), - Limitation: predict.LimitationManagedFieldsMigration, - }}) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - assert.Nil(t, p.Changes[0].After) - assert.Empty(t, p.Changes[0].Fields) - require.Len(t, p.Diagnostics, 1) - assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) -} - -func TestSemanticPlanFromResults_UnknownAction(t *testing.T) { - t.Parallel() - _, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - }}) - require.Error(t, err) - assert.ErrorContains(t, err, "invalid predict action") -} - -func TestSemanticPlanFromResults_CreateRequiresPredicted(t *testing.T) { - t.Parallel() - _, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionCreate, - }}) - require.Error(t, err) - assert.ErrorContains(t, err, "create requires a predicted object") -} - -func TestSemanticPlanFromResults_DoesNotMutateResults(t *testing.T) { - t.Parallel() - live := predictionConfigMap("app", "prod", "old") - predicted := predictionConfigMap("app", "prod", "new") - results := []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionUpdate, - Live: live, - Predicted: predicted, - }} - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, results) - require.NoError(t, err) - - results[0].Action = predict.ActionDelete - setPredictionObjectString(t, live.Object, "mutated", "data", "key") - setPredictionObjectString(t, predicted.Object, "mutated", "data", "key") - - assert.Equal(t, semantic.Update, p.Changes[0].Action) - assert.Equal(t, "old", predictionObjectString(t, p.Changes[0].Before.Object, "data", "key")) - assert.Equal(t, "new", predictionObjectString(t, p.Changes[0].After.Object, "data", "key")) -} - -func TestSemanticPlanFromResults_GoIntInObject(t *testing.T) { - t.Parallel() - predicted := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": map[string]any{ - "name": "web", - "namespace": "prod", - }, - "spec": map[string]any{"replicas": 3}, - }} - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predict.Identity{Group: "apps", Version: "v1", Kind: "Deployment", Namespace: "prod", Name: "web"}, - Action: predict.ActionCreate, - Predicted: predicted, - }}) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - require.NotNil(t, p.Changes[0].After) - require.NotNil(t, p.Changes[0].Apply.Write) - assert.Equal(t, "apps/v1", p.Changes[0].Resource.APIVersion) - spec, ok := p.Changes[0].After.Object["spec"].(map[string]any) - require.True(t, ok) - assert.Equal(t, 3, spec["replicas"]) - assert.Equal(t, kube.ManagedFieldsManager, p.Changes[0].Apply.Write.FieldManager) - assert.NotEmpty(t, p.Changes[0].Apply.Write.FieldManager) - - callerSpec, ok := predicted.Object["spec"].(map[string]any) - require.True(t, ok) - callerSpec["replicas"] = 9 - spec, ok = p.Changes[0].After.Object["spec"].(map[string]any) - require.True(t, ok) - assert.Equal(t, 3, spec["replicas"]) -} - -func TestSemanticPlanFromResults_GenerateName(t *testing.T) { - t.Parallel() - obj := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "generateName": "app-", - "namespace": "prod", - }, - "data": map[string]any{"key": "v1"}, - }} - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod"}, - Action: predict.ActionCreate, - Predicted: obj, - }}) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - assert.Empty(t, p.Changes[0].Resource.Name) - assert.Equal(t, "app-", p.Changes[0].Resource.GenerateName) -} - -func TestSemanticPlanFromResults_NoOpWithLimitation(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionNoOp, - Predicted: predictionConfigMap("app", "prod", "same"), - Limitation: predict.LimitationManagedFieldsMigration, - }}) - require.NoError(t, err) - assert.Empty(t, p.Changes) - require.Len(t, p.Diagnostics, 1) - assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) - assert.Contains(t, p.Diagnostics[0].Message, predict.LimitationManagedFieldsMigration) - assert.Equal(t, "app", p.Diagnostics[0].Resource.Name) -} - -func TestSemanticPlanFromResults_UpdateRequiresLive(t *testing.T) { - t.Parallel() - _, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionUpdate, - Predicted: predictionConfigMap("app", "prod", "new"), - }}) - require.Error(t, err) - assert.ErrorContains(t, err, "update requires a live object") -} - -func TestSemanticPlanFromResults_DeleteRequiresLive(t *testing.T) { - t.Parallel() - _, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionDelete, - }}) - require.Error(t, err) - assert.ErrorContains(t, err, "delete requires a live object") -} - -func TestSemanticPlanFromResults_NilObjectSnapshot(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{{ - Identity: predictionIdentity("app"), - Action: predict.ActionCreate, - Predicted: &unstructured.Unstructured{}, - }}) - require.NoError(t, err) - require.Len(t, p.Changes, 1) - require.NotNil(t, p.Changes[0].After) - assert.Nil(t, p.Changes[0].After.Object) -} - -func predictionObjectString(tb testing.TB, obj map[string]any, keys ...string) string { - tb.Helper() - var cur any = obj - for _, key := range keys { - m, ok := cur.(map[string]any) - require.True(tb, ok) - cur, ok = m[key] - require.True(tb, ok) - } - s, ok := cur.(string) - require.True(tb, ok) - return s -} - -func setPredictionObjectString(tb testing.TB, obj map[string]any, value string, keys ...string) { - tb.Helper() - require.NotEmpty(tb, keys) - cur := obj - for _, key := range keys[:len(keys)-1] { - next, ok := cur[key].(map[string]any) - require.True(tb, ok) - cur = next - } - cur[keys[len(keys)-1]] = value -} - -func predictionIdentity(name string) predict.Identity { - return predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: name} -} - -func predictionConfigMap(name, ns, value string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": ns, - }, - "data": map[string]any{"key": value}, - }} -} diff --git a/internal/plan/prereq_cluster.go b/internal/plan/prereq_cluster.go deleted file mode 100644 index 4e28f2a..0000000 --- a/internal/plan/prereq_cluster.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "context" - - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -const limitationTargetNamespace = "target namespace is created earlier in this deployment" - -type prereqCluster struct { - inner predict.Cluster - missingTargetNS bool - targetNS string -} - -func newPrereqCluster(inner predict.Cluster, missingTargetNS bool, targetNS string) *prereqCluster { - return &prereqCluster{ - inner: inner, - missingTargetNS: missingTargetNS, - targetNS: targetNS, - } -} - -var _ predict.Cluster = (*prereqCluster)(nil) - -func (c *prereqCluster) Get(ctx context.Context, id predict.Identity) (*unstructured.Unstructured, error) { - if c.missingTargetNS && id.Namespace != "" && id.Namespace == c.targetNS { - return nil, apierrors.NewNotFound(schema.GroupResource{Group: id.Group, Resource: id.Kind}, id.Name) - } - return c.inner.Get(ctx, id) -} - -func (c *prereqCluster) Create(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return c.inner.Create(ctx, obj) -} - -func (c *prereqCluster) Apply(ctx context.Context, obj *unstructured.Unstructured, opts predict.ApplyOptions) (*unstructured.Unstructured, error) { - if c.needsSyntheticApply(obj) { - return obj.DeepCopy(), nil - } - return c.inner.Apply(ctx, obj, opts) -} - -func (c *prereqCluster) JSONPatch(ctx context.Context, id predict.Identity, patch []byte) error { - return c.inner.JSONPatch(ctx, id, patch) -} - -func (c *prereqCluster) Delete(ctx context.Context, id predict.Identity) error { - return c.inner.Delete(ctx, id) -} - -func (c *prereqCluster) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { - return c.inner.Mapping(gvk) -} - -func (c *prereqCluster) needsSyntheticApply(obj *unstructured.Unstructured) bool { - return c.namespacedInMissingTarget(obj) -} - -func limitationDiagnostics(c *prereqCluster, results []predict.Result) []semantic.Diagnostic { - if c == nil { - return nil - } - var diags []semantic.Diagnostic - for _, r := range results { - obj := firstObject(r.Predicted, r.Live) - if obj == nil || !c.needsSyntheticApply(obj) { - continue - } - diags = append(diags, limitationDiagnostic(resourceRef(r.Identity, obj), limitationTargetNamespace)) - } - return diags -} - -func (c *prereqCluster) namespacedInMissingTarget(obj *unstructured.Unstructured) bool { - if obj == nil || !c.missingTargetNS || obj.GetNamespace() != c.targetNS { - return false - } - mapping, err := c.inner.Mapping(obj.GroupVersionKind()) - return err == nil && mapping.Scope.Name() == meta.RESTScopeNameNamespace -} diff --git a/internal/plan/prereq_cluster_test.go b/internal/plan/prereq_cluster_test.go deleted file mode 100644 index 6b2b197..0000000 --- a/internal/plan/prereq_cluster_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan - -import ( - "context" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -type prereqFake struct { - gets []predict.Identity - applies []*unstructured.Unstructured -} - -func (f *prereqFake) Get(_ context.Context, id predict.Identity) (*unstructured.Unstructured, error) { - f.gets = append(f.gets, id) - return nil, apierrors.NewNotFound(schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, id.Name) -} - -func (f *prereqFake) Create(_ context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return obj.DeepCopy(), nil -} - -func (f *prereqFake) Apply(_ context.Context, obj *unstructured.Unstructured, _ predict.ApplyOptions) (*unstructured.Unstructured, error) { - f.applies = append(f.applies, obj.DeepCopy()) - return obj.DeepCopy(), nil -} - -func (f *prereqFake) JSONPatch(context.Context, predict.Identity, []byte) error { return nil } - -func (f *prereqFake) Delete(context.Context, predict.Identity) error { return nil } - -func (f *prereqFake) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { - scope := meta.RESTScopeNamespace - if gvk.Kind == "Namespace" && gvk.Group == "" { - scope = meta.RESTScopeRoot - } - return &meta.RESTMapping{ - Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"}, - GroupVersionKind: gvk, - Scope: scope, - }, nil -} - -func TestPrereqCluster_GenerateNameApplyWithoutGet(t *testing.T) { - t.Parallel() - inner := &prereqFake{} - wrapped := newPrereqCluster(inner, true, "prod") - obj := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "generateName": "app-", - "namespace": "prod", - }, - }} - require.True(t, wrapped.needsSyntheticApply(obj)) - got, err := wrapped.Apply(t.Context(), obj, predict.ApplyOptions{}) - require.NoError(t, err) - assert.Equal(t, "app-", got.GetGenerateName()) - assert.Empty(t, inner.applies) - assert.Empty(t, inner.gets) -} - -func TestPrereqCluster_MissingTargetNamespaceGetIsNotFound(t *testing.T) { - t.Parallel() - inner := &prereqFake{} - wrapped := newPrereqCluster(inner, true, "prod") - _, err := wrapped.Get(t.Context(), predict.Identity{Version: "v1", Kind: "ConfigMap", Name: "app", Namespace: "prod"}) - require.Error(t, err) - assert.True(t, apierrors.IsNotFound(err)) - assert.Empty(t, inner.gets) -} diff --git a/internal/plan/resource_changes.go b/internal/plan/resource_changes.go new file mode 100644 index 0000000..ee6a7d3 --- /dev/null +++ b/internal/plan/resource_changes.go @@ -0,0 +1,313 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/resource" + + "deployah.dev/deployah/internal/plan/semantic" +) + +// logicalIdentity pairs a named resource by group, kind, effective +// namespace, and name. API version is left out. generateName has none. +type logicalIdentity struct { + Group string + Kind string + Namespace string + Name string +} + +func (id logicalIdentity) String() string { + if id.Group == "" { + return id.Kind + " " + id.Namespace + "/" + id.Name + } + return id.Group + "/" + id.Kind + " " + id.Namespace + "/" + id.Name +} + +// declarationKey pairs a nameless generateName resource. It is not +// logical identity, and it cannot find a live object. +type declarationKey struct { + Group string + Kind string + Namespace string + GenerateName string +} + +func (k declarationKey) String() string { + if k.Group == "" { + return k.Kind + " " + k.Namespace + "/generateName=" + k.GenerateName + } + return k.Group + "/" + k.Kind + " " + k.Namespace + "/generateName=" + k.GenerateName +} + +// declaration is one flattened Previous or Desired object. Pairing uses +// effectiveNamespace and does not write it back. Named resources set +// identity. generateName resources set pairKey. +type declaration struct { + obj *unstructured.Unstructured + effectiveNamespace string + namespaced bool + identity *logicalIdentity + pairKey *declarationKey +} + +func (d declaration) matchKey() string { + if d.identity != nil { + id := d.identity + return "id\x00" + id.Group + "\x00" + id.Kind + "\x00" + id.Namespace + "\x00" + id.Name + } + k := d.pairKey + return "gen\x00" + k.Group + "\x00" + k.Kind + "\x00" + k.Namespace + "\x00" + k.GenerateName +} + +func flattenManifest(manifest string) ([]*unstructured.Unstructured, error) { + if strings.TrimSpace(manifest) == "" { + return nil, nil + } + infos, err := resource.NewLocalBuilder(). + ContinueOnError(). + Flatten(). + Unstructured(). + Stream(bytes.NewBufferString(manifest), "manifest"). + Do().Infos() + if err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + out := make([]*unstructured.Unstructured, 0, len(infos)) + for _, info := range infos { + if u, ok := info.Object.(*unstructured.Unstructured); ok { + out = append(out, u.DeepCopy()) + continue + } + m, convErr := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + if convErr != nil { + return nil, fmt.Errorf("convert object to unstructured: %w", convErr) + } + out = append(out, &unstructured.Unstructured{Object: m}) + } + return out, nil +} + +// generateNameState reports whether obj is a generateName resource. +// Name and generateName together is an error. Both empty is an error. +func generateNameState(obj *unstructured.Unstructured) (bool, error) { + name := obj.GetName() + generateName := obj.GetGenerateName() + if name == "" && generateName != "" { + return true, nil + } + if name != "" && generateName != "" { + return false, errors.New("metadata.name and metadata.generateName cannot both be set") + } + if name == "" { + return false, errors.New("resource requires metadata.name or metadata.generateName") + } + return false, nil +} + +func declareAll(mapper RESTMapper, objs []*unstructured.Unstructured, releaseNamespace, side string) ([]declaration, error) { + out := make([]declaration, 0, len(objs)) + seenID := make(map[logicalIdentity]struct{}, len(objs)) + seenGen := make(map[declarationKey]struct{}) + for _, obj := range objs { + d, loadErr := declareOne(mapper, obj, releaseNamespace, side) + if loadErr != nil { + return nil, loadErr + } + if d.identity != nil { + if _, ok := seenID[*d.identity]; ok { + return nil, fmt.Errorf("%s: duplicate resource %s", side, d.identity) + } + seenID[*d.identity] = struct{}{} + } else { + if _, ok := seenGen[*d.pairKey]; ok { + return nil, fmt.Errorf("%s: ambiguous generateName pairing %s", side, d.pairKey) + } + seenGen[*d.pairKey] = struct{}{} + } + out = append(out, d) + } + return out, nil +} + +func declareOne(mapper RESTMapper, obj *unstructured.Unstructured, releaseNamespace, side string) (declaration, error) { + genOnly, err := generateNameState(obj) + if err != nil { + return declaration{}, fmt.Errorf("%s: %s: %w", side, resourceLabel(obj), err) + } + gvk := obj.GroupVersionKind() + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return declaration{}, fmt.Errorf("%s: resolve resource mapping for %s %s: %w", side, gvk, resourceLabel(obj), err) + } + if mapping == nil || mapping.Scope == nil { + return declaration{}, fmt.Errorf("%s: resolve resource mapping for %s %s: missing scope", side, gvk, resourceLabel(obj)) + } + namespaced := mapping.Scope.Name() == meta.RESTScopeNameNamespace + effective := "" + if namespaced { + effective = obj.GetNamespace() + if effective == "" { + effective = releaseNamespace + } + } + d := declaration{ + obj: obj, + effectiveNamespace: effective, + namespaced: namespaced, + } + if genOnly { + d.pairKey = &declarationKey{ + Group: gvk.Group, + Kind: gvk.Kind, + Namespace: effective, + GenerateName: obj.GetGenerateName(), + } + return d, nil + } + d.identity = &logicalIdentity{ + Group: gvk.Group, + Kind: gvk.Kind, + Namespace: effective, + Name: obj.GetName(), + } + return d, nil +} + +func resourceLabel(obj *unstructured.Unstructured) string { + name := obj.GetName() + if name == "" { + name = "generateName=" + obj.GetGenerateName() + } + ns := obj.GetNamespace() + if ns == "" { + return obj.GetKind() + "/" + name + } + return obj.GetKind() + " " + ns + "/" + name +} + +// comparisonCopy deep-copies an object for field comparison. If a +// namespaced object omitted its namespace, the copy gets the effective +// one. A cluster-scoped namespace stays as declared. +func comparisonCopy(d declaration) map[string]any { + cp := d.obj.DeepCopy() + if d.namespaced && cp.GetNamespace() == "" { + cp.SetNamespace(d.effectiveNamespace) + } + if cp.Object == nil { + return map[string]any{} + } + return cp.Object +} + +// diffDeclared pairs Previous with Desired. Creates and updates keep +// Desired order. Deletes follow, in Previous order. +func diffDeclared(previous, desired []declaration) ([]semantic.ResourceChange, error) { + prevBy := make(map[string]declaration, len(previous)) + for _, d := range previous { + prevBy[d.matchKey()] = d + } + used := make(map[string]struct{}, len(previous)) + changes := make([]semantic.ResourceChange, 0) + for _, d := range desired { + key := d.matchKey() + prev, ok := prevBy[key] + if !ok { + changes = append(changes, createChange(d)) + continue + } + used[key] = struct{}{} + change, changed, diffErr := updateIfDifferent(prev, d) + if diffErr != nil { + return nil, diffErr + } + if changed { + changes = append(changes, change) + } + } + for _, d := range previous { + if _, ok := used[d.matchKey()]; ok { + continue + } + changes = append(changes, deleteChange(d)) + } + return changes, nil +} + +func createChange(d declaration) semantic.ResourceChange { + return semantic.ResourceChange{ + Resource: resourceRef(d), + Action: semantic.Create, + After: snapshotOf(d.obj), + } +} + +func deleteChange(d declaration) semantic.ResourceChange { + return semantic.ResourceChange{ + Resource: resourceRef(d), + Action: semantic.Delete, + Before: snapshotOf(d.obj), + } +} + +func updateIfDifferent(prev, desired declaration) (semantic.ResourceChange, bool, error) { + fields, err := semantic.DiffFields(comparisonCopy(prev), comparisonCopy(desired)) + if err != nil { + return semantic.ResourceChange{}, false, fmt.Errorf("diff %s: %w", resourceRef(desired), err) + } + if len(fields) == 0 { + return semantic.ResourceChange{}, false, nil + } + return semantic.ResourceChange{ + Resource: resourceRef(desired), + Action: semantic.Update, + Before: snapshotOf(prev.obj), + After: snapshotOf(desired.obj), + Fields: fields, + }, true, nil +} + +func resourceRef(d declaration) semantic.ResourceRef { + ref := semantic.ResourceRef{ + APIVersion: d.obj.GetAPIVersion(), + Kind: d.obj.GetKind(), + Namespace: d.effectiveNamespace, + Name: d.obj.GetName(), + } + if ref.Name == "" { + ref.GenerateName = d.obj.GetGenerateName() + } + return ref +} + +func snapshotOf(obj *unstructured.Unstructured) *semantic.ResourceSnapshot { + if obj == nil { + return nil + } + if obj.Object == nil { + return &semantic.ResourceSnapshot{} + } + // [semantic.New] copies the map. Passing Object here avoids + // runtime.DeepCopyJSON, which panics on ordinary Go int values. + return &semantic.ResourceSnapshot{Object: obj.Object} +} diff --git a/internal/plan/resource_changes_test.go b/internal/plan/resource_changes_test.go new file mode 100644 index 0000000..a8ffebb --- /dev/null +++ b/internal/plan/resource_changes_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "deployah.dev/deployah/internal/plan/semantic" +) + +func TestLoadDeclared_IdentityAndPairingKey(t *testing.T) { + t.Parallel() + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: app + namespace: prod +--- +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: job- + namespace: prod +` + objs, err := flattenManifest(manifest) + require.NoError(t, err) + decls, err := declareAll(testRESTMapper{}, objs, "prod", "rendered manifest") + require.NoError(t, err) + require.Len(t, decls, 2) + + assert.NotNil(t, decls[0].identity) + assert.Nil(t, decls[0].pairKey) + assert.Equal(t, "app", decls[0].identity.Name) + assert.Equal(t, "prod", decls[0].effectiveNamespace) + assert.Equal(t, "prod", decls[0].obj.GetNamespace()) + + assert.Nil(t, decls[1].identity) + assert.NotNil(t, decls[1].pairKey) + assert.Equal(t, "job-", decls[1].pairKey.GenerateName) + assert.Equal(t, "prod", decls[1].pairKey.Namespace) +} + +func TestLoadDeclared_EffectiveNamespaceDoesNotMutateObject(t *testing.T) { + t.Parallel() + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: app +data: + key: v1 +` + objs, err := flattenManifest(manifest) + require.NoError(t, err) + decls, err := declareAll(testRESTMapper{}, objs, "prod", "rendered manifest") + require.NoError(t, err) + require.Len(t, decls, 1) + assert.Equal(t, "prod", decls[0].effectiveNamespace) + assert.Empty(t, decls[0].obj.GetNamespace()) + _, found, nestErr := unstructured.NestedFieldNoCopy(decls[0].obj.Object, "metadata", "namespace") + require.NoError(t, nestErr) + assert.False(t, found) +} + +func TestComparisonCopy_OnlyFillsOmittedNamespacedNamespace(t *testing.T) { + t.Parallel() + tests := []struct { + name string + manifest string + wantCopyNS string + wantObjectNS string + wantEffective string + }{ + { + name: "omitted namespaced namespace", + manifest: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\ndata:\n key: v1\n", + wantCopyNS: "prod", + wantEffective: "prod", + }, + { + name: "cluster scoped namespace stays declared", + manifest: `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: view + namespace: stray +rules: [] +`, + wantCopyNS: "stray", + wantObjectNS: "stray", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + objs, err := flattenManifest(tt.manifest) + require.NoError(t, err) + decls, err := declareAll(testRESTMapper{}, objs, "prod", "rendered manifest") + require.NoError(t, err) + require.Len(t, decls, 1) + copied := comparisonCopy(decls[0]) + ns, found, nestErr := unstructured.NestedString(copied, "metadata", "namespace") + require.NoError(t, nestErr) + require.True(t, found) + assert.Equal(t, tt.wantCopyNS, ns) + assert.Equal(t, tt.wantObjectNS, decls[0].obj.GetNamespace()) + assert.Equal(t, tt.wantEffective, decls[0].effectiveNamespace) + }) + } +} + +func TestDiffDeclared_ListAndOrder(t *testing.T) { + t.Parallel() + previous := `apiVersion: v1 +kind: List +items: +- apiVersion: v1 + kind: ConfigMap + metadata: + name: b + namespace: prod + data: + key: old +- apiVersion: v1 + kind: ConfigMap + metadata: + name: a + namespace: prod +` + desired := `apiVersion: v1 +kind: ConfigMap +metadata: + name: a + namespace: prod +data: + key: new +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: c + namespace: prod +` + prevObjs, err := flattenManifest(previous) + require.NoError(t, err) + prevDecls, err := declareAll(testRESTMapper{}, prevObjs, "prod", "previous release") + require.NoError(t, err) + desiredObjs, err := flattenManifest(desired) + require.NoError(t, err) + desiredDecls, err := declareAll(testRESTMapper{}, desiredObjs, "prod", "rendered manifest") + require.NoError(t, err) + changes, err := diffDeclared(prevDecls, desiredDecls) + require.NoError(t, err) + require.Len(t, changes, 3) + assert.Equal(t, semantic.Update, changes[0].Action) + assert.Equal(t, "a", changes[0].Resource.Name) + assert.Equal(t, semantic.Create, changes[1].Action) + assert.Equal(t, "c", changes[1].Resource.Name) + assert.Equal(t, semantic.Delete, changes[2].Action) + assert.Equal(t, "b", changes[2].Resource.Name) +} + +type testRESTMapper struct{} + +func (testRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + version := "" + if len(versions) > 0 { + version = versions[0] + } + scope := meta.RESTScopeNamespace + if gk.Kind == "ClusterRole" { + scope = meta.RESTScopeRoot + } + return &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: gk.Group, Version: version, Resource: "items"}, + GroupVersionKind: gk.WithVersion(version), + Scope: scope, + }, nil +} diff --git a/internal/plan/semantic/change.go b/internal/plan/semantic/change.go index f15b6a3..6d3f2e1 100644 --- a/internal/plan/semantic/change.go +++ b/internal/plan/semantic/change.go @@ -14,18 +14,24 @@ package semantic -// ResourceChange describes a visible Kubernetes resource consequence -// of this invocation. +// ResourceChange is one declarative difference between Previous and +// Desired for a resource in the Helm release [Header] names. type ResourceChange struct { Resource ResourceRef - Origin ResourceOrigin Action Action - Before *ResourceSnapshot - After *ResourceSnapshot - Fields []FieldChange - Apply ApplySemantics - // ApplyOrder is origin-local apply and presentation rank. Lower is - // earlier. The assembler stamps it; [New] sorts by origin rank then - // ApplyOrder then identity. It is not a JSON field. - ApplyOrder int + // Before is the Previous declaration. It is unset for [Create]. + Before *ResourceSnapshot + // After is the Desired declaration. It is unset for [Delete]. + After *ResourceSnapshot + // Fields are the semantic differences the caller computed. [New] + // keeps them; it does not recompute them from the snapshots. + Fields []FieldChange + // HelmOrder is where this change sits in Helm's deploy or upgrade + // order. A lower number comes first. Create and Update follow + // Helm's install kind order, and equal kinds keep manifest order. + // Deletes come after those, in Previous release order. The planner + // sets it. [New] sorts by HelmOrder, then by the resource + // reference. It is not written to JSON. It does not say how Helm + // writes or deletes. + HelmOrder int } diff --git a/internal/plan/semantic/chart_crd_test.go b/internal/plan/semantic/chart_crd_test.go index 6fd33a1..6e1a659 100644 --- a/internal/plan/semantic/chart_crd_test.go +++ b/internal/plan/semantic/chart_crd_test.go @@ -26,7 +26,7 @@ import ( func TestAttachChartCRDs_Lifecycle(t *testing.T) { t.Parallel() - base, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, nil, nil, nil) + base, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, nil, nil) require.NoError(t, err) changesBefore := len(base.Changes) tests := []struct { @@ -62,7 +62,7 @@ func TestAttachChartCRDs_Lifecycle(t *testing.T) { func TestAttachChartCRDs_PreservesOrder(t *testing.T) { t.Parallel() - base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil, nil) + base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil) require.NoError(t, err) p, err := semantic.AttachChartCRDs(base, []semantic.ChartCRD{ {Kind: "CustomResourceDefinition", Name: "one.example.com", Lifecycle: semantic.ChartCRDUpgrade}, @@ -78,7 +78,7 @@ func TestAttachChartCRDs_PreservesOrder(t *testing.T) { func TestAttachChartCRDs_DoesNotChangeHasEffects(t *testing.T) { t.Parallel() - base, err := semantic.New(semantic.Header{}, semantic.HelmNone, nil, nil, nil) + base, err := semantic.New(semantic.Header{}, semantic.HelmNone, nil, nil) require.NoError(t, err) require.True(t, base.IsNoOp()) p, err := semantic.AttachChartCRDs(base, []semantic.ChartCRD{{ @@ -94,7 +94,7 @@ func TestAttachChartCRDs_DoesNotChangeHasEffects(t *testing.T) { func TestAttachChartCRDs_Validation(t *testing.T) { t.Parallel() - base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil, nil) + base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil) require.NoError(t, err) tests := []struct { name string @@ -121,7 +121,7 @@ func TestAttachChartCRDs_Validation(t *testing.T) { func TestChartCRD_HasNoMutationFields(t *testing.T) { t.Parallel() rt := reflect.TypeFor[semantic.ChartCRD]() - for _, name := range []string{"APIVersion", "Action", "Origin", "Apply", "Before", "After", "Fields", "Namespace", "Scope", "Spec"} { + for _, name := range []string{"APIVersion", "Action", "Before", "After", "Fields", "Namespace", "Scope", "Spec"} { _, has := rt.FieldByName(name) assert.False(t, has, name) } diff --git a/internal/plan/semantic/diagnostic.go b/internal/plan/semantic/diagnostic.go deleted file mode 100644 index d83497c..0000000 --- a/internal/plan/semantic/diagnostic.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package semantic - -import "fmt" - -// DiagnosticSeverity is the severity of a [Diagnostic]. The zero value is -// invalid. -type DiagnosticSeverity int - -const ( - // DiagnosticWarning is a non-fatal plan note. - DiagnosticWarning DiagnosticSeverity = iota + 1 -) - -func (s DiagnosticSeverity) String() string { - switch s { - case DiagnosticWarning: - return "warning" - default: - return fmt.Sprintf("DiagnosticSeverity(%d)", int(s)) - } -} - -func (s DiagnosticSeverity) valid() bool { - return s == DiagnosticWarning -} - -// DiagnosticCategory classifies a [Diagnostic]. The zero value is invalid. -type DiagnosticCategory int - -const ( - // CategoryPredictionLimitation marks a Stage B prediction that is not - // exact. - CategoryPredictionLimitation DiagnosticCategory = iota + 1 -) - -func (c DiagnosticCategory) String() string { - switch c { - case CategoryPredictionLimitation: - return "prediction_limitation" - default: - return fmt.Sprintf("DiagnosticCategory(%d)", int(c)) - } -} - -func (c DiagnosticCategory) valid() bool { - return c == CategoryPredictionLimitation -} - -// Diagnostic is semantic information attached to a successfully built -// [Plan]. It is not a substitute for a prediction error. -type Diagnostic struct { - Severity DiagnosticSeverity - Category DiagnosticCategory - Message string - Resource *ResourceRef -} diff --git a/internal/plan/semantic/field.go b/internal/plan/semantic/field.go index 47385f4..240d9c3 100644 --- a/internal/plan/semantic/field.go +++ b/internal/plan/semantic/field.go @@ -288,37 +288,6 @@ func copyFields(fields []FieldChange) []FieldChange { return out } -func copyOrigin(o ResourceOrigin) ResourceOrigin { - if o.Helm != nil { - h := *o.Helm - o.Helm = &h - } - return o -} - -func copyApply(a ApplySemantics) ApplySemantics { - if a.Write != nil { - w := *a.Write - a.Write = &w - } - if a.Delete != nil { - d := *a.Delete - a.Delete = &d - } - return a -} - -func copyDiagnostics(in []Diagnostic) []Diagnostic { - out := slices.Clone(in) - for i := range out { - if out[i].Resource != nil { - r := *out[i].Resource - out[i].Resource = &r - } - } - return out -} - func copyTasks(in []TaskPlan) []TaskPlan { if in == nil { return []TaskPlan{} @@ -350,13 +319,6 @@ func copyDefinitions(in []HookDefinition) []HookDefinition { return out } -func snapshotObject(s *ResourceSnapshot) map[string]any { - if s == nil { - return nil - } - return s.Object -} - func filterBookkeeping(in []FieldChange) []FieldChange { out := make([]FieldChange, 0, len(in)) for _, f := range in { diff --git a/internal/plan/semantic/helpers_test.go b/internal/plan/semantic/helpers_test.go index 25aaf9c..3df6543 100644 --- a/internal/plan/semantic/helpers_test.go +++ b/internal/plan/semantic/helpers_test.go @@ -48,63 +48,6 @@ func setObjectString(tb testing.TB, obj map[string]any, value string, keys ...st cur[keys[len(keys)-1]] = value } -func helmOrigin() semantic.ResourceOrigin { - return semantic.ResourceOrigin{ - Kind: semantic.OriginHelm, - Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}, - } -} - -func nsCreate(name string) semantic.ResourceChange { - return semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{"name": name}, - }}, - Apply: writeApply(), - } -} - -func writeCreate() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{ - Method: semantic.WriteCreate, - }, - } -} - -func writeApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{ - Method: semantic.WriteServerSide, - FieldManager: "deployah", - }, - } -} - -func writeForceApply() semantic.ApplySemantics { - a := writeApply() - a.Write.ForceConflicts = true - return a -} - -func deleteApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Delete: &semantic.DeleteSemantics{Propagation: semantic.PropagationBackground}, - } -} - -func bothApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: writeApply().Write, - Delete: deleteApply().Delete, - } -} - func cm(name, value string) map[string]any { return map[string]any{ "apiVersion": "v1", @@ -126,42 +69,38 @@ func ref(kind, name string) semantic.ResourceRef { } } -func limitation(res semantic.ResourceRef) semantic.Diagnostic { - return semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: managed-fields-migration", - Resource: &res, +func mustFields(before, after map[string]any) []semantic.FieldChange { + fields, err := semantic.DiffFields(before, after) + if err != nil { + panic(err) } + return fields } func createChange(name, value string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: ref("ConfigMap", name), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: cm(name, value)}, - Apply: writeApply(), } } func updateChange(name, before, after string) semantic.ResourceChange { + beforeObj := cm(name, before) + afterObj := cm(name, after) return semantic.ResourceChange{ Resource: ref("ConfigMap", name), - Origin: helmOrigin(), Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm(name, before)}, - After: &semantic.ResourceSnapshot{Object: cm(name, after)}, - Apply: writeApply(), + Before: &semantic.ResourceSnapshot{Object: beforeObj}, + After: &semantic.ResourceSnapshot{Object: afterObj}, + Fields: mustFields(beforeObj, afterObj), } } func deleteChange(name, value string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: ref("ConfigMap", name), - Origin: helmOrigin(), Action: semantic.Delete, Before: &semantic.ResourceSnapshot{Object: cm(name, value)}, - Apply: deleteApply(), } } diff --git a/internal/plan/semantic/order.go b/internal/plan/semantic/order.go index dc9065e..652ec59 100644 --- a/internal/plan/semantic/order.go +++ b/internal/plan/semantic/order.go @@ -30,8 +30,7 @@ func sortChanges(changes []ResourceChange) { func compareChange(a, b ResourceChange) int { return cmp.Or( - cmp.Compare(originRank(a.Origin.Kind), originRank(b.Origin.Kind)), - cmp.Compare(a.ApplyOrder, b.ApplyOrder), + cmp.Compare(a.HelmOrder, b.HelmOrder), cmp.Compare(a.Resource.APIVersion, b.Resource.APIVersion), cmp.Compare(a.Resource.Kind, b.Resource.Kind), cmp.Compare(a.Resource.Namespace, b.Resource.Namespace), @@ -41,21 +40,10 @@ func compareChange(a, b ResourceChange) int { ) } -func originRank(k OriginKind) int { - switch k { - case OriginNamespace: - return 1 - case OriginHelm: - return 2 - default: - return 3 - } -} - func sortTasks(tasks []TaskPlan, changes []ResourceChange) { - applyOrder := make(map[string]int, len(changes)) + helmOrder := make(map[string]int, len(changes)) for _, c := range changes { - applyOrder[c.Resource.identityKey()] = c.ApplyOrder + helmOrder[c.Resource.refKey()] = c.HelmOrder } slices.SortFunc(tasks, func(a, b TaskPlan) int { return cmp.Or( @@ -68,13 +56,13 @@ func sortTasks(tasks []TaskPlan, changes []ResourceChange) { slices.SortFunc(tasks[i].Definitions, func(a, b HookDefinition) int { return cmp.Or( cmp.Compare(a.HookWeight, b.HookWeight), - cmp.Compare(a.Resource.identityKey(), b.Resource.identityKey()), + cmp.Compare(a.Resource.refKey(), b.Resource.refKey()), ) }) slices.SortFunc(tasks[i].Resources, func(a, b ResourceRef) int { return cmp.Or( - cmp.Compare(applyOrder[a.identityKey()], applyOrder[b.identityKey()]), - cmp.Compare(a.identityKey(), b.identityKey()), + cmp.Compare(helmOrder[a.refKey()], helmOrder[b.refKey()]), + cmp.Compare(a.refKey(), b.refKey()), ) }) for j := range tasks[i].Definitions { @@ -84,20 +72,3 @@ func sortTasks(tasks []TaskPlan, changes []ResourceChange) { } } } - -func sortDiagnostics(diags []Diagnostic) { - slices.SortFunc(diags, func(a, b Diagnostic) int { - return cmp.Or( - cmp.Compare(refKey(a.Resource), refKey(b.Resource)), - cmp.Compare(int(a.Category), int(b.Category)), - cmp.Compare(a.Message, b.Message), - ) - }) -} - -func refKey(r *ResourceRef) string { - if r == nil { - return "" - } - return r.identityKey() -} diff --git a/internal/plan/semantic/order_test.go b/internal/plan/semantic/order_test.go index 4cf87b7..3ae0a58 100644 --- a/internal/plan/semantic/order_test.go +++ b/internal/plan/semantic/order_test.go @@ -89,7 +89,7 @@ func TestNew_OrderTieBreakers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, nil, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, nil) require.NoError(t, err) require.Len(t, p.Changes, len(tt.want)) got := make([]semantic.ResourceRef, 0, len(p.Changes)) @@ -107,27 +107,22 @@ func TestNew_ActionRank(t *testing.T) { p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{ { Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: deleteApply(), }, { Resource: res, - Origin: helmOrigin(), Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: writeApply(), + Fields: mustFields(cm("app", "v1"), cm("app", "v2")), }, { Resource: res, - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), }, - }, nil, nil) + }, nil) require.NoError(t, err) require.Len(t, p.Changes, 3) assert.Equal(t, []semantic.Action{ @@ -141,12 +136,15 @@ func TestNew_FieldChangeOrder(t *testing.T) { t.Parallel() p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: map[string]any{"z": "1", "a": "1", "m": "1"}}, After: &semantic.ResourceSnapshot{Object: map[string]any{"z": "2", "a": "2", "m": "2"}}, - Apply: writeApply(), - }}, nil, nil) + Fields: []semantic.FieldChange{ + {Path: "/z", Op: semantic.FieldReplace, Before: "1", After: "2"}, + {Path: "/a", Op: semantic.FieldReplace, Before: "1", After: "2"}, + {Path: "/m", Op: semantic.FieldReplace, Before: "1", After: "2"}, + }, + }}, nil) require.NoError(t, err) require.Len(t, p.Changes[0].Fields, 3) assert.Equal(t, []string{"/a", "/m", "/z"}, []string{ @@ -156,57 +154,20 @@ func TestNew_FieldChangeOrder(t *testing.T) { }) } -func TestNew_DiagnosticOrder(t *testing.T) { +func TestNew_HelmOrderThenRef(t *testing.T) { t.Parallel() - app := ref("ConfigMap", "app") - web := ref("ConfigMap", "web") - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil, []semantic.Diagnostic{ - { - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "z last", - Resource: &web, - }, - { - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "nil resource", - }, - { - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "b second", - Resource: &app, - }, - { - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "a first", - Resource: &app, - }, - }) + later := createChange("z", "v1") + later.HelmOrder = 2 + tiedHigh := createChange("m", "v1") + tiedHigh.HelmOrder = 1 + tiedLow := createChange("a", "v1") + tiedLow.HelmOrder = 1 + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{later, tiedHigh, tiedLow}, nil) require.NoError(t, err) - require.Len(t, p.Diagnostics, 4) - assert.Nil(t, p.Diagnostics[0].Resource) - assert.Equal(t, "nil resource", p.Diagnostics[0].Message) - assert.Equal(t, "app", p.Diagnostics[1].Resource.Name) - assert.Equal(t, "a first", p.Diagnostics[1].Message) - assert.Equal(t, "app", p.Diagnostics[2].Resource.Name) - assert.Equal(t, "b second", p.Diagnostics[2].Message) - assert.Equal(t, "web", p.Diagnostics[3].Resource.Name) -} - -func TestNew_OriginRankBeforeApplyOrder(t *testing.T) { - t.Parallel() - helm := createChange("app", "v1") - helm.ApplyOrder = 1 - ns := nsCreate("prod") - ns.ApplyOrder = 9 - p, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, []semantic.ResourceChange{helm, ns}, nil, nil) - require.NoError(t, err) - require.Len(t, p.Changes, 2) - assert.Equal(t, semantic.OriginNamespace, p.Changes[0].Origin.Kind) - assert.Equal(t, semantic.OriginHelm, p.Changes[1].Origin.Kind) + require.Len(t, p.Changes, 3) + assert.Equal(t, "a", p.Changes[0].Resource.Name) + assert.Equal(t, "m", p.Changes[1].Resource.Name) + assert.Equal(t, "z", p.Changes[2].Resource.Name) } func namedCreate(res semantic.ResourceRef) semantic.ResourceChange { @@ -216,9 +177,7 @@ func namedCreate(res semantic.ResourceRef) semantic.ResourceChange { } return semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: cm(name, "v1")}, - Apply: writeApply(), } } diff --git a/internal/plan/semantic/plan.go b/internal/plan/semantic/plan.go index 4ebbe38..e70867e 100644 --- a/internal/plan/semantic/plan.go +++ b/internal/plan/semantic/plan.go @@ -19,28 +19,24 @@ import ( "slices" ) -// Plan is the semantic plan for one invocation: Deployah's intended -// operations and their visible resource consequences. Construct it with -// [New]. Chart CRDs are attached with [AttachChartCRDs]; [New] leaves -// them empty. Snapshots and field values are unredacted. This type is -// not a JSON rendering contract. +// Plan is one run's Helm release intent and its Previous-to-Desired +// resource changes. Build it with [New]. Add chart CRDs with +// [AttachChartCRDs]. Snapshots still contain secrets, and this type +// is not the plan JSON document. type Plan struct { - Header Header - HelmAction HelmAction - Changes []ResourceChange - Tasks []TaskPlan - ChartCRDs []ChartCRD - Diagnostics []Diagnostic - Summary Summary + Header Header + HelmAction HelmAction + Changes []ResourceChange + Tasks []TaskPlan + ChartCRDs []ChartCRD + Summary Summary } -// New validates helmAction, header, changes, tasks, and diagnostics, -// sorts them, derives [Summary], and returns a plan whose slices are -// non-nil. ChartCRDs is empty; call [AttachChartCRDs] to set them. Task -// references to [ResourceChange] values must be consistent; it fails -// closed on dangling or duplicate ownership. helmAction is stored as -// provided; [New] does not derive or mutate it. -func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks []TaskPlan, diagnostics []Diagnostic) (Plan, error) { +// New checks and sorts its inputs, fills [Summary], and returns +// non-nil slices. Chart CRDs stay empty. It returns an error if a +// task reference is missing, ambiguous, or shared. helmAction and +// field changes are stored as given. +func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks []TaskPlan) (Plan, error) { if !helmAction.valid() { return Plan{}, fmt.Errorf("invalid helm action %s", helmAction) } @@ -53,18 +49,9 @@ func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks [ copiedChanges = []ResourceChange{} } copiedTasks := copyTasks(tasks) - copiedDiags := copyDiagnostics(diagnostics) - if copiedDiags == nil { - copiedDiags = []Diagnostic{} - } - for i := range copiedDiags { - if err := validateDiagnostic(copiedDiags[i]); err != nil { - return Plan{}, err - } - } for i := range copiedChanges { - if err := validateChange(copiedChanges[i], copiedDiags); err != nil { + if err := validateChange(copiedChanges[i]); err != nil { return Plan{}, fmt.Errorf("resource %s: %w", copiedChanges[i].Resource, err) } normalized, nerr := normalizeChange(copiedChanges[i]) @@ -89,22 +76,20 @@ func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks [ sortChanges(copiedChanges) sortTasks(copiedTasks, copiedChanges) - sortDiagnostics(copiedDiags) return Plan{ - Header: header, - HelmAction: helmAction, - Changes: copiedChanges, - Tasks: copiedTasks, - ChartCRDs: []ChartCRD{}, - Diagnostics: copiedDiags, - Summary: Summarize(copiedChanges), + Header: header, + HelmAction: helmAction, + Changes: copiedChanges, + Tasks: copiedTasks, + ChartCRDs: []ChartCRD{}, + Summary: Summarize(copiedChanges), }, nil } -// AttachChartCRDs returns a copy of p with validated chart CRD lifecycle -// entries. Order is preserved. It does not change [Plan.Changes], -// [Plan.HasEffects], or [Plan.IsNoOp]. +// AttachChartCRDs returns a copy of p with checked chart CRDs in the +// given order. [Plan.Changes], [Plan.HasEffects], and [Plan.IsNoOp] +// stay the same. func AttachChartCRDs(p Plan, crds []ChartCRD) (Plan, error) { copied := slices.Clone(crds) if copied == nil { @@ -136,9 +121,8 @@ func validateChartCRD(c ChartCRD) error { return nil } -// HasEffects reports whether the plan lists a known resource mutation or -// a task that would change or run. Diagnostics and [HelmAction] are not -// effects. +// HasEffects reports whether the plan lists a resource change or a task +// that would change or run. [HelmAction] is not an effect. func (p Plan) HasEffects() bool { if len(p.Changes) > 0 { return true @@ -162,19 +146,7 @@ func (p Plan) IsNoOp() bool { func normalizeChange(c ResourceChange) (ResourceChange, error) { c.Before = copySnapshot(c.Before) c.After = copySnapshot(c.After) - c.Origin = copyOrigin(c.Origin) - c.Apply = copyApply(c.Apply) c.Fields = copyFields(c.Fields) - switch { - case c.Action == Update && c.After != nil: - fields, err := DiffFields(snapshotObject(c.Before), snapshotObject(c.After)) - if err != nil { - return ResourceChange{}, err - } - c.Fields = fields - default: - c.Fields = []FieldChange{} - } if c.Fields == nil { c.Fields = []FieldChange{} } @@ -202,16 +174,6 @@ func normalizeDefinition(d HookDefinition) (HookDefinition, error) { d.Before = copySnapshot(d.Before) d.After = copySnapshot(d.After) d.Fields = copyFields(d.Fields) - switch { - case d.Action == Update && d.After != nil: - fields, err := DiffFields(snapshotObject(d.Before), snapshotObject(d.After)) - if err != nil { - return HookDefinition{}, err - } - d.Fields = fields - default: - d.Fields = []FieldChange{} - } if d.Fields == nil { d.Fields = []FieldChange{} } @@ -221,7 +183,7 @@ func normalizeDefinition(d HookDefinition) (HookDefinition, error) { func validateTasks(tasks []TaskPlan, changes []ResourceChange) error { changeIndex := make(map[string]int, len(changes)) for i, c := range changes { - key := c.Resource.identityKey() + key := c.Resource.refKey() if _, exists := changeIndex[key]; exists { changeIndex[key] = -1 continue @@ -265,7 +227,7 @@ func validateTask(t TaskPlan, changeIndex map[string]int, owned map[string]strin return fmt.Errorf("schedule must not will run") } for _, ref := range t.Resources { - key := ref.identityKey() + key := ref.refKey() idx, ok := changeIndex[key] if !ok { return fmt.Errorf("dangling resource %s", ref) @@ -303,6 +265,9 @@ func validateDefinition(d HookDefinition) error { if d.After == nil { return fmt.Errorf("create requires an after snapshot") } + if len(d.Fields) != 0 { + return fmt.Errorf("create must not have field changes") + } case Update: if d.Before == nil { return fmt.Errorf("update requires a before snapshot") @@ -310,6 +275,9 @@ func validateDefinition(d HookDefinition) error { if d.After == nil { return fmt.Errorf("update requires an after snapshot") } + if len(d.Fields) == 0 { + return fmt.Errorf("update requires a field change") + } case Delete: if d.Before == nil { return fmt.Errorf("delete requires a before snapshot") @@ -317,124 +285,18 @@ func validateDefinition(d HookDefinition) error { if d.After != nil { return fmt.Errorf("delete must not have an after snapshot") } + if len(d.Fields) != 0 { + return fmt.Errorf("delete must not have field changes") + } } return nil } -func validateChange(c ResourceChange, diags []Diagnostic) error { +func validateChange(c ResourceChange) error { if !c.Action.valid() { return fmt.Errorf("invalid action %s", c.Action) } - if err := validateOrigin(c.Origin); err != nil { - return err - } - if err := validateOriginResource(c); err != nil { - return err - } - if err := validateApply(c.Action, c.Apply); err != nil { - return err - } - if err := validateOriginApply(c); err != nil { - return err - } - return validateSnapshots(c, diags) -} - -func validateOrigin(o ResourceOrigin) error { - if !o.Kind.valid() { - return fmt.Errorf("invalid origin %s", o.Kind) - } - switch o.Kind { - case OriginHelm: - if o.Helm == nil { - return fmt.Errorf("helm origin requires helm details") - } - case OriginNamespace: - if o.Helm != nil { - return fmt.Errorf("%s origin must not include helm details", o.Kind) - } - } - return nil -} - -func validateOriginResource(c ResourceChange) error { - if c.Origin.Kind != OriginNamespace { - return nil - } - if c.Resource.APIVersion != "v1" || c.Resource.Kind != "Namespace" { - return fmt.Errorf("namespace origin requires v1 Namespace") - } - if c.Resource.Namespace != "" { - return fmt.Errorf("namespace origin must be cluster-scoped") - } - switch c.Action { - case Create, Update: - default: - return fmt.Errorf("namespace origin does not support %s", c.Action) - } - return nil -} - -func validateOriginApply(c ResourceChange) error { - if c.Apply.Write == nil || c.Origin.Kind != OriginNamespace { - return nil - } - if c.Apply.Write.Method != WriteServerSide { - return fmt.Errorf("namespace origin requires server_side_apply") - } - if c.Apply.Write.ForceConflicts { - return fmt.Errorf("namespace origin must not force conflicts") - } - return nil -} - -func validateApply(action Action, apply ApplySemantics) error { - switch action { - case Create, Update: - if apply.Write == nil { - return fmt.Errorf("%s requires write semantics", action) - } - if apply.Delete != nil { - return fmt.Errorf("%s must not have delete semantics", action) - } - if err := validateWrite(*apply.Write); err != nil { - return err - } - if action == Update && apply.Write.Method == WriteCreate { - return fmt.Errorf("update requires server_side_apply") - } - return nil - case Delete: - if apply.Write != nil { - return fmt.Errorf("delete must not have write semantics") - } - if apply.Delete == nil { - return fmt.Errorf("delete requires delete semantics") - } - return validateDelete(*apply.Delete) - default: - return fmt.Errorf("invalid action %s", action) - } -} - -func validateWrite(w WriteSemantics) error { - if !w.Method.valid() { - return fmt.Errorf("invalid write method %s", w.Method) - } - switch w.Method { - case WriteServerSide: - if w.FieldManager == "" { - return fmt.Errorf("server_side_apply requires a field manager") - } - case WriteCreate: - if w.FieldManager != "" { - return fmt.Errorf("create write must not set a field manager") - } - if w.ForceConflicts { - return fmt.Errorf("create write must not force conflicts") - } - } - return nil + return validateSnapshots(c) } func validateHelmAction(header Header, helmAction HelmAction) error { @@ -454,19 +316,12 @@ func validateHelmAction(header Header, helmAction HelmAction) error { } func validateHelmContent(helmAction HelmAction, changes []ResourceChange, tasks []TaskPlan) error { - for _, c := range changes { - if c.Origin.Kind == OriginNamespace && helmAction != HelmInstall { - return fmt.Errorf("namespace origin requires helm install") - } + if helmAction == HelmNone && len(changes) > 0 { + return fmt.Errorf("helm none must not include resource changes") } if helmAction != HelmNone { return nil } - for _, c := range changes { - if c.Origin.Kind == OriginHelm { - return fmt.Errorf("helm none must not include helm resource changes") - } - } for _, t := range tasks { if t.Phase != TaskPreDeploy && t.Phase != TaskPostDeploy { continue @@ -481,14 +336,7 @@ func validateHelmContent(helmAction HelmAction, changes []ResourceChange, tasks return nil } -func validateDelete(d DeleteSemantics) error { - if !d.Propagation.valid() { - return fmt.Errorf("invalid delete propagation %s", d.Propagation) - } - return nil -} - -func validateSnapshots(c ResourceChange, diags []Diagnostic) error { +func validateSnapshots(c ResourceChange) error { switch c.Action { case Create: if c.Before != nil { @@ -497,12 +345,18 @@ func validateSnapshots(c ResourceChange, diags []Diagnostic) error { if c.After == nil { return fmt.Errorf("create requires an after snapshot") } + if len(c.Fields) != 0 { + return fmt.Errorf("create must not have field changes") + } case Update: if c.Before == nil { return fmt.Errorf("update requires a before snapshot") } - if c.After == nil && !hasLimitation(c.Resource, diags) { - return fmt.Errorf("update without after requires a prediction-limitation diagnostic") + if c.After == nil { + return fmt.Errorf("update requires an after snapshot") + } + if len(c.Fields) == 0 { + return fmt.Errorf("update requires a field change") } case Delete: if c.Before == nil { @@ -511,31 +365,9 @@ func validateSnapshots(c ResourceChange, diags []Diagnostic) error { if c.After != nil { return fmt.Errorf("delete must not have an after snapshot") } - } - return nil -} - -func hasLimitation(ref ResourceRef, diags []Diagnostic) bool { - for _, d := range diags { - if d.Category != CategoryPredictionLimitation || d.Resource == nil { - continue - } - if *d.Resource == ref { - return true + if len(c.Fields) != 0 { + return fmt.Errorf("delete must not have field changes") } } - return false -} - -func validateDiagnostic(d Diagnostic) error { - if !d.Severity.valid() { - return fmt.Errorf("invalid diagnostic severity %s", d.Severity) - } - if !d.Category.valid() { - return fmt.Errorf("invalid diagnostic category %s", d.Category) - } - if d.Message == "" { - return fmt.Errorf("diagnostic message is required") - } return nil } diff --git a/internal/plan/semantic/plan_test.go b/internal/plan/semantic/plan_test.go index d3b6036..74106b2 100644 --- a/internal/plan/semantic/plan_test.go +++ b/internal/plan/semantic/plan_test.go @@ -15,7 +15,6 @@ package semantic_test import ( - "math" "testing" "github.com/stretchr/testify/assert" @@ -34,8 +33,6 @@ func TestNew_SnapshotInvariants(t *testing.T) { before bool after bool fields bool - write bool - delete bool fieldPath string fieldOp semantic.FieldOp }{ @@ -44,7 +41,6 @@ func TestNew_SnapshotInvariants(t *testing.T) { change: createChange("app", "v1"), action: semantic.Create, after: true, - write: true, }, { name: "update", @@ -53,7 +49,6 @@ func TestNew_SnapshotInvariants(t *testing.T) { before: true, after: true, fields: true, - write: true, fieldPath: "/data/key", fieldOp: semantic.FieldReplace, }, @@ -62,13 +57,12 @@ func TestNew_SnapshotInvariants(t *testing.T) { change: deleteChange("app", "v1"), action: semantic.Delete, before: true, - delete: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil, nil) + p, err := semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil) require.NoError(t, err) require.Len(t, p.Changes, 1) c := p.Changes[0] @@ -82,46 +76,45 @@ func TestNew_SnapshotInvariants(t *testing.T) { assert.Equal(t, tt.before, c.Before != nil) assert.Equal(t, tt.after, c.After != nil) assert.Equal(t, tt.fields, len(c.Fields) > 0) - assert.Equal(t, tt.write, c.Apply.Write != nil) - assert.Equal(t, tt.delete, c.Apply.Delete != nil) assert.Equal(t, tt.fieldPath, path) assert.Equal(t, tt.fieldOp, op) }) } } -func TestNew_UpdateWithoutAfterRequiresLimitation(t *testing.T) { +func TestNew_UpdateRequiresAfterAndFields(t *testing.T) { t.Parallel() header := semantic.Header{Release: "web", Namespace: "prod"} res := ref("ConfigMap", "app") - change := semantic.ResourceChange{ + missingAfter := semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), + Fields: []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "v1", After: "v2"}}, } - - _, err := semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil, nil) + _, err := semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{missingAfter}, nil) require.Error(t, err) - assert.ErrorContains(t, err, "update without after") + assert.ErrorContains(t, err, "update requires an after snapshot") - p, err := semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil, []semantic.Diagnostic{limitation(res)}) - require.NoError(t, err) - assert.Nil(t, p.Changes[0].After) - assert.Empty(t, p.Changes[0].Fields) + missingFields := semantic.ResourceChange{ + Resource: res, + Action: semantic.Update, + Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, + After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, + } + _, err = semantic.New(header, semantic.HelmUpgrade, []semantic.ResourceChange{missingFields}, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "update requires a field change") } func TestNew_TasksAlwaysNonNil(t *testing.T) { t.Parallel() - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil) require.NoError(t, err) require.NotNil(t, p.Tasks) assert.Empty(t, p.Tasks) require.NotNil(t, p.Changes) assert.Empty(t, p.Changes) - require.NotNil(t, p.Diagnostics) - assert.Empty(t, p.Diagnostics) require.NotNil(t, p.ChartCRDs) assert.Empty(t, p.ChartCRDs) assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) @@ -134,7 +127,7 @@ func TestNew_SummaryDerived(t *testing.T) { createChange("a", "1"), updateChange("b", "1", "2"), deleteChange("c", "1"), - }, nil, nil) + }, nil) require.NoError(t, err) assert.Equal(t, semantic.Summary{Create: 1, Update: 1, Delete: 1}, p.Summary) assert.Equal(t, 3, p.Summary.Total()) @@ -146,7 +139,7 @@ func TestNew_DeterministicOrder(t *testing.T) { deleteChange("z", "1"), createChange("a", "1"), updateChange("m", "1", "2"), - }, nil, nil) + }, nil) require.NoError(t, err) require.Len(t, p.Changes, 3) assert.Equal(t, "a", p.Changes[0].Resource.Name) @@ -165,11 +158,9 @@ func TestNew_GoIntInSnapshot(t *testing.T) { } p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Deployment", "web"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: obj}, - Apply: writeApply(), - }}, nil, nil) + }}, nil) require.NoError(t, err) require.NotNil(t, p.Changes[0].After) spec, ok := p.Changes[0].After.Object["spec"].(map[string]any) @@ -189,51 +180,38 @@ func TestNew_DoesNotMutateCallerSnapshots(t *testing.T) { obj := cm("app", "v1") change := semantic.ResourceChange{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: obj}, - Apply: writeApply(), } - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil) require.NoError(t, err) setObjectString(t, obj, "mutated", "data", "key") assert.Equal(t, "v1", objectString(t, p.Changes[0].After.Object, "data", "key")) } -func TestNew_DoesNotAliasCallerInputs(t *testing.T) { +func TestNew_KeepsCallerFields(t *testing.T) { t.Parallel() - helm := &semantic.HelmOrigin{Release: "web", Namespace: "prod"} - write := &semantic.WriteSemantics{Method: semantic.WriteServerSide, FieldManager: "deployah"} - del := &semantic.DeleteSemantics{Propagation: semantic.PropagationBackground} - res := ref("ConfigMap", "app") - diag := limitation(res) + before := cm("app", "v1") + after := cm("app", "v2") + fields := []semantic.FieldChange{ + {Path: "/z", Op: semantic.FieldReplace, Before: "1", After: "9"}, + {Path: "/a", Op: semantic.FieldReplace, Before: "1", After: "2"}, + } p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: res, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: helm}, + Resource: ref("ConfigMap", "app"), Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: semantic.ApplySemantics{Write: write}, - }, { - Resource: ref("ConfigMap", "gone"), - Origin: helmOrigin(), - Action: semantic.Delete, - Before: &semantic.ResourceSnapshot{Object: cm("gone", "v1")}, - Apply: semantic.ApplySemantics{Delete: del}, - }}, nil, []semantic.Diagnostic{diag}) + Before: &semantic.ResourceSnapshot{Object: before}, + After: &semantic.ResourceSnapshot{Object: after}, + Fields: fields, + }}, nil) require.NoError(t, err) + require.Len(t, p.Changes[0].Fields, 2) + assert.Equal(t, "/a", p.Changes[0].Fields[0].Path) + assert.Equal(t, "/z", p.Changes[0].Fields[1].Path) + assert.Equal(t, "9", p.Changes[0].Fields[1].After) - helm.Release = "mutated" - write.FieldManager = "mutated" - del.Propagation = 0 - diag.Resource.Name = "mutated" - - require.Len(t, p.Changes, 2) - assert.Equal(t, "web", p.Changes[0].Origin.Helm.Release) - assert.Equal(t, "deployah", p.Changes[0].Apply.Write.FieldManager) - assert.Equal(t, semantic.PropagationBackground, p.Changes[1].Apply.Delete.Propagation) - require.Len(t, p.Diagnostics, 1) - assert.Equal(t, "app", p.Diagnostics[0].Resource.Name) + fields[0].After = "mutated" + assert.Equal(t, "2", p.Changes[0].Fields[0].After) } func TestSummarize(t *testing.T) { @@ -252,41 +230,19 @@ func TestSummarize(t *testing.T) { func TestNew_RejectsInvalid(t *testing.T) { t.Parallel() res := ref("ConfigMap", "app") + fields := []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "v1", After: "v2"}} tests := []struct { name string change semantic.ResourceChange wantErr string }{ - { - name: "unknown origin", - change: semantic.ResourceChange{ - Resource: res, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }, - wantErr: "invalid origin", - }, - { - name: "helm origin without details", - change: semantic.ResourceChange{ - Resource: res, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm}, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }, - wantErr: "helm origin requires helm details", - }, { name: "create with before", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, Before: &semantic.ResourceSnapshot{Object: cm("app", "v0")}, After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), }, wantErr: "create must not have a before snapshot", }, @@ -294,168 +250,91 @@ func TestNew_RejectsInvalid(t *testing.T) { name: "create missing after", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, - Apply: writeApply(), }, wantErr: "create requires an after snapshot", }, { - name: "update missing write", + name: "create with fields", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), - Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, + Action: semantic.Create, + After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, + Fields: fields, }, - wantErr: "requires write semantics", + wantErr: "create must not have field changes", }, { name: "update missing before", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Update, After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: writeApply(), + Fields: fields, }, wantErr: "update requires a before snapshot", }, { - name: "delete missing before", + name: "update missing after", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), - Action: semantic.Delete, - Apply: deleteApply(), - }, - wantErr: "delete requires a before snapshot", - }, - { - name: "delete with after", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Delete, + Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: deleteApply(), - }, - wantErr: "delete must not have an after snapshot", - }, - { - name: "create rejects delete", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: bothApply(), + Fields: fields, }, - wantErr: "must not have delete semantics", + wantErr: "update requires an after snapshot", }, { - name: "create rejects missing write", + name: "update missing fields", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - }, - wantErr: "requires write semantics", - }, - { - name: "update rejects delete", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: bothApply(), }, - wantErr: "must not have delete semantics", + wantErr: "update requires a field change", }, { - name: "delete rejects write", + name: "delete missing before", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: bothApply(), }, - wantErr: "must not have write semantics", + wantErr: "delete requires a before snapshot", }, { - name: "delete rejects missing delete", + name: "delete with after", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, + After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, }, - wantErr: "requires delete semantics", - }, - { - name: "invalid write method", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: semantic.ApplySemantics{Write: &semantic.WriteSemantics{}}, - }, - wantErr: "invalid write method", + wantErr: "delete must not have an after snapshot", }, { - name: "invalid delete propagation", + name: "delete with fields", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: semantic.ApplySemantics{Delete: &semantic.DeleteSemantics{}}, + Fields: fields, }, - wantErr: "invalid delete propagation", + wantErr: "delete must not have field changes", }, { name: "zero action", change: semantic.ResourceChange{ Resource: res, - Origin: helmOrigin(), After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), }, wantErr: "invalid action", }, - { - name: "server-side apply missing field manager", - change: func() semantic.ResourceChange { - c := createChange("app", "v1") - c.Apply.Write.FieldManager = "" - return c - }(), - wantErr: "field manager", - }, - { - name: "unencodable after snapshot", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: map[string]any{"n": math.NaN()}}, - Apply: writeApply(), - }, - wantErr: "encode after snapshot", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil, nil) + _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) assert.ErrorContains(t, err, res.String()) @@ -463,82 +342,6 @@ func TestNew_RejectsInvalid(t *testing.T) { } } -func TestNew_DiagnosticValidation(t *testing.T) { - t.Parallel() - res := ref("ConfigMap", "app") - tests := []struct { - name string - diag semantic.Diagnostic - wantErr string - }{ - { - name: "invalid severity", - diag: semantic.Diagnostic{ - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact", - Resource: &res, - }, - wantErr: "invalid diagnostic severity", - }, - { - name: "invalid category", - diag: semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Message: "prediction is not exact", - Resource: &res, - }, - wantErr: "invalid diagnostic category", - }, - { - name: "empty message", - diag: semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Resource: &res, - }, - wantErr: "diagnostic message is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil, []semantic.Diagnostic{tt.diag}) - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - }) - } -} - -func TestNew_LimitationMatching(t *testing.T) { - t.Parallel() - res := ref("ConfigMap", "app") - other := ref("ConfigMap", "other") - change := semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - } - nilResource := limitation(res) - nilResource.Resource = nil - tests := []struct { - name string - diags []semantic.Diagnostic - }{ - {name: "other resource", diags: []semantic.Diagnostic{limitation(other)}}, - {name: "nil resource", diags: []semantic.Diagnostic{nilResource}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil, tt.diags) - require.Error(t, err) - assert.ErrorContains(t, err, "update without after") - }) - } -} - func TestNew_CopiesNestedSnapshotShapes(t *testing.T) { t.Parallel() labels := map[string]string{"app": "web"} @@ -553,12 +356,9 @@ func TestNew_CopiesNestedSnapshotShapes(t *testing.T) { } p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: obj}, - Fields: []semantic.FieldChange{{Path: "/unused", Op: semantic.FieldAdd, After: "x"}}, - Apply: writeApply(), - }}, nil, nil) + }}, nil) require.NoError(t, err) assert.Empty(t, p.Changes[0].Fields) @@ -583,26 +383,23 @@ func TestNew_UpdateEmptyBeforeObject(t *testing.T) { t.Parallel() p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: &semantic.ResourceSnapshot{}, After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }}, nil, nil) + Fields: []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "v1", After: "v2"}}, + }}, nil) require.NoError(t, err) - require.NotEmpty(t, p.Changes[0].Fields) - assert.Equal(t, semantic.FieldAdd, p.Changes[0].Fields[0].Op) + require.Len(t, p.Changes[0].Fields, 1) + assert.Equal(t, "/data/key", p.Changes[0].Fields[0].Path) } func TestNew_NilSnapshotObject(t *testing.T) { t.Parallel() p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{}, - Apply: writeApply(), - }}, nil, nil) + }}, nil) require.NoError(t, err) require.NotNil(t, p.Changes[0].After) assert.Nil(t, p.Changes[0].After.Object) @@ -645,7 +442,7 @@ func TestNew_HelmActionHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(tt.header, tt.helmAction, nil, nil, nil) + p, err := semantic.New(tt.header, tt.helmAction, nil, nil) if tt.wantErr != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -667,14 +464,9 @@ func TestNew_HelmNoneContent(t *testing.T) { }{ {name: "empty"}, { - name: "origin helm", + name: "resource change", changes: []semantic.ResourceChange{createChange("app", "v1")}, - wantErr: "helm none must not include helm resource changes", - }, - { - name: "origin namespace", - changes: []semantic.ResourceChange{nsCreate("prod")}, - wantErr: "namespace origin requires helm install", + wantErr: "helm none must not include resource changes", }, { name: "changed preDeploy", @@ -707,234 +499,7 @@ func TestNew_HelmNoneContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := semantic.New(semantic.Header{}, semantic.HelmNone, tt.changes, tt.tasks, nil) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestNew_OriginNamespaceRequiresInstall(t *testing.T) { - t.Parallel() - change := nsCreate("prod") - tests := []struct { - name string - header semantic.Header - helmAction semantic.HelmAction - wantErr string - }{ - {name: "install", header: semantic.Header{FreshInstall: true}, helmAction: semantic.HelmInstall}, - {name: "none", helmAction: semantic.HelmNone, wantErr: "namespace origin requires helm install"}, - {name: "upgrade", helmAction: semantic.HelmUpgrade, wantErr: "namespace origin requires helm install"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(tt.header, tt.helmAction, []semantic.ResourceChange{change}, nil, nil) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestNew_OriginPayload(t *testing.T) { - t.Parallel() - tests := []struct { - name string - header semantic.Header - helmAction semantic.HelmAction - change semantic.ResourceChange - wantErr string - }{ - { - name: "helm with details", - helmAction: semantic.HelmUpgrade, - change: createChange("app", "v1"), - }, - { - name: "helm without details", - helmAction: semantic.HelmUpgrade, - change: semantic.ResourceChange{ - Resource: ref("ConfigMap", "app"), - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm}, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }, - wantErr: "helm origin requires helm details", - }, - { - name: "namespace without helm", - header: semantic.Header{FreshInstall: true}, - helmAction: semantic.HelmInstall, - change: nsCreate("prod"), - }, - { - name: "namespace with helm", - header: semantic.Header{FreshInstall: true}, - helmAction: semantic.HelmInstall, - change: semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace, Helm: helmOrigin().Helm}, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{"name": "prod"}, - }}, - Apply: writeApply(), - }, - wantErr: "namespace origin must not include helm details", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(tt.header, tt.helmAction, []semantic.ResourceChange{tt.change}, nil, nil) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestNew_OriginContracts(t *testing.T) { - t.Parallel() - nsAfter := &semantic.ResourceSnapshot{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{"name": "prod"}, - }} - tests := []struct { - name string - header semantic.Header - helmAction semantic.HelmAction - change semantic.ResourceChange - wantErr string - }{ - { - name: "namespace write create forbidden", - header: semantic.Header{FreshInstall: true}, - helmAction: semantic.HelmInstall, - change: semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, - After: nsAfter, - Apply: writeCreate(), - }, - wantErr: "namespace origin requires server_side_apply", - }, - { - name: "namespace force forbidden", - header: semantic.Header{FreshInstall: true}, - helmAction: semantic.HelmInstall, - change: semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, - After: nsAfter, - Apply: writeForceApply(), - }, - wantErr: "namespace origin must not force conflicts", - }, - { - name: "namespace wrong kind", - header: semantic.Header{FreshInstall: true}, - helmAction: semantic.HelmInstall, - change: semantic.ResourceChange{ - Resource: ref("ConfigMap", "app"), - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, - After: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }, - wantErr: "namespace origin requires v1 Namespace", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(tt.header, tt.helmAction, []semantic.ResourceChange{tt.change}, nil, nil) - if tt.wantErr != "" { - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - }) - } -} - -func TestNew_WriteSemantics(t *testing.T) { - t.Parallel() - createSSA := createChange("app", "v1") - createK8s := createChange("app", "v1") - createK8s.Apply = writeCreate() - createWithManager := createChange("app", "v1") - createWithManager.Apply = writeCreate() - createWithManager.Apply.Write.FieldManager = "deployah" - createWithForce := createChange("app", "v1") - createWithForce.Apply = writeCreate() - createWithForce.Apply.Write.ForceConflicts = true - updateSSA := updateChange("app", "v1", "v2") - updateCreate := updateChange("app", "v1", "v2") - updateCreate.Apply = writeCreate() - ssaForce := createChange("app", "v1") - ssaForce.Apply.Write.ForceConflicts = true - ssaNoManager := createChange("app", "v1") - ssaNoManager.Apply.Write.FieldManager = "" - tests := []struct { - name string - header semantic.Header - helmAction semantic.HelmAction - change semantic.ResourceChange - wantErr string - }{ - {name: "create with create write", helmAction: semantic.HelmUpgrade, change: createK8s}, - {name: "create with server side apply", helmAction: semantic.HelmUpgrade, change: createSSA}, - {name: "update with server side apply", helmAction: semantic.HelmUpgrade, change: updateSSA}, - { - name: "update with create write", - helmAction: semantic.HelmUpgrade, - change: updateCreate, - wantErr: "update requires server_side_apply", - }, - { - name: "create write with field manager", - helmAction: semantic.HelmUpgrade, - change: createWithManager, - wantErr: "create write must not set a field manager", - }, - { - name: "create write with force conflicts", - helmAction: semantic.HelmUpgrade, - change: createWithForce, - wantErr: "create write must not force conflicts", - }, - { - name: "server side apply without field manager", - helmAction: semantic.HelmUpgrade, - change: ssaNoManager, - wantErr: "server_side_apply requires a field manager", - }, - {name: "server side apply force conflicts", helmAction: semantic.HelmUpgrade, change: ssaForce}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := semantic.New(tt.header, tt.helmAction, []semantic.ResourceChange{tt.change}, nil, nil) + _, err := semantic.New(semantic.Header{}, semantic.HelmNone, tt.changes, tt.tasks) if tt.wantErr != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -961,7 +526,7 @@ func TestPlan_HasEffects(t *testing.T) { name: "resource change", header: semantic.Header{FreshInstall: true}, helmAction: semantic.HelmInstall, - changes: []semantic.ResourceChange{nsCreate("prod")}, + changes: []semantic.ResourceChange{createChange("app", "v1")}, want: true, }, { @@ -998,7 +563,7 @@ func TestPlan_HasEffects(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(tt.header, tt.helmAction, tt.changes, tt.tasks, nil) + p, err := semantic.New(tt.header, tt.helmAction, tt.changes, tt.tasks) require.NoError(t, err) assert.Equal(t, tt.want, p.HasEffects()) }) @@ -1013,7 +578,6 @@ func TestPlan_IsNoOp(t *testing.T) { helmAction semantic.HelmAction changes []semantic.ResourceChange tasks []semantic.TaskPlan - diags []semantic.Diagnostic want bool }{ {name: "helm none", helmAction: semantic.HelmNone, want: true}, @@ -1024,26 +588,16 @@ func TestPlan_IsNoOp(t *testing.T) { helmAction: semantic.HelmInstall, }, { - name: "helm none with limitation diagnostic", - helmAction: semantic.HelmNone, - want: true, - diags: []semantic.Diagnostic{{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact", - }}, - }, - { - name: "install with origin namespace", + name: "install with a resource change", header: semantic.Header{FreshInstall: true}, helmAction: semantic.HelmInstall, - changes: []semantic.ResourceChange{nsCreate("prod")}, + changes: []semantic.ResourceChange{createChange("app", "v1")}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(tt.header, tt.helmAction, tt.changes, tt.tasks, tt.diags) + p, err := semantic.New(tt.header, tt.helmAction, tt.changes, tt.tasks) require.NoError(t, err) assert.Equal(t, tt.want, p.IsNoOp()) }) diff --git a/internal/plan/semantic/task_test.go b/internal/plan/semantic/task_test.go index 168ea72..35d8ea6 100644 --- a/internal/plan/semantic/task_test.go +++ b/internal/plan/semantic/task_test.go @@ -172,13 +172,75 @@ func TestNew_TaskValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, tt.tasks, nil) + _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, tt.tasks) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) }) } } +func TestNew_HookDefinitionFields(t *testing.T) { + t.Parallel() + app := ref("ConfigMap", "app") + before := &semantic.ResourceSnapshot{Object: cm("app", "v1")} + after := &semantic.ResourceSnapshot{Object: cm("app", "v2")} + fields := []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "v1", After: "v2"}} + task := func(def semantic.HookDefinition) []semantic.TaskPlan { + return []semantic.TaskPlan{{ + Name: "migrate", + Phase: semantic.TaskPreDeploy, + Action: semantic.TaskUpdate, + WillRun: true, + Definitions: []semantic.HookDefinition{def}, + }} + } + tests := []struct { + name string + def semantic.HookDefinition + wantErr string + }{ + { + name: "create with fields", + def: semantic.HookDefinition{Resource: app, Action: semantic.Create, After: after, Fields: fields}, + wantErr: "create must not have field changes", + }, + { + name: "update without fields", + def: semantic.HookDefinition{Resource: app, Action: semantic.Update, Before: before, After: after}, + wantErr: "update requires a field change", + }, + { + name: "delete with fields", + def: semantic.HookDefinition{Resource: app, Action: semantic.Delete, Before: before, Fields: fields}, + wantErr: "delete must not have field changes", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, task(tt.def)) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } + + t.Run("update keeps caller fields", func(t *testing.T) { + t.Parallel() + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, task(semantic.HookDefinition{ + Resource: app, + Action: semantic.Update, + Before: before, + After: after, + Fields: fields, + })) + require.NoError(t, err) + require.Len(t, p.Tasks[0].Definitions[0].Fields, 1) + assert.Equal(t, "/data/key", p.Tasks[0].Definitions[0].Fields[0].Path) + assert.Equal(t, "v1", p.Tasks[0].Definitions[0].Fields[0].Before) + assert.Equal(t, "v2", p.Tasks[0].Definitions[0].Fields[0].After) + }) +} + func TestNew_ValidScheduleReference(t *testing.T) { t.Parallel() app := ref("ConfigMap", "app") @@ -187,7 +249,7 @@ func TestNew_ValidScheduleReference(t *testing.T) { Phase: semantic.TaskSchedule, Action: semantic.TaskCreate, Resources: []semantic.ResourceRef{app}, - }}, nil) + }}) require.NoError(t, err) require.Len(t, p.Tasks, 1) require.Len(t, p.Tasks[0].Resources, 1) @@ -208,7 +270,7 @@ func TestNew_SummaryIgnoresHookDefinitions(t *testing.T) { Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: cm("migrate", "v1")}, }}, - }}, nil) + }}) require.NoError(t, err) assert.Equal(t, 0, p.Summary.Total()) require.Len(t, p.Tasks, 1) @@ -224,7 +286,7 @@ func TestNew_TaskSort(t *testing.T) { {Name: "a", Phase: semantic.TaskPreDeploy, Action: semantic.TaskUnchanged, WillRun: true, HookWeight: 2}, {Name: "smoke", Phase: semantic.TaskPostDeploy, Action: semantic.TaskCreate, WillRun: true}, {Name: "migrate", Phase: semantic.TaskPreDeploy, Action: semantic.TaskUnchanged, WillRun: true, HookWeight: 1}, - }, nil) + }) require.NoError(t, err) require.Len(t, p.Tasks, 5) assert.Equal(t, []string{"migrate", "a", "z", "smoke", "cleanup"}, []string{ @@ -235,9 +297,9 @@ func TestNew_TaskSort(t *testing.T) { func TestNew_NestedTaskOrder(t *testing.T) { t.Parallel() z := createChange("z", "1") - z.ApplyOrder = 1 + z.HelmOrder = 1 a := createChange("a", "1") - a.ApplyOrder = 2 + a.HelmOrder = 2 tests := []struct { name string changes []semantic.ResourceChange @@ -286,7 +348,7 @@ func TestNew_NestedTaskOrder(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, []semantic.TaskPlan{tt.task}, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, []semantic.TaskPlan{tt.task}) require.NoError(t, err) require.Len(t, p.Tasks, 1) gotDefs := make([]string, 0, len(p.Tasks[0].Definitions)) @@ -303,13 +365,13 @@ func TestNew_NestedTaskOrder(t *testing.T) { } } -func TestNew_ApplyOrderThenIdentity(t *testing.T) { +func TestNew_HelmOrderSortsChanges(t *testing.T) { t.Parallel() a := createChange("a", "1") - a.ApplyOrder = 2 + a.HelmOrder = 2 z := createChange("z", "1") - z.ApplyOrder = 1 - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{a, z}, nil, nil) + z.HelmOrder = 1 + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{a, z}, nil) require.NoError(t, err) require.Len(t, p.Changes, 2) assert.Equal(t, "z", p.Changes[0].Resource.Name) @@ -323,7 +385,7 @@ func TestNew_NonNilNestedTaskSlices(t *testing.T) { Phase: semantic.TaskPreDeploy, Action: semantic.TaskUnchanged, WillRun: true, - }}, nil) + }}) require.NoError(t, err) require.NotNil(t, p.Tasks[0].Definitions) assert.Empty(t, p.Tasks[0].Definitions) @@ -333,11 +395,13 @@ func TestNew_NonNilNestedTaskSlices(t *testing.T) { func TestNew_DoesNotAliasCallerTasks(t *testing.T) { t.Parallel() + fields := []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "v1", After: "v2"}} defs := []semantic.HookDefinition{{ Resource: ref("ConfigMap", "env"), Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("env", "v1")}, After: &semantic.ResourceSnapshot{Object: cm("env", "v2")}, + Fields: fields, }} p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, []semantic.TaskPlan{{ Name: "migrate", @@ -345,9 +409,11 @@ func TestNew_DoesNotAliasCallerTasks(t *testing.T) { Action: semantic.TaskUpdate, WillRun: true, Definitions: defs, - }}, nil) + }}) require.NoError(t, err) defs[0].Resource.Name = "mutated" + fields[0].After = "mutated" assert.Equal(t, "env", p.Tasks[0].Definitions[0].Resource.Name) - require.NotEmpty(t, p.Tasks[0].Definitions[0].Fields) + require.Len(t, p.Tasks[0].Definitions[0].Fields, 1) + assert.Equal(t, "v2", p.Tasks[0].Definitions[0].Fields[0].After) } diff --git a/internal/plan/semantic/types.go b/internal/plan/semantic/types.go index 4a3b395..2c05863 100644 --- a/internal/plan/semantic/types.go +++ b/internal/plan/semantic/types.go @@ -21,12 +21,13 @@ import "fmt" type Action int const ( - // Create is a resource that would exist after apply and does not exist - // live. + // Create is a resource present in Desired and absent from Previous. Create Action = iota + 1 - // Update is an in-place write to a live resource. + // Update is a resource present in both Previous and Desired whose + // declared content differs. Update - // Delete is a prune of a live resource. + // Delete means the resource leaves Desired release state. It does not + // claim that Helm or Kubernetes will delete the live object. Delete ) @@ -71,17 +72,17 @@ func actionRank(a Action) int { type HelmAction int const ( - // HelmNone means the semantic plan contains no Helm release-intent - // transition. It does not control whether `deployah deploy` invokes - // Helm. OriginHelm changes and changed or WillRun preDeploy/postDeploy - // tasks are invalid. + // HelmNone means the release intent did not change. The plan must not + // include resource changes. preDeploy and postDeploy tasks must be + // unchanged, and WillRun must be false. HelmNone does not stop + // `deployah deploy` from running Helm. HelmNone HelmAction = iota + 1 - // HelmInstall means the plan's transition is a fresh Helm install. It - // requires [Header.FreshInstall]. + // HelmInstall means the release is new. [Header.FreshInstall] must be + // true. HelmInstall - // HelmUpgrade means the plan's transition is a Helm upgrade. Zero - // resource consequences remain valid when release intent changed and - // Live already matches Desired. + // HelmUpgrade means an existing release changes. The plan can have no + // resource changes when only hooks changed, or when the manifests + // differ only in encoding. HelmUpgrade ) @@ -121,9 +122,15 @@ type Header struct { FreshInstall bool } -// ResourceRef is Kubernetes identity for one change. APIVersion is a -// GroupVersion string (core is "v1"). GenerateName is set only when Name -// is empty. +// ResourceRef names one resource in a change. APIVersion comes from +// the object this change uses. Core resources use "v1". Namespace is the +// effective namespace used to pair Previous and Desired. It is not +// always the namespace written in the snapshot. GenerateName is set +// only when Name is empty. +// +// Sorting and task links compare every field, including APIVersion and +// GenerateName. That comparison is not logical identity. Logical +// identity ignores APIVersion and does not use GenerateName. type ResourceRef struct { APIVersion string Kind string @@ -132,7 +139,9 @@ type ResourceRef struct { GenerateName string } -func (r ResourceRef) identityKey() string { +// refKey is an exact equality key for this reference. It includes +// APIVersion and GenerateName, so it is not logical resource identity. +func (r ResourceRef) refKey() string { return r.APIVersion + "\x00" + r.Kind + "\x00" + r.Namespace + "\x00" + r.Name + "\x00" + r.GenerateName } @@ -143,128 +152,9 @@ func (r ResourceRef) String() string { return r.Kind + "/" + r.Namespace + "/" + r.Name } -// OriginKind classifies how a resource entered the plan. The zero value -// is invalid. -type OriginKind int - -const ( - // OriginHelm is a resource rendered by the Helm release. - OriginHelm OriginKind = iota + 1 - // OriginNamespace is the target Namespace created as part of a - // Helm install. It is invalid with [HelmNone] or [HelmUpgrade]. - OriginNamespace -) - -func (k OriginKind) String() string { - switch k { - case OriginHelm: - return "helm" - case OriginNamespace: - return "namespace" - default: - return fmt.Sprintf("OriginKind(%d)", int(k)) - } -} - -func (k OriginKind) valid() bool { - switch k { - case OriginHelm, OriginNamespace: - return true - default: - return false - } -} - -// ResourceOrigin names the producer of a [ResourceChange]. Helm details -// are required for [OriginHelm] and forbidden for [OriginNamespace]. -type ResourceOrigin struct { - Kind OriginKind - Helm *HelmOrigin -} - -// HelmOrigin is the Helm release that produced a resource change. -type HelmOrigin struct { - Release string - Namespace string -} - -// WriteMethod is the write method on [WriteSemantics]. The zero value is -// invalid. -type WriteMethod int - -const ( - // WriteCreate is Kubernetes create. FieldManager must be empty and - // ForceConflicts must be false. - WriteCreate WriteMethod = iota + 1 - // WriteServerSide is Kubernetes server-side apply. FieldManager - // must be non-empty. ForceConflicts may be true or false. - WriteServerSide -) - -func (m WriteMethod) String() string { - switch m { - case WriteCreate: - return "create" - case WriteServerSide: - return "server_side_apply" - default: - return fmt.Sprintf("WriteMethod(%d)", int(m)) - } -} - -func (m WriteMethod) valid() bool { - switch m { - case WriteCreate, WriteServerSide: - return true - default: - return false - } -} - -// DeletePropagation is Kubernetes deletion propagation. The zero value -// is invalid. -type DeletePropagation int - -const ( - // PropagationBackground is background deletion. - PropagationBackground DeletePropagation = iota + 1 -) - -func (p DeletePropagation) String() string { - switch p { - case PropagationBackground: - return "background" - default: - return fmt.Sprintf("DeletePropagation(%d)", int(p)) - } -} - -func (p DeletePropagation) valid() bool { - return p == PropagationBackground -} - -// WriteSemantics is the write half of [ApplySemantics]. -type WriteSemantics struct { - Method WriteMethod - FieldManager string - ForceConflicts bool -} - -// DeleteSemantics is the delete half of [ApplySemantics]. -type DeleteSemantics struct { - Propagation DeletePropagation -} - -// ApplySemantics is the mutation semantics of a resource consequence. -// Create and Update set Write only. Delete sets Delete only. -type ApplySemantics struct { - Write *WriteSemantics - Delete *DeleteSemantics -} - -// ResourceSnapshot is one full unredacted Kubernetes object. Object is -// unstructured content, not a client type. It is not a JSON rendering -// contract. +// ResourceSnapshot is one Kubernetes object, with secret values still +// present. Object is a plain map, not a typed client object. It is not +// the JSON document the plan view writes. type ResourceSnapshot struct { Object map[string]any } @@ -359,15 +249,19 @@ func (a TaskAction) valid() bool { } } -// TaskPlan is one Deployah task in a [Plan]. preDeploy and postDeploy -// carry [HookDefinition] diffs. schedule references [ResourceChange] -// values in Plan.Changes. A current-only manual task is omitted. A -// current manual task whose previous release was preDeploy, postDeploy, -// or schedule is represented as a deletion of that previous footprint. -// Changing on between schedule and preDeploy or postDeploy is a -// [TaskUpdate] of the current phase. The previous CronJob stays in -// Plan.Changes; previous hook documents are not emitted as Kubernetes -// deletes. +// TaskPlan is one Deployah task in a [Plan]. +// +// A preDeploy or postDeploy task carries [HookDefinition] diffs. A +// schedule task points at [ResourceChange] entries in [Plan.Changes]. +// +// A manual task that exists only in the current spec is left out. If +// the task is manual now, but was preDeploy, postDeploy, or schedule +// in the previous release, the plan shows that old hook or schedule +// as removed. +// +// Moving between schedule and preDeploy or postDeploy is a +// [TaskUpdate] of the phase the task has now. The old CronJob stays +// in [Plan.Changes]. Old hook documents are not listed as deletes. type TaskPlan struct { Name string Phase TaskPhase @@ -384,8 +278,8 @@ type TaskPlan struct { } // HookDefinition is a rendered Helm hook document difference for a -// preDeploy or postDeploy task. It is not a live Kubernetes apply or -// prune. It has no [ApplySemantics] and no [ResourceOrigin]. +// preDeploy or postDeploy task. It compares Previous and Desired hook +// declarations. It is not a live Kubernetes apply or prune. type HookDefinition struct { Resource ResourceRef Action Action diff --git a/internal/plan/semantic/types_test.go b/internal/plan/semantic/types_test.go index faca2a1..803421b 100644 --- a/internal/plan/semantic/types_test.go +++ b/internal/plan/semantic/types_test.go @@ -37,22 +37,10 @@ func TestEnumString(t *testing.T) { {name: "install", got: semantic.HelmInstall.String(), want: "install"}, {name: "upgrade", got: semantic.HelmUpgrade.String(), want: "upgrade"}, {name: "unknown helm action", got: semantic.HelmAction(0).String(), want: "HelmAction(0)"}, - {name: "helm", got: semantic.OriginHelm.String(), want: "helm"}, - {name: "namespace", got: semantic.OriginNamespace.String(), want: "namespace"}, - {name: "unknown origin", got: semantic.OriginKind(0).String(), want: "OriginKind(0)"}, - {name: "create write", got: semantic.WriteCreate.String(), want: "create"}, - {name: "server_side_apply", got: semantic.WriteServerSide.String(), want: "server_side_apply"}, - {name: "unknown write method", got: semantic.WriteMethod(0).String(), want: "WriteMethod(0)"}, - {name: "background", got: semantic.PropagationBackground.String(), want: "background"}, - {name: "unknown delete propagation", got: semantic.DeletePropagation(0).String(), want: "DeletePropagation(0)"}, {name: "field add", got: semantic.FieldAdd.String(), want: "add"}, {name: "field remove", got: semantic.FieldRemove.String(), want: "remove"}, {name: "field replace", got: semantic.FieldReplace.String(), want: "replace"}, {name: "unknown field op", got: semantic.FieldOp(0).String(), want: "FieldOp(0)"}, - {name: "warning", got: semantic.DiagnosticWarning.String(), want: "warning"}, - {name: "unknown severity", got: semantic.DiagnosticSeverity(0).String(), want: "DiagnosticSeverity(0)"}, - {name: "prediction_limitation", got: semantic.CategoryPredictionLimitation.String(), want: "prediction_limitation"}, - {name: "unknown category", got: semantic.DiagnosticCategory(0).String(), want: "DiagnosticCategory(0)"}, {name: "preDeploy", got: semantic.TaskPreDeploy.String(), want: "preDeploy"}, {name: "postDeploy", got: semantic.TaskPostDeploy.String(), want: "postDeploy"}, {name: "schedule", got: semantic.TaskSchedule.String(), want: "schedule"}, diff --git a/internal/plan/semantic_build.go b/internal/plan/semantic_build.go index 47f19f4..4f2becc 100644 --- a/internal/plan/semantic_build.go +++ b/internal/plan/semantic_build.go @@ -19,20 +19,31 @@ import ( "fmt" "helm.sh/helm/v4/pkg/postrenderer" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" "deployah.dev/deployah/internal/extras" "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" ) -// SemanticBuildClient is the Helm surface [BuildSemanticPlan] needs: -// one prep-aware client-side render. It does not expose release-history -// lookups. Defined narrowly so this package does not depend on -// [deployah.dev/deployah/internal/session] and tests can inject a -// minimal fake. +// RESTMapper maps a GroupKind and version to a REST mapping. +// [BuildSemanticPlan] uses it only to learn scope and whether a +// resource can be built. It does not read live objects or send writes. +// [meta.RESTMapper] satisfies it. +type RESTMapper interface { + RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) +} + +var _ RESTMapper = meta.RESTMapper(nil) + +// SemanticBuildClient is the one render [BuildSemanticPlan] calls. +// It returns the render and its prep. A real Helm client looks up +// release history in that call and returns Previous through the prep. +// The interface stays small so this package does not import +// [deployah.dev/deployah/internal/session], and tests can pass a fake. type SemanticBuildClient interface { RenderManifestsWithPrep( ctx context.Context, @@ -64,30 +75,34 @@ type SemanticBuildInput struct { SkipCRDs bool } -// BuildSemanticPlan renders [spec.ResolvedSpec], derives Helm's action -// from Previous and Desired release intent, and returns a [semantic.Plan]. -// It uses the [helm.ReleasePrep] from -// [SemanticBuildClient.RenderManifestsWithPrep] and does not look up Helm -// history itself. PostRenderer, when non-nil, is forwarded once to the -// render client. +// BuildSemanticPlan renders the spec and returns the Helm release +// intent and the resource changes from Previous to Desired. +// +// It calls [SemanticBuildClient.RenderManifestsWithPrep] unchanged. A +// real Helm client reads release history, renders Desired with a +// client-side dry-run, and may use Helm discovery. After that, mapper +// is used only to learn scope and whether each resource can be built. +// That step does not read live objects or send create, update, patch, +// or delete requests. +// +// Previous is the current release manifest, empty on install. Desired +// is the render. Only Desired is [semantic.Create]. Only Previous is +// [semantic.Delete]: it leaves Desired release state. A resource in +// both that differs is [semantic.Update]. Equal ones are omitted. +// +// The plan fails if the render declares the target namespace, or if +// the previous release does on upgrade. Other namespaces stay ordinary +// changes. // -// The plan's HelmAction is [semantic.HelmInstall] on a fresh install, or -// [semantic.HelmUpgrade] when the Previous release intent differs from the -// rendered Desired manifest or hooks. An unchanged release is -// [semantic.HelmNone] and runs no Namespace or Helm write prediction. When -// the plan has a Helm transition, the install target Namespace is predicted -// on cluster first, then Helm [predict.Predict] runs against a plan-local -// wrapper so a same-deploy missing target namespace is not fatal. Chart CRD -// files are forwarded to Helm only; they are not parsed into resource -// changes. Presentation identity comes from [SemanticBuildInput.CRDDocs]. +// Chart CRD files go to Helm and are recorded as lifecycle, not as +// resource changes. Identity comes from [SemanticBuildInput.CRDDocs]. // -// The caller must invoke the returned cleanup func once. Cleanup is -// always non-nil. On error the plan is empty and the render result is -// nil. +// Call the returned cleanup once. It is never nil. On error the plan +// is empty and the render result is nil. func BuildSemanticPlan( ctx context.Context, client SemanticBuildClient, - cluster predict.Cluster, + mapper RESTMapper, input SemanticBuildInput, ) (semantic.Plan, *render.RenderResult, func(), error) { cleanup := func() {} @@ -97,8 +112,8 @@ func BuildSemanticPlan( if input.Resolved == nil || input.Resolved.Spec == nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("semantic plan requires resolved spec; call spec.Resolve first") } - if cluster == nil { - return semantic.Plan{}, nil, cleanup, fmt.Errorf("semantic plan requires a cluster") + if mapper == nil { + return semantic.Plan{}, nil, cleanup, fmt.Errorf("semantic plan requires a REST mapper") } result, prep, renderCleanup, err := client.RenderManifestsWithPrep(ctx, input.Resolved, input.PostRenderer, input.CRDs) @@ -118,10 +133,9 @@ func BuildSemanticPlan( return semantic.Plan{}, nil, cleanup, fmt.Errorf("determine helm release intent: %w", err) } - if prep.Operation == helm.OperationInstall { - if overlapErr := checkInstallNamespaceOverlap(result.Manifest, result.Namespace); overlapErr != nil { - return semantic.Plan{}, nil, cleanup, overlapErr - } + changes, err := declaredChanges(mapper, prep, result) + if err != nil { + return semantic.Plan{}, nil, cleanup, err } header := semantic.Header{ @@ -133,34 +147,12 @@ func BuildSemanticPlan( Revision: prep.NextRevision, FreshInstall: prep.Operation == helm.OperationInstall, } - origin := semantic.ResourceOrigin{ - Kind: semantic.OriginHelm, - Helm: &semantic.HelmOrigin{ - Release: header.Release, - Namespace: header.Namespace, - }, - } - - var nsChange *semantic.ResourceChange - var helmChanges []semantic.ResourceChange - var diags []semantic.Diagnostic - if helmAction != semantic.HelmNone { - nsChange, helmChanges, diags, err = predictWriteConsequences(ctx, cluster, prep, result, origin) - if err != nil { - return semantic.Plan{}, nil, cleanup, err - } - } - var changes []semantic.ResourceChange - if nsChange != nil { - changes = append(changes, *nsChange) - } - changes = append(changes, helmChanges...) - tasks, err := assembleTasks(input.Resolved, prep, result.Hooks, helmChanges) + tasks, err := assembleTasks(input.Resolved, prep, result.Hooks, changes) if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) } stampTaskWillRun(tasks, helmAction) - p, err := semantic.New(header, helmAction, changes, tasks, diags) + p, err := semantic.New(header, helmAction, changes, tasks) if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) } @@ -171,38 +163,46 @@ func BuildSemanticPlan( return p, result, cleanup, nil } -func predictWriteConsequences( - ctx context.Context, - cluster predict.Cluster, - prep helm.ReleasePrep, - result *render.RenderResult, - origin semantic.ResourceOrigin, -) (*semantic.ResourceChange, []semantic.ResourceChange, []semantic.Diagnostic, error) { - nsChange, missingNS, err := predictNamespace(ctx, cluster, prep.Operation, result.Namespace) +func declaredChanges(mapper RESTMapper, prep helm.ReleasePrep, result *render.RenderResult) ([]semantic.ResourceChange, error) { + desiredObjs, err := flattenManifest(result.Manifest) if err != nil { - return nil, nil, nil, err + return nil, fmt.Errorf("rendered manifest: %w", err) } - - predInput, err := predict.InputFromPrep(&prep, result.ReleaseName, result.Namespace, result.Manifest) + if ownErr := checkTargetNamespaceOwnership("rendered manifest", desiredObjs, result.Namespace); ownErr != nil { + return nil, ownErr + } + desired, err := declareAll(mapper, desiredObjs, result.Namespace, "rendered manifest") if err != nil { - return nil, nil, nil, fmt.Errorf("build prediction input: %w", err) + return nil, err } - wrapper := newPrereqCluster(cluster, missingNS, result.Namespace) - results, err := predict.Predict(ctx, wrapper, predInput) - if err != nil { - return nil, nil, nil, fmt.Errorf("predict resources: %w", err) + var previous []declaration + if prep.Operation == helm.OperationUpgrade { + if prep.Current == nil { + return nil, fmt.Errorf("upgrade requires the previous release") + } + side := fmt.Sprintf("previous release revision %d", prep.Current.Version) + previousObjs, flatErr := flattenManifest(prep.Current.Manifest) + if flatErr != nil { + return nil, fmt.Errorf("%s: %w", side, flatErr) + } + if ownErr := checkTargetNamespaceOwnership(side, previousObjs, result.Namespace); ownErr != nil { + return nil, ownErr + } + previous, err = declareAll(mapper, previousObjs, result.Namespace, side) + if err != nil { + return nil, err + } } - helmChanges, diags, err := mapPredictResults(origin, results) + changes, err := diffDeclared(previous, desired) if err != nil { - return nil, nil, nil, fmt.Errorf("assemble semantic plan: %w", err) + return nil, err } - diags = append(diags, limitationDiagnostics(wrapper, results)...) - if orderErr := stampHelmApplyOrder(helmChanges); orderErr != nil { - return nil, nil, nil, fmt.Errorf("assemble semantic plan: %w", orderErr) + if err = stampHelmOrder(changes); err != nil { + return nil, fmt.Errorf("assemble semantic plan: %w", err) } - return nsChange, helmChanges, diags, nil + return changes, nil } func chartCRDsFromDocs(docs []extras.CRDDoc, op helm.Operation, skipCRDs bool) []semantic.ChartCRD { diff --git a/internal/plan/semantic_build_crd_test.go b/internal/plan/semantic_build_crd_test.go new file mode 100644 index 0000000..a78d0f7 --- /dev/null +++ b/internal/plan/semantic_build_crd_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/helm" + "deployah.dev/deployah/internal/plan" + "deployah.dev/deployah/internal/plan/semantic" +) + +func TestBuildSemanticPlan_CRDFilesDoNotProduceResourceChanges(t *testing.T) { + t.Parallel() + crd := extras.RawFile{Path: "widgets.yaml", Raw: []byte("apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\n")} + rawCopy := append([]byte(nil), crd.Raw...) + client := installClient(configMapYAML("app", "prod", "v1")) + in := buildInput("ctx", resolvedSpec(), nil) + in.CRDs = []extras.RawFile{crd} + in.CRDDocs = []extras.CRDDoc{{ + Path: "/abs/.deployah/crds/widgets.yaml", + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + }} + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), in) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, rawCopy, in.CRDs[0].Raw) + assert.Equal(t, crd.Raw, client.gotCRDs[0].Raw) + for _, c := range p.Changes { + assert.NotEqual(t, "CustomResourceDefinition", c.Resource.Kind) + } + require.Len(t, p.ChartCRDs, 1) + assert.Equal(t, semantic.ChartCRDProcess, p.ChartCRDs[0].Lifecycle) + assert.True(t, p.ChartCRDs[0].WillProcess) + assert.Equal(t, "widgets.example.com", p.ChartCRDs[0].Name) +} + +func TestBuildSemanticPlan_ChartCRDLifecycle(t *testing.T) { + t.Parallel() + docs := []extras.CRDDoc{{ + Path: "/abs/.deployah/crds/widget.yaml", + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + }} + tests := []struct { + name string + op helm.Operation + skip bool + wantLife semantic.ChartCRDLifecycle + wantProcess bool + wantAction semantic.HelmAction + wantChanges int + wantEffects bool + wantNoOp bool + }{ + {name: "fresh install", op: helm.OperationInstall, wantLife: semantic.ChartCRDProcess, wantProcess: true, wantAction: semantic.HelmInstall, wantChanges: 1, wantEffects: true}, + {name: "fresh skip", op: helm.OperationInstall, skip: true, wantLife: semantic.ChartCRDSkip, wantAction: semantic.HelmInstall, wantChanges: 1, wantEffects: true}, + {name: "upgrade", op: helm.OperationUpgrade, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone, wantNoOp: true}, + {name: "upgrade ignores skip", op: helm.OperationUpgrade, skip: true, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone, wantNoOp: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + var client *fakeBuildClient + switch tc.op { + case helm.OperationInstall: + client = installClient(configMapYAML("app", "prod", "v1")) + case helm.OperationUpgrade: + client = upgradeClient(manifest, manifest, 4) + } + in := buildInput("ctx", resolvedSpec(), nil) + in.CRDDocs = docs + in.SkipCRDs = tc.skip + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), in) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, tc.wantAction, p.HelmAction) + require.Len(t, p.ChartCRDs, 1) + assert.Equal(t, ".deployah/crds/widget.yaml", p.ChartCRDs[0].Source) + assert.Equal(t, "CustomResourceDefinition", p.ChartCRDs[0].Kind) + assert.Equal(t, "widgets.example.com", p.ChartCRDs[0].Name) + assert.Equal(t, tc.wantLife, p.ChartCRDs[0].Lifecycle) + assert.Equal(t, tc.wantProcess, p.ChartCRDs[0].WillProcess) + assert.Len(t, p.Changes, tc.wantChanges) + assert.Equal(t, tc.wantEffects, p.HasEffects()) + assert.Equal(t, tc.wantNoOp, p.IsNoOp()) + for _, c := range p.Changes { + assert.NotEqual(t, "CustomResourceDefinition", c.Resource.Kind) + } + }) + } +} + +func TestBuildSemanticPlan_OmittedLiveCRDIsNotDeleted(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + p := mustBuild(t, upgradeClient(manifest, manifest, 4), newMapper()) + assert.Empty(t, p.Changes) + for _, c := range p.Changes { + assert.NotEqual(t, "CustomResourceDefinition", c.Resource.Kind) + } +} diff --git a/internal/plan/semantic_build_test.go b/internal/plan/semantic_build_test.go index 369c3f2..2cd98a0 100644 --- a/internal/plan/semantic_build_test.go +++ b/internal/plan/semantic_build_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Deployah Authors +// Copyright 2026 The Deployah Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -33,12 +33,10 @@ import ( "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan" "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" v1 "helm.sh/helm/v4/pkg/release/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" ) type ctxKey struct{} @@ -56,6 +54,7 @@ type fakeBuildClient struct { err error gotCtx context.Context gotRenderer postrenderer.PostRenderer + gotCRDs []extras.RawFile calls int } @@ -63,168 +62,73 @@ func (f *fakeBuildClient) RenderManifestsWithPrep( ctx context.Context, _ *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, - _ []extras.RawFile, + crds []extras.RawFile, ) (*render.RenderResult, helm.ReleasePrep, func(), error) { f.calls++ f.gotCtx = ctx f.gotRenderer = postRenderer + f.gotCRDs = crds return f.result, f.prep, f.cleanup, f.err } -type recordedApply struct { - Obj *unstructured.Unstructured - Opts predict.ApplyOptions +type fakeRESTMapper struct { + cluster map[schema.GroupKind]bool + err map[schema.GroupKind]error + calls []schema.GroupVersionKind } -type fakeCluster struct { - objects map[string]*unstructured.Unstructured - getErr map[string]error - mappingErr map[schema.GroupVersionKind]error - applyErr error - applyErrGVK map[schema.GroupVersionKind]error - createErr error - gotCtx context.Context - gets int - creates []*unstructured.Unstructured - applies []recordedApply - deletes []predict.Identity -} - -func newFakeCluster() *fakeCluster { - return &fakeCluster{ - objects: make(map[string]*unstructured.Unstructured), - getErr: make(map[string]error), - mappingErr: make(map[schema.GroupVersionKind]error), - applyErrGVK: make(map[schema.GroupVersionKind]error), - } -} - -func readyCluster() *fakeCluster { - cluster := newFakeCluster() - seedConvergedNamespace(cluster, "prod") - return cluster -} - -func seedConvergedNamespace(cluster *fakeCluster, namespace string) { - cluster.store(&unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": namespace, - "labels": map[string]any{"name": namespace}, +func newMapper() *fakeRESTMapper { + return &fakeRESTMapper{ + cluster: map[schema.GroupKind]bool{ + {Kind: "Namespace"}: true, + {Group: "rbac.authorization.k8s.io", Kind: "ClusterRole"}: true, + {Group: "apiextensions.k8s.io", Kind: "CustomResourceDefinition"}: true, }, - }}) -} - -func (f *fakeCluster) store(obj *unstructured.Unstructured) { - f.objects[clusterKey(identityOf(obj))] = obj.DeepCopy() -} - -func (f *fakeCluster) Get(ctx context.Context, id predict.Identity) (*unstructured.Unstructured, error) { - f.gotCtx = ctx - f.gets++ - if err, ok := f.getErr[clusterKey(id)]; ok { - return nil, err + err: map[schema.GroupKind]error{}, } - obj, ok := f.objects[clusterKey(id)] - if !ok { - return nil, apierrors.NewNotFound(schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, id.Name) - } - return obj.DeepCopy(), nil } -func (f *fakeCluster) Create(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - f.gotCtx = ctx - f.creates = append(f.creates, obj.DeepCopy()) - if f.createErr != nil { - return nil, f.createErr +func (f *fakeRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + version := "" + if len(versions) > 0 { + version = versions[0] } - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) Apply(ctx context.Context, obj *unstructured.Unstructured, opts predict.ApplyOptions) (*unstructured.Unstructured, error) { - f.gotCtx = ctx - f.applies = append(f.applies, recordedApply{Obj: obj.DeepCopy(), Opts: opts}) - if err, ok := f.applyErrGVK[obj.GroupVersionKind()]; ok { - return nil, err - } - if f.applyErr != nil { - return nil, f.applyErr - } - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) JSONPatch(context.Context, predict.Identity, []byte) error { - return nil -} - -func (f *fakeCluster) Delete(_ context.Context, id predict.Identity) error { - f.deletes = append(f.deletes, id) - return nil -} - -func (f *fakeCluster) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { - if err, ok := f.mappingErr[gvk]; ok { + f.calls = append(f.calls, gk.WithVersion(version)) + if err, ok := f.err[gk]; ok { return nil, err } scope := meta.RESTScopeNamespace - if gvk.Group == "" && gvk.Kind == "Namespace" { - scope = meta.RESTScopeRoot - } - if gvk.Kind == "CustomResourceDefinition" { + if f.cluster[gk] { scope = meta.RESTScopeRoot } return &meta.RESTMapping{ - Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"}, - GroupVersionKind: gvk, + Resource: schema.GroupVersionResource{Group: gk.Group, Version: version, Resource: strings.ToLower(gk.Kind) + "s"}, + GroupVersionKind: gk.WithVersion(version), Scope: scope, }, nil } -func clusterKey(id predict.Identity) string { - gv := schema.GroupVersion{Group: id.Group, Version: id.Version} - return fmt.Sprintf("%s/%s/%s/%s", gv.String(), id.Kind, id.Namespace, id.Name) -} - -func identityOf(obj *unstructured.Unstructured) predict.Identity { - gvk := obj.GroupVersionKind() - return predict.Identity{ - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), +func configMapYAML(name, namespace, data string) string { + ns := "" + if namespace != "" { + ns = "\n namespace: " + namespace } + return fmt.Sprintf("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s%s\ndata:\n key: %s\n", name, ns, data) } -func configMapYAML(name, namespace, data string) string { - return fmt.Sprintf(`apiVersion: v1 -kind: ConfigMap -metadata: - name: %s - namespace: %s -data: - key: %s -`, name, namespace, data) +func kindYAML(apiVersion, kind, name, namespace, body string) string { + ns := "" + if namespace != "" { + ns = "\n namespace: " + namespace + } + if body != "" { + body = "\n" + body + } + return fmt.Sprintf("apiVersion: %s\nkind: %s\nmetadata:\n name: %s%s%s\n", apiVersion, kind, name, ns, body) } -func ownedConfigMap(name, namespace, release, data string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - "labels": map[string]any{ - "app.kubernetes.io/managed-by": "Helm", - }, - "annotations": map[string]any{ - "meta.helm.sh/release-name": release, - "meta.helm.sh/release-namespace": namespace, - }, - }, - "data": map[string]any{"key": data}, - }} +func joinManifests(docs ...string) string { + return strings.Join(docs, "---\n") } func resolvedSpec() *spec.ResolvedSpec { @@ -254,13 +158,26 @@ func upgradeResult(manifest string, revision int) *render.RenderResult { } } -func hasFieldPath(fields []semantic.FieldChange, path string) bool { - for _, f := range fields { - if f.Path == path { - return true - } +func installClient(manifest string) *fakeBuildClient { + return &fakeBuildClient{ + result: installResult(manifest), + prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, + cleanup: func() {}, + } +} + +func upgradeClient(previous, desired string, revision int) *fakeBuildClient { + current := &v1.Release{Version: revision - 1, Manifest: previous} + return &fakeBuildClient{ + result: upgradeResult(desired, revision), + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: revision, + }, + cleanup: func() {}, } - return false } func buildInput(clusterContext string, resolved *spec.ResolvedSpec, post postrenderer.PostRenderer) plan.SemanticBuildInput { @@ -271,10 +188,28 @@ func buildInput(clusterContext string, resolved *spec.ResolvedSpec, post postren } } +func mustBuild(t *testing.T, client *fakeBuildClient, mapper plan.RESTMapper) semantic.Plan { + t.Helper() + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + return p +} + +func changeByName(t *testing.T, changes []semantic.ResourceChange, name string) semantic.ResourceChange { + t.Helper() + for _, c := range changes { + if c.Resource.Name == name || c.Resource.GenerateName == name { + return c + } + } + t.Fatalf("missing change %q", name) + return semantic.ResourceChange{} +} + func TestBuildSemanticPlan_RequiresClient(t *testing.T) { t.Parallel() - - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), nil, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), nil, newMapper(), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "semantic plan requires a helm client") @@ -286,33 +221,33 @@ func TestBuildSemanticPlan_RequiresInput(t *testing.T) { t.Parallel() tests := []struct { name string - cluster predict.Cluster + mapper plan.RESTMapper in plan.SemanticBuildInput wantErr string }{ { name: "nil resolved", - cluster: newFakeCluster(), + mapper: newMapper(), in: buildInput("ctx", nil, nil), wantErr: "semantic plan requires resolved spec; call spec.Resolve first", }, { name: "nil spec", - cluster: newFakeCluster(), + mapper: newMapper(), in: buildInput("ctx", &spec.ResolvedSpec{}, nil), wantErr: "semantic plan requires resolved spec; call spec.Resolve first", }, { - name: "nil cluster", + name: "nil mapper", in: buildInput("ctx", resolvedSpec(), nil), - wantErr: "semantic plan requires a cluster", + wantErr: "semantic plan requires a REST mapper", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() client := &fakeBuildClient{} - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, tt.cluster, tt.in) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, tt.mapper, tt.in) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -323,21 +258,16 @@ func TestBuildSemanticPlan_RequiresInput(t *testing.T) { } } -func TestBuildSemanticPlan_FreshInstall(t *testing.T) { +func TestBuildSemanticPlan_FreshInstallCreatesDesired(t *testing.T) { t.Parallel() - - result := installResult(configMapYAML("app", "prod", "v1")) + manifest := joinManifests(configMapYAML("app", "prod", "v1"), configMapYAML("extra", "prod", "v1")) var post postrenderer.PostRenderer = &identityPostRenderer{} var cleanups int - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() { cleanups++ }, - } + client := installClient(manifest) + client.cleanup = func() { cleanups++ } ctx := context.WithValue(t.Context(), ctxKey{}, "pipeline") - cluster := readyCluster() - p, got, cleanup, err := plan.BuildSemanticPlan(ctx, client, cluster, buildInput("kind-dev", resolvedSpec(), post)) + p, got, cleanup, err := plan.BuildSemanticPlan(ctx, client, newMapper(), buildInput("kind-dev", resolvedSpec(), post)) require.NotNil(t, cleanup) t.Cleanup(func() { if cleanups == 0 { @@ -345,10 +275,9 @@ func TestBuildSemanticPlan_FreshInstall(t *testing.T) { } }) require.NoError(t, err) - assert.Same(t, result, got) + assert.Same(t, client.result, got) assert.Equal(t, post, client.gotRenderer) assert.Equal(t, "pipeline", client.gotCtx.Value(ctxKey{})) - assert.Equal(t, "pipeline", cluster.gotCtx.Value(ctxKey{})) assert.Equal(t, "shop", p.Header.Project) assert.Equal(t, "prod", p.Header.Environment) assert.Equal(t, "web", p.Header.Release) @@ -357,63 +286,576 @@ func TestBuildSemanticPlan_FreshInstall(t *testing.T) { assert.Equal(t, 1, p.Header.Revision) assert.True(t, p.Header.FreshInstall) assert.Equal(t, semantic.HelmInstall, p.HelmAction) - require.Len(t, p.Changes, 1) - assert.Equal(t, semantic.Create, p.Changes[0].Action) - assert.Equal(t, "app", p.Changes[0].Resource.Name) - assert.Nil(t, p.Changes[0].Before) - require.NotNil(t, p.Changes[0].After) + require.Len(t, p.Changes, 2) + for _, c := range p.Changes { + assert.Equal(t, semantic.Create, c.Action) + assert.Nil(t, c.Before) + require.NotNil(t, c.After) + assert.Empty(t, c.Fields) + _, found, nestErr := unstructured.NestedFieldNoCopy(c.After.Object, "metadata", "annotations") + require.NoError(t, nestErr) + assert.False(t, found) + } assert.Equal(t, 0, cleanups) cleanup() assert.Equal(t, 1, cleanups) } -func TestBuildSemanticPlan_Upgrade(t *testing.T) { +func TestBuildSemanticPlan_NoResourceChange(t *testing.T) { t.Parallel() - current := &v1.Release{Version: 6, Manifest: configMapYAML("app", "prod", "old")} - result := upgradeResult(configMapYAML("app", "prod", "new"), 7) - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 7, + same := configMapYAML("app", "prod", "same") + bookkeeping := strings.Replace(same, "metadata:\n", "metadata:\n creationTimestamp: null\n", 1) + bookkeeping += "status:\n observedGeneration: 1\n" + tests := []struct { + name string + previous string + desired string + wantHelm semantic.HelmAction + wantNoOp bool + }{ + { + name: "identical release", + previous: same, + desired: same, + wantHelm: semantic.HelmNone, + wantNoOp: true, + }, + { + name: "bookkeeping only", + previous: same, + desired: bookkeeping, + wantHelm: semantic.HelmUpgrade, + }, + { + name: "omitted namespace matches explicit release namespace", + previous: configMapYAML("app", "", "v1"), + desired: configMapYAML("app", "prod", "v1"), + wantHelm: semantic.HelmUpgrade, }, - cleanup: func() {}, } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "old")) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := mustBuild(t, upgradeClient(tt.previous, tt.desired, 4), newMapper()) + assert.Equal(t, tt.wantHelm, p.HelmAction) + assert.Empty(t, p.Changes) + assert.False(t, p.HasEffects()) + assert.Equal(t, tt.wantNoOp, p.IsNoOp()) + }) + } +} - p, got, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("kind-dev", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Same(t, result, got) - assert.Equal(t, "kind-dev", p.Header.Context) - assert.Equal(t, 7, p.Header.Revision) - assert.False(t, p.Header.FreshInstall) +func TestBuildSemanticPlan_FieldChangeIsUpdate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + revision int + }{ + {name: "records the field change", revision: 7}, + {name: "previous change is an update", revision: 4}, + {name: "failed-only history is still an upgrade", revision: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client := upgradeClient(configMapYAML("app", "prod", "old"), configMapYAML("app", "prod", "new"), tt.revision) + p := mustBuild(t, client, newMapper()) + assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) + assert.Equal(t, tt.revision, p.Header.Revision) + assert.False(t, p.Header.FreshInstall) + require.Len(t, p.Changes, 1) + c := p.Changes[0] + assert.Equal(t, semantic.Update, c.Action) + assert.Equal(t, "v1", c.Resource.APIVersion) + require.NotNil(t, c.Before) + require.NotNil(t, c.After) + beforeKey, found, err := unstructured.NestedString(c.Before.Object, "data", "key") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "old", beforeKey) + afterKey, found, err := unstructured.NestedString(c.After.Object, "data", "key") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "new", afterKey) + require.Len(t, c.Fields, 1) + assert.Equal(t, "/data/key", c.Fields[0].Path) + assert.Equal(t, semantic.FieldReplace, c.Fields[0].Op) + assert.Equal(t, "old", c.Fields[0].Before) + assert.Equal(t, "new", c.Fields[0].After) + + again := mustBuild(t, upgradeClient(configMapYAML("app", "prod", "old"), configMapYAML("app", "prod", "new"), tt.revision), newMapper()) + assert.Equal(t, p.Changes[0].Fields, again.Changes[0].Fields) + }) + } +} + +func TestBuildSemanticPlan_AddedAndRemoved(t *testing.T) { + t.Parallel() + goneKeep := configMapYAML("gone", "prod", "v1") + goneKeep = strings.Replace(goneKeep, "metadata:\n", "metadata:\n annotations:\n helm.sh/resource-policy: keep\n", 1) + previous := joinManifests(configMapYAML("kept", "prod", "v1"), goneKeep) + desired := joinManifests(configMapYAML("kept", "prod", "v1"), configMapYAML("added", "prod", "v1")) + p := mustBuild(t, upgradeClient(previous, desired, 4), newMapper()) assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Changes, 1) - assert.Equal(t, semantic.Update, p.Changes[0].Action) - require.NotNil(t, p.Changes[0].Before) - require.NotNil(t, p.Changes[0].After) - beforeData, ok := p.Changes[0].Before.Object["data"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "old", beforeData["key"]) - afterData, ok := p.Changes[0].After.Object["data"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "new", afterData["key"]) - assert.True(t, hasFieldPath(p.Changes[0].Fields, "/data/key")) + added := changeByName(t, p.Changes, "added") + assert.Equal(t, semantic.Create, added.Action) + assert.Nil(t, added.Before) + gone := changeByName(t, p.Changes, "gone") + assert.Equal(t, semantic.Delete, gone.Action) + assert.Nil(t, gone.After) + policy, found, err := unstructured.NestedString(gone.Before.Object, "metadata", "annotations", "helm.sh/resource-policy") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "keep", policy) + for _, c := range p.Changes { + assert.NotEqual(t, "kept", c.Resource.Name) + } +} + +func TestBuildSemanticPlan_APIVersionScopeAndSnapshots(t *testing.T) { + t.Parallel() + hpa := func(version string, replicas int) string { + return fmt.Sprintf(`apiVersion: autoscaling/%s +kind: HorizontalPodAutoscaler +metadata: + name: app + namespace: prod +spec: + maxReplicas: %d + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: app +`, version, replicas) + } + t.Run("apiVersion only", func(t *testing.T) { + t.Parallel() + p := mustBuild(t, upgradeClient(hpa("v2beta2", 2), hpa("v2", 2), 4), newMapper()) + require.Len(t, p.Changes, 1) + c := p.Changes[0] + assert.Equal(t, semantic.Update, c.Action) + assert.Equal(t, "autoscaling/v2", c.Resource.APIVersion) + assert.Equal(t, "prod", c.Resource.Namespace) + require.Len(t, c.Fields, 1) + assert.Equal(t, "/apiVersion", c.Fields[0].Path) + assert.Equal(t, "autoscaling/v2beta2", c.Fields[0].Before) + assert.Equal(t, "autoscaling/v2", c.Fields[0].After) + }) + t.Run("other field ignores injected namespace", func(t *testing.T) { + t.Parallel() + p := mustBuild(t, upgradeClient(configMapYAML("app", "", "old"), configMapYAML("app", "prod", "new"), 4), newMapper()) + require.Len(t, p.Changes, 1) + c := p.Changes[0] + assert.Equal(t, "prod", c.Resource.Namespace) + require.Len(t, c.Fields, 1) + assert.Equal(t, "/data/key", c.Fields[0].Path) + _, beforeFound, err := unstructured.NestedFieldNoCopy(c.Before.Object, "metadata", "namespace") + require.NoError(t, err) + _, afterFound, err := unstructured.NestedFieldNoCopy(c.After.Object, "metadata", "namespace") + require.NoError(t, err) + assert.False(t, beforeFound) + assert.True(t, afterFound) + }) + t.Run("cluster scope ignores identical stray namespace", func(t *testing.T) { + t.Parallel() + role := func(ns, rule string) string { + return fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: view + namespace: %s +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["%s"] +`, ns, rule) + } + same := mustBuild(t, upgradeClient(role("stray", "get"), role("stray", "get"), 4), newMapper()) + assert.Empty(t, same.Changes) + changed := mustBuild(t, upgradeClient(role("stray", "get"), role("stray", "list"), 4), newMapper()) + require.Len(t, changed.Changes, 1) + c := changed.Changes[0] + assert.Empty(t, c.Resource.Namespace) + beforeNS, _, err := unstructured.NestedString(c.Before.Object, "metadata", "namespace") + require.NoError(t, err) + afterNS, _, err := unstructured.NestedString(c.After.Object, "metadata", "namespace") + require.NoError(t, err) + assert.Equal(t, "stray", beforeNS) + assert.Equal(t, "stray", afterNS) + for _, f := range c.Fields { + assert.NotEqual(t, "/metadata/namespace", f.Path) + } + }) + t.Run("cluster scope namespace change is a field change", func(t *testing.T) { + t.Parallel() + role := func(ns string) string { + return fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: view + namespace: %s +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get"] +`, ns) + } + p := mustBuild(t, upgradeClient(role("one"), role("two"), 4), newMapper()) + require.Len(t, p.Changes, 1) + c := p.Changes[0] + assert.Equal(t, semantic.Update, c.Action) + assert.Empty(t, c.Resource.Namespace) + require.Len(t, c.Fields, 1) + assert.Equal(t, "/metadata/namespace", c.Fields[0].Path) + }) + t.Run("different namespaces are different resources", func(t *testing.T) { + t.Parallel() + p := mustBuild(t, upgradeClient(configMapYAML("app", "prod", "v1"), configMapYAML("app", "other", "v1"), 4), newMapper()) + require.Len(t, p.Changes, 2) + var actions []semantic.Action + for _, c := range p.Changes { + actions = append(actions, c.Action) + assert.Equal(t, "app", c.Resource.Name) + } + assert.ElementsMatch(t, []semantic.Action{semantic.Delete, semantic.Create}, actions) + }) + t.Run("create snapshot is the declaration", func(t *testing.T) { + t.Parallel() + declared := configMapYAML("app", "", "v1") + p := mustBuild(t, installClient(declared), newMapper()) + require.Len(t, p.Changes, 1) + c := p.Changes[0] + assert.Equal(t, "prod", c.Resource.Namespace) + _, found, err := unstructured.NestedFieldNoCopy(c.After.Object, "metadata", "namespace") + require.NoError(t, err) + assert.False(t, found) + value, found, err := unstructured.NestedString(c.After.Object, "data", "key") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "v1", value) + }) +} + +func TestBuildSemanticPlan_Tasks(t *testing.T) { + t.Parallel() + t.Run("unchanged release", func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + cron := cronJobYAML("cleanup", "0 3 * * *") + manifest = joinManifests(manifest, cron) + pre := planHook("migrate", "busybox") + post := planHook("smoke", "busybox") + post.Events = []v1.HookEvent{v1.HookPostInstall, v1.HookPostUpgrade} + client := upgradeClient(manifest, manifest, 4) + client.result.Hooks = []*v1.Hook{pre, post} + client.prep.Current.Hooks = []*v1.Hook{pre, post} + client.prep.Current.Config = map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "tasks": map[string]any{ + "migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}, + "smoke": map[string]any{"on": "postDeploy", "hookWeight": 2}, + "cleanup": map[string]any{"on": "schedule"}, + }, + }, + }, + } + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, + "smoke": {Task: spec.Task{On: spec.TaskOnPostDeploy}, HookWeight: 2}, + "cleanup": {Task: spec.Task{On: spec.TaskOnSchedule}}, + "manual": {Task: spec.Task{On: spec.TaskOnManual}}, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Changes) + require.Len(t, p.Tasks, 2) + byName := map[string]semantic.TaskPlan{} + for _, task := range p.Tasks { + byName[task.Name] = task + assert.Equal(t, semantic.TaskUnchanged, task.Action) + assert.False(t, task.WillRun) + } + assert.Equal(t, semantic.TaskPreDeploy, byName["migrate"].Phase) + assert.Equal(t, semantic.TaskPostDeploy, byName["smoke"].Phase) + assert.NotContains(t, byName, "cleanup") + assert.NotContains(t, byName, "manual") + }) + t.Run("changed cronjob", func(t *testing.T) { + t.Parallel() + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "cleanup": {Task: spec.Task{On: spec.TaskOnSchedule}}, + } + client := upgradeClient(cronJobYAML("cleanup", "0 2 * * *"), cronJobYAML("cleanup", "0 3 * * *"), 4) + client.prep.Current.Config = map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "tasks": map[string]any{ + "cleanup": map[string]any{"on": string(spec.TaskOnSchedule)}, + }, + }, + }, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + require.Len(t, p.Changes, 1) + assert.Equal(t, semantic.Update, p.Changes[0].Action) + require.Len(t, p.Tasks, 1) + assert.Equal(t, semantic.TaskSchedule, p.Tasks[0].Phase) + assert.Equal(t, semantic.TaskUpdate, p.Tasks[0].Action) + require.Len(t, p.Tasks[0].Resources, 1) + assert.Equal(t, "cleanup", p.Tasks[0].Resources[0].Name) + }) +} + +func TestBuildSemanticPlan_HelmOrderFollowsUpgradeSequence(t *testing.T) { + t.Parallel() + previous := joinManifests( + kindYAML("apps/v1", "Deployment", "api", "prod", "spec:\n replicas: 1"), + kindYAML("v1", "Service", "api", "prod", "spec:\n ports:\n - port: 80"), + configMapYAML("gone", "prod", "old"), + kindYAML("v1", "ServiceAccount", "gone", "prod", ""), + ) + desired := joinManifests( + kindYAML("v1", "Service", "api", "prod", "spec:\n ports:\n - port: 81"), + configMapYAML("first", "prod", "v1"), + configMapYAML("second", "prod", "v1"), + kindYAML("apps/v1", "Deployment", "api", "prod", "spec:\n replicas: 2"), + ) + p := mustBuild(t, upgradeClient(previous, desired, 4), newMapper()) + var got []string + for _, c := range p.Changes { + got = append(got, c.Resource.Kind+"/"+c.Resource.Name+"/"+c.Action.String()) + } + assert.Equal(t, []string{ + "ConfigMap/first/create", + "ConfigMap/second/create", + "Service/api/update", + "Deployment/api/update", + "ConfigMap/gone/delete", + "ServiceAccount/gone/delete", + }, got) +} + +func TestBuildSemanticPlan_TargetNamespaceOwnership(t *testing.T) { + t.Parallel() + ns := func(name string) string { + return kindYAML("v1", "Namespace", name, "", "") + } + tests := []struct { + name string + install bool + previous string + desired string + wantErr string + }{ + { + name: "desired on install", + install: true, + desired: joinManifests(configMapYAML("app", "prod", "v1"), ns("prod")), + wantErr: "rendered manifest declares target namespace", + }, + { + name: "desired on upgrade", + previous: configMapYAML("app", "prod", "v1"), + desired: joinManifests(configMapYAML("app", "prod", "v1"), ns("prod")), + wantErr: "rendered manifest declares target namespace", + }, + { + name: "previous on upgrade", + previous: joinManifests(configMapYAML("app", "prod", "v1"), ns("prod")), + desired: configMapYAML("app", "prod", "v1"), + wantErr: "previous release revision 3 declares target namespace", + }, + { + name: "both sides", + previous: ns("prod"), + desired: ns("prod"), + wantErr: "rendered manifest declares target namespace", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var client *fakeBuildClient + if tt.install { + client = installClient(tt.desired) + } else { + client = upgradeClient(tt.previous, tt.desired, 4) + } + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + assert.ErrorContains(t, err, "must not claim it") + }) + } +} + +func TestBuildSemanticPlan_OtherNamespaceIsAChange(t *testing.T) { + t.Parallel() + install := mustBuild(t, installClient(kindYAML("v1", "Namespace", "extra", "", "")), newMapper()) + require.Len(t, install.Changes, 1) + assert.Equal(t, semantic.Create, install.Changes[0].Action) + assert.Equal(t, "Namespace", install.Changes[0].Resource.Kind) + assert.Equal(t, "extra", install.Changes[0].Resource.Name) + assert.Empty(t, install.Changes[0].Resource.Namespace) + + upgrade := mustBuild(t, upgradeClient( + kindYAML("v1", "Namespace", "extra", "", ""), + kindYAML("v1", "Namespace", "other", "", ""), + 4, + ), newMapper()) + var actions []string + for _, c := range upgrade.Changes { + actions = append(actions, c.Resource.Name+"/"+c.Action.String()) + } + assert.ElementsMatch(t, []string{"extra/delete", "other/create"}, actions) +} + +func TestBuildSemanticPlan_PairingErrors(t *testing.T) { + t.Parallel() + dup := joinManifests(configMapYAML("app", "prod", "a"), configMapYAML("app", "prod", "b")) + both := `apiVersion: v1 +kind: ConfigMap +metadata: + name: app + generateName: app- + namespace: prod +data: + key: v1 +` + gen := `apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: %s +` + tests := []struct { + name string + install bool + previous string + desired string + wantErr string + }{ + {name: "duplicate desired", install: true, desired: dup, wantErr: "rendered manifest: duplicate resource"}, + {name: "duplicate previous", previous: dup, desired: configMapYAML("other", "prod", "v1"), wantErr: "previous release revision 3: duplicate resource"}, + {name: "name and generateName", install: true, desired: both, wantErr: "metadata.name and metadata.generateName cannot both be set"}, + {name: "duplicate generateName", install: true, desired: joinManifests(fmt.Sprintf(gen, "a"), fmt.Sprintf(gen, "b")), wantErr: "ambiguous generateName pairing"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var client *fakeBuildClient + if tt.install { + client = installClient(tt.desired) + } else { + client = upgradeClient(tt.previous, tt.desired, 4) + } + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestBuildSemanticPlan_GenerateNamePairing(t *testing.T) { + t.Parallel() + gen := func(value string) string { + return fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: %s +`, value) + } + tests := []struct { + name string + client *fakeBuildClient + want []semantic.Action + }{ + {name: "unchanged", client: upgradeClient(gen("v1"), gen("v1"), 4)}, + {name: "changed", client: upgradeClient(gen("old"), gen("new"), 4), want: []semantic.Action{semantic.Update}}, + {name: "desired only", client: installClient(gen("v1")), want: []semantic.Action{semantic.Create}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := mustBuild(t, tt.client, newMapper()) + require.Len(t, p.Changes, len(tt.want)) + for i, action := range tt.want { + assert.Equal(t, action, p.Changes[i].Action) + assert.Empty(t, p.Changes[i].Resource.Name) + assert.Equal(t, "app-", p.Changes[i].Resource.GenerateName) + assert.Equal(t, "prod", p.Changes[i].Resource.Namespace) + } + }) + } +} + +func TestBuildSemanticPlan_PreviousMappingFailure(t *testing.T) { + t.Parallel() + widget := `apiVersion: example.com/v1 +kind: Widget +metadata: + name: app + namespace: prod +spec: + color: blue +` + mapper := newMapper() + gk := schema.GroupKind{Group: "example.com", Kind: "Widget"} + mapper.err[gk] = &meta.NoKindMatchError{GroupKind: gk} + client := upgradeClient(widget, configMapYAML("app", "prod", "v1"), 4) + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, "previous release revision 3: resolve resource mapping") + assert.ErrorContains(t, err, "Widget") +} + +func TestBuildSemanticPlan_UnknownCustomResource(t *testing.T) { + t.Parallel() + widget := `apiVersion: example.com/v1 +kind: Widget +metadata: + name: app + namespace: prod +spec: + color: blue +` + mapper := newMapper() + gk := schema.GroupKind{Group: "example.com", Kind: "Widget"} + mapper.err[gk] = &meta.NoKindMatchError{GroupKind: gk} + client := installClient(widget) + in := buildInput("ctx", resolvedSpec(), nil) + in.CRDs = []extras.RawFile{{Path: "widgets.yaml", Raw: []byte("apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\n")}} + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, in) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, "rendered manifest: resolve resource mapping") + assert.NotContains(t, err.Error(), "widgets.yaml") + require.NotEmpty(t, mapper.calls) } func TestBuildSemanticPlan_PreviousUsesCurrentNotNewest(t *testing.T) { t.Parallel() removed := configMapYAML("removed", "prod", "old") kept := configMapYAML("app", "prod", "keep") - current := &v1.Release{Version: 3, Manifest: removed + "---\n" + kept} + current := &v1.Release{Version: 3, Manifest: joinManifests(removed, kept)} newest := &v1.Release{Version: 4, Manifest: kept} - result := upgradeResult(kept, 5) client := &fakeBuildClient{ - result: result, + result: upgradeResult(kept, 5), prep: helm.ReleasePrep{ Operation: helm.OperationUpgrade, Current: current, @@ -422,47 +864,10 @@ func TestBuildSemanticPlan_PreviousUsesCurrentNotNewest(t *testing.T) { }, cleanup: func() {}, } - cluster := readyCluster() - cluster.store(ownedConfigMap("removed", "prod", "web", "old")) - cluster.store(ownedConfigMap("app", "prod", "web", "keep")) - - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("kind-dev", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) + p := mustBuild(t, client, newMapper()) assert.Equal(t, 5, p.Header.Revision) - assert.False(t, p.Header.FreshInstall) - byName := map[string]semantic.ResourceChange{} - for _, c := range p.Changes { - byName[c.Resource.Name] = c - } - require.Contains(t, byName, "removed") - assert.Equal(t, semantic.Delete, byName["removed"].Action) -} - -func TestBuildSemanticPlan_FailedOnlyHistoryIsUpgrade(t *testing.T) { - t.Parallel() - failed := &v1.Release{Version: 1, Manifest: configMapYAML("app", "prod", "old")} - result := upgradeResult(configMapYAML("app", "prod", "new"), 2) - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: failed, - Newest: failed, - NextRevision: 2, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "old")) - - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("kind-dev", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, 2, p.Header.Revision) - assert.False(t, p.Header.FreshInstall) - require.Len(t, p.Changes, 1) - assert.Equal(t, semantic.Update, p.Changes[0].Action) + gone := changeByName(t, p.Changes, "removed") + assert.Equal(t, semantic.Delete, gone.Action) } func TestBuildSemanticPlan_PrepRenderMismatch(t *testing.T) { @@ -517,15 +922,13 @@ func TestBuildSemanticPlan_PrepRenderMismatch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() client := &fakeBuildClient{result: tt.result, prep: tt.prep, cleanup: func() {}} - cluster := readyCluster() - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "render preparation:") assert.ErrorContains(t, err, tt.want) assert.Zero(t, p) assert.Nil(t, result) - assert.Equal(t, 0, cluster.gets) }) } } @@ -536,34 +939,27 @@ func TestBuildSemanticPlan_InvalidPrep(t *testing.T) { result: upgradeResult(configMapYAML("app", "prod", "new"), 2), prep: helm.ReleasePrep{ Operation: helm.OperationUpgrade, - Current: nil, NextRevision: 2, }, cleanup: func() {}, } - cluster := readyCluster() - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "determine helm release intent:") assert.ErrorContains(t, err, "upgrade prep requires a current release") assert.Zero(t, p) assert.Nil(t, result) - assert.Equal(t, 0, cluster.gets) } -func TestBuildSemanticPlan_PredictFailureLeavesCleanup(t *testing.T) { +func TestBuildSemanticPlan_MappingFailureLeavesCleanup(t *testing.T) { t.Parallel() var cleanups int - client := &fakeBuildClient{ - result: installResult(configMapYAML("app", "prod", "v1")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() { cleanups++ }, - } - cluster := readyCluster() - cluster.applyErrGVK[schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}] = errors.New("ssa dry-run failed") - - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) + client := installClient(configMapYAML("app", "prod", "v1")) + client.cleanup = func() { cleanups++ } + mapper := newMapper() + mapper.err[schema.GroupKind{Kind: "ConfigMap"}] = errors.New("discovery failed") + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) require.NotNil(t, cleanup) t.Cleanup(func() { if cleanups == 0 { @@ -571,7 +967,7 @@ func TestBuildSemanticPlan_PredictFailureLeavesCleanup(t *testing.T) { } }) require.Error(t, err) - assert.ErrorContains(t, err, "predict resources:") + assert.ErrorContains(t, err, "rendered manifest: resolve resource mapping") assert.Zero(t, p) assert.Nil(t, result) assert.Equal(t, 0, cleanups) @@ -581,12 +977,9 @@ func TestBuildSemanticPlan_PredictFailureLeavesCleanup(t *testing.T) { func TestBuildSemanticPlan_NilCleanup(t *testing.T) { t.Parallel() - client := &fakeBuildClient{ - result: installResult(configMapYAML("app", "prod", "v1")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: nil, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolvedSpec(), nil)) + client := installClient(configMapYAML("app", "prod", "v1")) + client.cleanup = nil + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) require.NotNil(t, cleanup) t.Cleanup(cleanup) require.NoError(t, err) @@ -596,7 +989,7 @@ func TestBuildSemanticPlan_NilCleanup(t *testing.T) { func TestBuildSemanticPlan_RenderError(t *testing.T) { t.Parallel() client := &fakeBuildClient{err: errors.New("chart missing")} - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "render manifests:") @@ -605,433 +998,95 @@ func TestBuildSemanticPlan_RenderError(t *testing.T) { assert.Nil(t, result) } -func TestBuildSemanticPlan_NoChange(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - current := &v1.Release{Version: 3, Manifest: manifest} - client := &fakeBuildClient{ - result: upgradeResult(manifest, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Empty(t, p.Changes) - assert.Equal(t, 0, p.Summary.Total()) - assert.Equal(t, semantic.HelmNone, p.HelmAction) - assert.False(t, p.HasEffects()) - assert.True(t, p.IsNoOp()) -} - -func TestBuildSemanticPlan_InstallHookWillRun(t *testing.T) { - t.Parallel() - hook := planHook("migrate", "busybox") - result := installResult(configMapYAML("app", "prod", "v1")) - result.Hooks = []*v1.Hook{hook} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - } - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmInstall, p.HelmAction) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskCreate, p.Tasks[0].Action) - assert.True(t, p.Tasks[0].WillRun) -} - -func TestBuildSemanticPlan_UnchangedHookNoHelmChange(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - hook := planHook("migrate", "busybox") - result := upgradeResult(manifest, 4) - result.Hooks = []*v1.Hook{hook} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - } - client := &fakeBuildClient{ - result: result, - prep: upgradePrepWithHook(manifest, 3, 4, hook), - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmNone, p.HelmAction) - assert.Empty(t, p.Changes) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskUnchanged, p.Tasks[0].Action) - assert.False(t, p.Tasks[0].WillRun) - assert.True(t, p.IsNoOp()) -} - -func TestBuildSemanticPlan_HelmChangeUnchangedHookWillRun(t *testing.T) { +func TestBuildSemanticPlan_Hooks(t *testing.T) { t.Parallel() + same := configMapYAML("app", "prod", "same") hook := planHook("migrate", "busybox") - result := upgradeResult(configMapYAML("app", "prod", "new"), 7) - result.Hooks = []*v1.Hook{hook} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - } - client := &fakeBuildClient{ - result: result, - prep: upgradePrepWithHook(configMapYAML("app", "prod", "old"), 6, 7, hook), - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "old")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Changes, 1) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskUnchanged, p.Tasks[0].Action) - assert.True(t, p.Tasks[0].WillRun) -} - -func TestBuildSemanticPlan_HookCreateWithoutResourceChange(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - hook := planHook("migrate", "busybox") - result := upgradeResult(manifest, 4) - result.Hooks = []*v1.Hook{hook} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - } - current := &v1.Release{ - Version: 3, - Manifest: manifest, - } - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Empty(t, p.Changes) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskCreate, p.Tasks[0].Action) - assert.True(t, p.Tasks[0].WillRun) - assert.False(t, p.IsNoOp()) -} - -func TestBuildSemanticPlan_HookUpdateWithoutResourceChange(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - prev := planHook("migrate", "old") - desired := planHook("migrate", "new") - result := upgradeResult(manifest, 4) - result.Hooks = []*v1.Hook{desired} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ + install := installClient(configMapYAML("app", "prod", "v1")) + install.result.Hooks = []*v1.Hook{hook} + hookOnly := upgradeClient(same, same, 4) + hookOnly.result.Hooks = []*v1.Hook{planHook("migrate", "busybox")} + migrate := map[string]spec.ResolvedTask{ "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, } - client := &fakeBuildClient{ - result: result, - prep: upgradePrepWithHook(manifest, 3, 4, prev), - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Empty(t, p.Changes) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskUpdate, p.Tasks[0].Action) - assert.True(t, p.Tasks[0].WillRun) -} - -func TestBuildSemanticPlan_HookDeleteWithoutResourceChange(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - prev := planHook("migrate", "busybox") - result := upgradeResult(manifest, 4) - client := &fakeBuildClient{ - result: result, - prep: upgradePrepWithHook(manifest, 3, 4, prev), - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Empty(t, p.Changes) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Tasks, 1) - assert.Equal(t, "migrate", p.Tasks[0].Name) - assert.Equal(t, semantic.TaskDelete, p.Tasks[0].Action) - assert.False(t, p.Tasks[0].WillRun) - assert.False(t, p.IsNoOp()) -} - -func TestBuildSemanticPlan_ScheduleCronJobChange(t *testing.T) { - t.Parallel() - previous := cronJobYAML("cleanup", "0 2 * * *") - desired := cronJobYAML("cleanup", "0 3 * * *") - current := &v1.Release{Version: 3, Manifest: previous} - client := &fakeBuildClient{ - result: upgradeResult(desired, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedCronJob("cleanup", "prod", "web", "0 2 * * *")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - require.Len(t, p.Changes, 1) - assert.Equal(t, "CronJob", p.Changes[0].Resource.Kind) - assert.Equal(t, semantic.Update, p.Changes[0].Action) -} - -func TestBuildSemanticPlan_HelmActionIgnoresLive(t *testing.T) { - t.Parallel() tests := []struct { name string - previous string - desired string - live string - wantAction semantic.HelmAction - wantActions []semantic.Action - wantApplies int + client *fakeBuildClient + tasks map[string]spec.ResolvedTask + wantHelm semantic.HelmAction + wantChanges int + wantAction semantic.TaskAction + wantWillRun bool + wantNoOp bool }{ - {name: "unchanged live matches", previous: "same", desired: "same", live: "same", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, - {name: "drift only", previous: "same", desired: "same", live: "drift", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, - {name: "missing live", previous: "same", desired: "same", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, - {name: "release changed live desired", previous: "old", desired: "new", live: "new", wantAction: semantic.HelmUpgrade, wantActions: []semantic.Action{}, wantApplies: 1}, - {name: "release changed live differs", previous: "old", desired: "new", live: "old", wantAction: semantic.HelmUpgrade, wantActions: []semantic.Action{semantic.Update}, wantApplies: 1}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - current := &v1.Release{Version: 3, Manifest: configMapYAML("app", "prod", tt.previous)} - client := &fakeBuildClient{ - result: upgradeResult(configMapYAML("app", "prod", tt.desired), 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - if tt.live != "" { - cluster.store(ownedConfigMap("app", "prod", "web", tt.live)) - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, tt.wantAction, p.HelmAction) - actions := make([]semantic.Action, 0, len(p.Changes)) - for _, c := range p.Changes { - actions = append(actions, c.Action) - } - assert.Equal(t, tt.wantActions, actions) - assert.Len(t, cluster.applies, tt.wantApplies) - }) - } -} - -func TestBuildSemanticPlan_UnchangedReleaseSkipsWritePrediction(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - current := &v1.Release{Version: 3, Manifest: manifest} - client := &fakeBuildClient{ - result: upgradeResult(manifest, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, + { + name: "install hook will run", + client: install, + tasks: migrate, + wantHelm: semantic.HelmInstall, + wantChanges: 1, + wantAction: semantic.TaskCreate, + wantWillRun: true, }, - cleanup: func() {}, - } - cluster := readyCluster() - id := predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"} - cluster.getErr[clusterKey(id)] = errors.New("get failed") - cluster.applyErr = errors.New("apply failed") - cluster.createErr = errors.New("create failed") - - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmNone, p.HelmAction) - assert.Empty(t, p.Changes) - assert.Empty(t, cluster.applies) - assert.Empty(t, cluster.creates) -} - -func TestBuildSemanticPlan_UnchangedReleaseSkipsNamespacePrediction(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - current := &v1.Release{Version: 3, Manifest: manifest} - client := &fakeBuildClient{ - result: upgradeResult(manifest, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, + { + name: "new hook without a resource change", + client: hookOnly, + tasks: migrate, + wantHelm: semantic.HelmUpgrade, + wantAction: semantic.TaskCreate, + wantWillRun: true, + }, + { + name: "unchanged hook and no helm change", + client: upgradePrepWithHook(same, same, 3, 4, hook, hook), + tasks: migrate, + wantHelm: semantic.HelmNone, + wantAction: semantic.TaskUnchanged, + wantNoOp: true, + }, + { + name: "helm change runs an unchanged hook", + client: upgradePrepWithHook(configMapYAML("app", "prod", "old"), configMapYAML("app", "prod", "new"), 6, 7, hook, hook), + tasks: migrate, + wantHelm: semantic.HelmUpgrade, + wantChanges: 1, + wantAction: semantic.TaskUnchanged, + wantWillRun: true, + }, + { + name: "hook update without a resource change", + client: upgradePrepWithHook(same, same, 3, 4, planHook("migrate", "old"), planHook("migrate", "new")), + tasks: migrate, + wantHelm: semantic.HelmUpgrade, + wantAction: semantic.TaskUpdate, + wantWillRun: true, + }, + { + name: "hook delete without a resource change", + client: upgradePrepWithHook(same, same, 3, 4, planHook("migrate", "busybox"), nil), + wantHelm: semantic.HelmUpgrade, + wantAction: semantic.TaskDelete, }, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmNone, p.HelmAction) -} - -func TestBuildSemanticPlan_UpgradeGenerateName(t *testing.T) { - t.Parallel() - unchanged := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n generateName: app-\n namespace: prod\ndata:\n key: v1\n" - changed := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n generateName: app-\n namespace: prod\ndata:\n key: v2\n" - tests := []struct { - name string - desired string - want semantic.HelmAction - }{ - {name: "unchanged", desired: unchanged, want: semantic.HelmNone}, - {name: "changed", desired: changed, want: semantic.HelmUpgrade}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - current := &v1.Release{Version: 3, Manifest: unchanged} - client := &fakeBuildClient{ - result: upgradeResult(tt.desired, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolvedSpec(), nil)) + resolved := resolvedSpec() + resolved.Tasks = tt.tasks + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), tt.client, newMapper(), buildInput("ctx", resolved, nil)) t.Cleanup(cleanup) require.NoError(t, err) - assert.Equal(t, tt.want, p.HelmAction) + assert.Equal(t, tt.wantHelm, p.HelmAction) + assert.Len(t, p.Changes, tt.wantChanges) + require.Len(t, p.Tasks, 1) + assert.Equal(t, "migrate", p.Tasks[0].Name) + assert.Equal(t, tt.wantAction, p.Tasks[0].Action) + assert.Equal(t, tt.wantWillRun, p.Tasks[0].WillRun) + assert.Equal(t, tt.wantNoOp, p.IsNoOp()) }) } } -func TestBuildSemanticPlan_ReleaseChangeLiveCurrentUnchangedHookWillRun(t *testing.T) { - t.Parallel() - hook := planHook("migrate", "busybox") - result := upgradeResult(configMapYAML("app", "prod", "new"), 4) - result.Hooks = []*v1.Hook{hook} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - } - client := &fakeBuildClient{ - result: result, - prep: upgradePrepWithHook(configMapYAML("app", "prod", "old"), 3, 4, hook), - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "new")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) - assert.Empty(t, p.Changes) - require.Len(t, p.Tasks, 1) - assert.Equal(t, semantic.TaskUnchanged, p.Tasks[0].Action) - assert.True(t, p.Tasks[0].WillRun) -} - -func TestBuildSemanticPlan_DriftUnchangedHooksWillNotRun(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - pre := planHook("migrate", "busybox") - post := planHook("smoke", "busybox") - post.Events = []v1.HookEvent{v1.HookPostInstall, v1.HookPostUpgrade} - result := upgradeResult(manifest, 4) - result.Hooks = []*v1.Hook{pre, post} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - "smoke": {Task: spec.Task{On: spec.TaskOnPostDeploy}, HookWeight: 2}, - } - current := &v1.Release{ - Version: 3, - Manifest: manifest, - Hooks: []*v1.Hook{pre, post}, - Config: map[string]any{ - "deployah": map[string]any{ - "resolved": map[string]any{ - "tasks": map[string]any{ - "migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}, - "smoke": map[string]any{"on": "postDeploy", "hookWeight": 2}, - }, - }, - }, - }, - } - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "drift")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmNone, p.HelmAction) - require.Len(t, p.Tasks, 2) - for _, task := range p.Tasks { - assert.False(t, task.WillRun) - } -} - func planHook(name, image string) *v1.Hook { return &v1.Hook{ Name: name, @@ -1056,26 +1111,40 @@ spec: } } -func upgradePrepWithHook(manifest string, currentRevision, nextRevision int, hook *v1.Hook) helm.ReleasePrep { +func upgradePrepWithHook(previous, desired string, currentRevision, nextRevision int, prev, desiredHook *v1.Hook) *fakeBuildClient { + var currentHooks []*v1.Hook + if prev != nil { + currentHooks = []*v1.Hook{prev} + } current := &v1.Release{ Version: currentRevision, - Manifest: manifest, - Hooks: []*v1.Hook{hook}, + Manifest: previous, + Hooks: currentHooks, Config: map[string]any{ "deployah": map[string]any{ "resolved": map[string]any{ "tasks": map[string]any{ - hook.Name: map[string]any{"on": "preDeploy", "hookWeight": 1}, + "migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}, }, }, }, }, } - return helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: nextRevision, + var hooks []*v1.Hook + if desiredHook != nil { + hooks = []*v1.Hook{desiredHook} + } + result := upgradeResult(desired, nextRevision) + result.Hooks = hooks + return &fakeBuildClient{ + result: result, + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: nextRevision, + }, + cleanup: func() {}, } } @@ -1085,6 +1154,8 @@ kind: CronJob metadata: name: %s namespace: prod + labels: + %s: %s spec: schedule: %q jobTemplate: @@ -1095,38 +1166,5 @@ spec: - name: job image: busybox restartPolicy: OnFailure -`, name, schedule) -} - -func ownedCronJob(name, namespace, release, schedule string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "batch/v1", - "kind": "CronJob", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - "labels": map[string]any{ - "app.kubernetes.io/managed-by": "Helm", - }, - "annotations": map[string]any{ - "meta.helm.sh/release-name": release, - "meta.helm.sh/release-namespace": namespace, - }, - }, - "spec": map[string]any{ - "schedule": schedule, - "jobTemplate": map[string]any{ - "spec": map[string]any{ - "template": map[string]any{ - "spec": map[string]any{ - "containers": []any{ - map[string]any{"name": "job", "image": "busybox"}, - }, - "restartPolicy": "OnFailure", - }, - }, - }, - }, - }, - }} +`, name, spec.LabelTask, name, schedule) } diff --git a/internal/plan/semantic_prereq_test.go b/internal/plan/semantic_prereq_test.go deleted file mode 100644 index 343abb4..0000000 --- a/internal/plan/semantic_prereq_test.go +++ /dev/null @@ -1,531 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package plan_test - -import ( - "maps" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/extras" - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/plan" - "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/spec" - - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -func widgetManifest(name, generateName string) string { - meta := " name: " + name - if generateName != "" { - meta = " generateName: " + generateName - } - return "apiVersion: example.com/v1\nkind: Widget\nmetadata:\n" + meta + "\n namespace: prod\nspec:\n color: blue\n" -} - -func storedCRD(name, group, kind, plural, version string, extraSpec map[string]any) *unstructured.Unstructured { - spec := map[string]any{ - "group": group, - "scope": "Namespaced", - "names": map[string]any{"kind": kind, "plural": plural}, - "versions": []any{map[string]any{ - "name": version, - "served": true, - "storage": true, - "schema": map[string]any{"openAPIV3Schema": map[string]any{"type": "object"}}, - }}, - } - maps.Copy(spec, extraSpec) - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": map[string]any{"name": name}, - "spec": spec, - }} -} - -func TestBuildSemanticPlan_InstallCreatesNamespace(t *testing.T) { - t.Parallel() - client := &fakeBuildClient{ - result: installResult(configMapYAML("app", "prod", "v1")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - cluster := newFakeCluster() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - require.GreaterOrEqual(t, len(p.Changes), 2) - assert.Equal(t, semantic.OriginNamespace, p.Changes[0].Origin.Kind) - assert.Equal(t, semantic.Create, p.Changes[0].Action) - assert.Equal(t, semantic.WriteServerSide, p.Changes[0].Apply.Write.Method) - assert.False(t, p.Changes[0].Apply.Write.ForceConflicts) - require.NotEmpty(t, p.Diagnostics) - assert.Contains(t, p.Diagnostics[0].Message, "target namespace is created earlier in this deployment") - require.Len(t, cluster.applies, 1) - assert.Equal(t, "deployah", cluster.applies[0].Opts.FieldManager) - assert.False(t, cluster.applies[0].Opts.ForceConflicts) -} - -func TestBuildSemanticPlan_InstallNamespaceLabelUpdate(t *testing.T) { - t.Parallel() - tests := []struct { - name string - ns *unstructured.Unstructured - }{ - { - name: "missing name label", - ns: &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{"name": "prod"}, - }}, - }, - { - name: "wrong name label", - ns: &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": "prod", - "labels": map[string]any{"name": "other"}, - }, - }}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - client := &fakeBuildClient{ - result: installResult(configMapYAML("app", "prod", "v1")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - cluster := newFakeCluster() - cluster.store(tt.ns) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - require.GreaterOrEqual(t, len(p.Changes), 1) - assert.Equal(t, semantic.OriginNamespace, p.Changes[0].Origin.Kind) - assert.Equal(t, semantic.Update, p.Changes[0].Action) - assert.True(t, hasFieldPath(p.Changes[0].Fields, "/metadata/labels") || hasFieldPath(p.Changes[0].Fields, "/metadata/labels/name")) - }) - } -} - -func TestBuildSemanticPlan_UpgradeMissingNamespace(t *testing.T) { - t.Parallel() - current := &v1.Release{Version: 1, Manifest: configMapYAML("app", "prod", "old")} - client := &fakeBuildClient{ - result: upgradeResult(configMapYAML("app", "prod", "new"), 2), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - NextRevision: 2, - }, - cleanup: func() {}, - } - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.Error(t, err) - assert.ErrorContains(t, err, `upgrade requires namespace "prod" to exist`) -} - -func TestBuildSemanticPlan_InstallTargetNamespaceOverlap(t *testing.T) { - t.Parallel() - tests := []struct { - name string - manifest string - }{ - { - name: "bare namespace", - manifest: `apiVersion: v1 -kind: Namespace -metadata: - name: prod -`, - }, - { - name: "namespace in list", - manifest: `apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: Namespace - metadata: - name: prod -`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - client := &fakeBuildClient{ - result: installResult(tt.manifest), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot plan target namespace") - assert.Zero(t, p) - }) - } -} - -func TestBuildSemanticPlan_InstallOtherNamespaceAllowed(t *testing.T) { - t.Parallel() - manifest := `apiVersion: v1 -kind: Namespace -metadata: - name: extra ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: app - namespace: prod -data: - key: v1 -` - client := &fakeBuildClient{ - result: installResult(manifest), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - var kinds []string - for _, c := range p.Changes { - kinds = append(kinds, c.Origin.Kind.String()+":"+c.Resource.Kind+"/"+c.Resource.Name) - } - assert.Contains(t, kinds, "helm:Namespace/extra") - assert.NotContains(t, kinds, "namespace:Namespace/prod") -} - -func TestBuildSemanticPlan_UpgradeRenderedTargetNamespace(t *testing.T) { - t.Parallel() - manifest := `apiVersion: v1 -kind: Namespace -metadata: - name: prod -` - current := &v1.Release{Version: 1, Manifest: manifest} - desired := `apiVersion: v1 -kind: Namespace -metadata: - name: prod - labels: - env: prod -` - client := &fakeBuildClient{ - result: upgradeResult(desired, 2), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - NextRevision: 2, - }, - cleanup: func() {}, - } - cluster := readyCluster() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - require.NotEmpty(t, p.Changes) - assert.Equal(t, semantic.OriginHelm, p.Changes[0].Origin.Kind) - assert.Equal(t, "Namespace", p.Changes[0].Resource.Kind) -} - -func TestBuildSemanticPlan_CRDFilesDoNotProduceResourceChanges(t *testing.T) { - t.Parallel() - crd := extras.RawFile{Path: "widgets.yaml", Raw: []byte("apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\n")} - rawCopy := append([]byte(nil), crd.Raw...) - client := &fakeBuildClient{ - result: installResult(configMapYAML("app", "prod", "v1")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - in := buildInput("ctx", resolvedSpec(), nil) - in.CRDs = []extras.RawFile{crd} - in.CRDDocs = []extras.CRDDoc{{ - Path: "/abs/.deployah/crds/widgets.yaml", - Kind: "CustomResourceDefinition", - Name: "widgets.example.com", - }} - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), in) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, rawCopy, in.CRDs[0].Raw) - for _, c := range p.Changes { - assert.True(t, c.Origin.Kind == semantic.OriginHelm || c.Origin.Kind == semantic.OriginNamespace) - assert.NotEqual(t, "CustomResourceDefinition", c.Resource.Kind) - } - require.Len(t, p.ChartCRDs, 1) - assert.Equal(t, semantic.ChartCRDProcess, p.ChartCRDs[0].Lifecycle) - assert.True(t, p.ChartCRDs[0].WillProcess) - assert.Equal(t, "widgets.example.com", p.ChartCRDs[0].Name) -} - -func TestBuildSemanticPlan_ChartCRDLifecycle(t *testing.T) { - t.Parallel() - docs := []extras.CRDDoc{{ - Path: "/abs/.deployah/crds/widget.yaml", - Kind: "CustomResourceDefinition", - Name: "widgets.example.com", - }} - tests := []struct { - name string - op helm.Operation - skip bool - wantLife semantic.ChartCRDLifecycle - wantProcess bool - wantAction semantic.HelmAction - }{ - {name: "fresh install", op: helm.OperationInstall, wantLife: semantic.ChartCRDProcess, wantProcess: true, wantAction: semantic.HelmInstall}, - {name: "fresh skip", op: helm.OperationInstall, skip: true, wantLife: semantic.ChartCRDSkip, wantAction: semantic.HelmInstall}, - {name: "upgrade", op: helm.OperationUpgrade, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone}, - {name: "upgrade ignores skip", op: helm.OperationUpgrade, skip: true, wantLife: semantic.ChartCRDUpgrade, wantAction: semantic.HelmNone}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - client := &fakeBuildClient{cleanup: func() {}} - cluster := readyCluster() - in := buildInput("ctx", resolvedSpec(), nil) - in.CRDDocs = docs - in.SkipCRDs = tc.skip - switch tc.op { - case helm.OperationInstall: - client.result = installResult(configMapYAML("app", "prod", "v1")) - client.prep = helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1} - case helm.OperationUpgrade: - current := &v1.Release{Version: 3, Manifest: manifest} - client.result = upgradeResult(manifest, 4) - client.prep = helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - } - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, in) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, tc.wantAction, p.HelmAction) - require.Len(t, p.ChartCRDs, 1) - assert.Equal(t, ".deployah/crds/widget.yaml", p.ChartCRDs[0].Source) - assert.Equal(t, "CustomResourceDefinition", p.ChartCRDs[0].Kind) - assert.Equal(t, "widgets.example.com", p.ChartCRDs[0].Name) - assert.Equal(t, tc.wantLife, p.ChartCRDs[0].Lifecycle) - assert.Equal(t, tc.wantProcess, p.ChartCRDs[0].WillProcess) - for _, c := range p.Changes { - assert.NotEqual(t, "CustomResourceDefinition", c.Resource.Kind) - } - if tc.op == helm.OperationUpgrade { - assert.Empty(t, p.Changes) - assert.False(t, p.HasEffects()) - assert.True(t, p.IsNoOp()) - } - }) - } -} - -func TestBuildSemanticPlan_UnknownCustomResourceNotInventedFromCRDFiles(t *testing.T) { - t.Parallel() - client := &fakeBuildClient{ - result: installResult(widgetManifest("app", "")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - cluster := readyCluster() - gvk := schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"} - cluster.mappingErr[gvk] = &meta.NoKindMatchError{GroupKind: gvk.GroupKind()} - in := buildInput("ctx", resolvedSpec(), nil) - in.CRDs = []extras.RawFile{{Path: "widgets.yaml", Raw: []byte("apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\n")}} - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, in) - t.Cleanup(cleanup) - require.Error(t, err) - assert.ErrorContains(t, err, "predict resources") - assert.Empty(t, cluster.creates) -} - -func TestBuildSemanticPlan_GenerateNameMissingNamespaceConfigMap(t *testing.T) { - t.Parallel() - manifest := `apiVersion: v1 -kind: ConfigMap -metadata: - generateName: app- - namespace: prod -data: - key: v1 -` - client := &fakeBuildClient{ - result: installResult(manifest), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - cluster := newFakeCluster() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - var gens []string - for _, d := range p.Diagnostics { - if d.Resource != nil { - gens = append(gens, d.Resource.GenerateName) - assert.Contains(t, d.Message, "target namespace is created earlier in this deployment") - } - } - assert.Contains(t, gens, "app-") - require.Len(t, cluster.applies, 1) - assert.Equal(t, "Namespace", cluster.applies[0].Obj.GetKind()) -} - -func TestBuildSemanticPlan_GenerateNameMissingNamespace(t *testing.T) { - t.Parallel() - client := &fakeBuildClient{ - result: installResult(widgetManifest("", "app-") + "---\n" + widgetManifest("", "job-")), - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - cleanup: func() {}, - } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - var gens []string - for _, d := range p.Diagnostics { - if d.Resource != nil { - gens = append(gens, d.Resource.GenerateName) - } - } - assert.Contains(t, gens, "app-") - assert.Contains(t, gens, "job-") -} - -func TestBuildSemanticPlan_PruneSyntheticGetNoLimitation(t *testing.T) { - t.Parallel() - previous := widgetManifest("old", "") - client := &fakeBuildClient{ - result: upgradeResult(configMapYAML("app", "prod", "v1"), 2), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: &v1.Release{Version: 1, Manifest: previous + "---\n" + configMapYAML("app", "prod", "v1")}, - NextRevision: 2, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "v1")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - for _, c := range p.Changes { - assert.NotEqual(t, "old", c.Resource.Name) - } - for _, d := range p.Diagnostics { - if d.Resource != nil { - assert.NotEqual(t, "old", d.Resource.Name) - } - } -} - -func TestBuildSemanticPlan_IdleReleaseDoesNotRunHooks(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - pre := planHook("migrate", "busybox") - post := planHook("smoke", "busybox") - post.Events = []v1.HookEvent{v1.HookPostInstall, v1.HookPostUpgrade} - result := upgradeResult(manifest, 4) - result.Hooks = []*v1.Hook{pre, post} - resolved := resolvedSpec() - resolved.Tasks = map[string]spec.ResolvedTask{ - "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, - "smoke": {Task: spec.Task{On: spec.TaskOnPostDeploy}, HookWeight: 2}, - } - current := &v1.Release{ - Version: 3, - Manifest: manifest, - Hooks: []*v1.Hook{pre, post}, - Config: map[string]any{ - "deployah": map[string]any{ - "resolved": map[string]any{ - "tasks": map[string]any{ - "migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}, - "smoke": map[string]any{"on": "postDeploy", "hookWeight": 2}, - }, - }, - }, - }, - } - client := &fakeBuildClient{ - result: result, - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Equal(t, semantic.HelmNone, p.HelmAction) - require.Len(t, p.Tasks, 2) - byName := make(map[string]semantic.TaskPlan, len(p.Tasks)) - for _, task := range p.Tasks { - byName[task.Name] = task - assert.Equal(t, semantic.TaskUnchanged, task.Action) - assert.False(t, task.WillRun) - } - assert.Equal(t, semantic.TaskPreDeploy, byName["migrate"].Phase) - assert.Equal(t, semantic.TaskPostDeploy, byName["smoke"].Phase) -} - -func TestBuildSemanticPlan_OmittedLiveCRDIsNotDeleted(t *testing.T) { - t.Parallel() - manifest := configMapYAML("app", "prod", "same") - current := &v1.Release{Version: 3, Manifest: manifest} - client := &fakeBuildClient{ - result: upgradeResult(manifest, 4), - prep: helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - Newest: current, - NextRevision: 4, - }, - cleanup: func() {}, - } - cluster := readyCluster() - cluster.store(ownedConfigMap("app", "prod", "web", "same")) - cluster.store(storedCRD("widgets.example.com", "example.com", "Widget", "widgets", "v1", nil)) - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) - t.Cleanup(cleanup) - require.NoError(t, err) - assert.Empty(t, p.Changes) - assert.Empty(t, cluster.deletes) - assert.Equal(t, semantic.HelmNone, p.HelmAction) -} diff --git a/internal/plan/tasks_test.go b/internal/plan/tasks_test.go index df06d0d..d75c75c 100644 --- a/internal/plan/tasks_test.go +++ b/internal/plan/tasks_test.go @@ -649,7 +649,7 @@ func TestAssembleTasks_HookScheduleTransition(t *testing.T) { tasks, err := assembleTasks(resolved, prep, tt.desired, tt.changes) require.NoError(t, err) stampTaskWillRun(tasks, semantic.HelmUpgrade) - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, tasks, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, tasks) require.NoError(t, err) byName := taskByName(t, p.Tasks) require.Contains(t, byName, "work") @@ -744,33 +744,29 @@ func TestAssembleTasks_ScheduleProvenance(t *testing.T) { } } -func TestStampHelmApplyOrder_ConfigMapBeforeDeployment(t *testing.T) { +func TestStampHelmOrder_ConfigMapBeforeDeployment(t *testing.T) { t.Parallel() changes := []semantic.ResourceChange{ { Resource: semantic.ResourceRef{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "prod", Name: "api"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Create, After: snapObj(map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]any{"name": "api"}}), - Apply: writeApply(), }, { Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "api"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Create, After: snapObj(map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "api"}}), - Apply: writeApply(), }, } - require.NoError(t, stampHelmApplyOrder(changes)) - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, changes, nil, nil) + require.NoError(t, stampHelmOrder(changes)) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, changes, nil) require.NoError(t, err) require.Len(t, p.Changes, 2) assert.Equal(t, "ConfigMap", p.Changes[0].Resource.Kind) assert.Equal(t, "Deployment", p.Changes[1].Resource.Kind) } -func TestStampHelmApplyOrder_SameKindPreservesInputOrder(t *testing.T) { +func TestStampHelmOrder_SameKindPreservesInputOrder(t *testing.T) { t.Parallel() const n = 12 changes := make([]semantic.ResourceChange, 0, n) @@ -778,17 +774,15 @@ func TestStampHelmApplyOrder_SameKindPreservesInputOrder(t *testing.T) { name := fmt.Sprintf("n%d", n-1-i) changes = append(changes, semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Create, After: snapObj(map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": name}}), - Apply: writeApply(), }) } - require.NoError(t, stampHelmApplyOrder(changes)) + require.NoError(t, stampHelmOrder(changes)) for i, c := range changes { - assert.Equal(t, i+1, c.ApplyOrder, "input index %d", i) + assert.Equal(t, i+1, c.HelmOrder, "input index %d", i) } - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, changes, nil, nil) + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, changes, nil) require.NoError(t, err) require.Len(t, p.Changes, n) for i, c := range p.Changes { @@ -885,7 +879,6 @@ func eventlessHook() *v1.Hook { func labeledCreate(name, component string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Create, After: snapObj(map[string]any{ "apiVersion": "v1", @@ -897,14 +890,12 @@ func labeledCreate(name, component string) semantic.ResourceChange { }, "data": map[string]any{"key": "v1"}, }), - Apply: writeApply(), } } func labeledTaskCreate(name string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "batch/v1", Kind: "CronJob", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Create, After: snapObj(map[string]any{ "apiVersion": "batch/v1", @@ -916,14 +907,12 @@ func labeledTaskCreate(name string) semantic.ResourceChange { }, "spec": map[string]any{"schedule": "0 3 * * *"}, }), - Apply: writeApply(), } } func labeledTaskDelete(name string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "batch/v1", Kind: "CronJob", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Delete, Before: snapObj(map[string]any{ "apiVersion": "batch/v1", @@ -935,7 +924,6 @@ func labeledTaskDelete(name string) semantic.ResourceChange { }, "spec": map[string]any{"schedule": "0 3 * * *"}, }), - Apply: deleteApply(), } } @@ -952,23 +940,27 @@ func labeledUpdate(name, component string) semantic.ResourceChange { "spec": map[string]any{"schedule": sched}, } } + before := obj("0 2 * * *", map[string]any{spec.LabelComponent: component}) + after := obj("0 3 * * *", map[string]any{ + spec.LabelComponent: component, + spec.LabelTask: name, + }) + fields, err := semantic.DiffFields(before, after) + if err != nil { + panic(err) + } return semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "batch/v1", Kind: "CronJob", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Update, - Before: snapObj(obj("0 2 * * *", map[string]any{spec.LabelComponent: component})), - After: snapObj(obj("0 3 * * *", map[string]any{ - spec.LabelComponent: component, - spec.LabelTask: name, - })), - Apply: writeApply(), + Before: snapObj(before), + After: snapObj(after), + Fields: fields, } } func labeledLegacyDelete(name, component string) semantic.ResourceChange { return semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "batch/v1", Kind: "CronJob", Namespace: "prod", Name: name}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}}, Action: semantic.Delete, Before: snapObj(map[string]any{ "apiVersion": "batch/v1", @@ -980,7 +972,6 @@ func labeledLegacyDelete(name, component string) semantic.ResourceChange { }, "spec": map[string]any{"schedule": "0 3 * * *"}, }), - Apply: deleteApply(), } } diff --git a/internal/plan/view/helpers_test.go b/internal/plan/view/helpers_test.go index ee3eadf..e4ae118 100644 --- a/internal/plan/view/helpers_test.go +++ b/internal/plan/view/helpers_test.go @@ -31,36 +31,6 @@ import ( var update = flag.Bool("update", false, "update golden files") -func helmOrigin() semantic.ResourceOrigin { - return semantic.ResourceOrigin{ - Kind: semantic.OriginHelm, - Helm: &semantic.HelmOrigin{Release: "web", Namespace: "prod"}, - } -} - -func writeCreate() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{ - Method: semantic.WriteCreate, - }, - } -} - -func writeApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{ - Method: semantic.WriteServerSide, - FieldManager: "deployah", - }, - } -} - -func deleteApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Delete: &semantic.DeleteSemantics{Propagation: semantic.PropagationBackground}, - } -} - func snap(obj map[string]any) *semantic.ResourceSnapshot { return &semantic.ResourceSnapshot{Object: obj} } @@ -237,10 +207,8 @@ func objectString(tb testing.TB, obj map[string]any, keys ...string) string { func createChangeForHuman() semantic.ResourceChange { return semantic.ResourceChange{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), } } @@ -255,28 +223,68 @@ func humanHeader() semantic.Header { } } -func mustPlan(tb testing.TB, helmAction semantic.HelmAction, changes []semantic.ResourceChange, diags []semantic.Diagnostic) semantic.Plan { +func mustPlan(tb testing.TB, helmAction semantic.HelmAction, changes []semantic.ResourceChange) semantic.Plan { tb.Helper() - return mustPlanWithTasks(tb, helmAction, changes, nil, diags) + return mustPlanWithTasks(tb, helmAction, changes, nil) } -func mustPlanWithTasks(tb testing.TB, helmAction semantic.HelmAction, changes []semantic.ResourceChange, tasks []semantic.TaskPlan, diags []semantic.Diagnostic) semantic.Plan { +func mustPlanWithTasks(tb testing.TB, helmAction semantic.HelmAction, changes []semantic.ResourceChange, tasks []semantic.TaskPlan) semantic.Plan { tb.Helper() return mustPlanWithHeader(tb, semantic.Header{ Project: "web", Environment: "prod", Release: "web", Namespace: "prod", - }, helmAction, changes, tasks, diags) + }, helmAction, changes, tasks) } -func mustPlanWithHeader(tb testing.TB, header semantic.Header, helmAction semantic.HelmAction, changes []semantic.ResourceChange, tasks []semantic.TaskPlan, diags []semantic.Diagnostic) semantic.Plan { +func mustPlanWithHeader(tb testing.TB, header semantic.Header, helmAction semantic.HelmAction, changes []semantic.ResourceChange, tasks []semantic.TaskPlan) semantic.Plan { tb.Helper() - p, err := semantic.New(header, helmAction, changes, tasks, diags) + p, err := semantic.New(header, helmAction, fillUpdateFields(changes), fillTaskFields(tasks)) require.NoError(tb, err) return p } +func fillUpdateFields(changes []semantic.ResourceChange) []semantic.ResourceChange { + out := append([]semantic.ResourceChange(nil), changes...) + for i, c := range out { + if c.Action != semantic.Update || len(c.Fields) > 0 || c.Before == nil || c.After == nil { + continue + } + fields, err := semantic.DiffFields(c.Before.Object, c.After.Object) + if err != nil { + panic(err) + } + out[i].Fields = fields + } + return out +} + +func fillTaskFields(tasks []semantic.TaskPlan) []semantic.TaskPlan { + if tasks == nil { + return nil + } + out := append([]semantic.TaskPlan(nil), tasks...) + for i, task := range out { + if len(task.Definitions) == 0 { + continue + } + defs := append([]semantic.HookDefinition(nil), task.Definitions...) + for j, def := range defs { + if def.Action != semantic.Update || len(def.Fields) > 0 || def.Before == nil || def.After == nil { + continue + } + fields, err := semantic.DiffFields(def.Before.Object, def.After.Object) + if err != nil { + panic(err) + } + defs[j].Fields = fields + } + out[i].Definitions = defs + } + return out +} + func writeHuman(t *testing.T, p semantic.Plan) string { t.Helper() var buf bytes.Buffer diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index f8cbc28..84062ca 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -64,17 +64,6 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { } wroteBody = true } - if len(prepared.Diagnostics) > 0 { - if wroteBody { - if berr := writeBlank(w); berr != nil { - return berr - } - } - if derr := writeHumanDiagnostics(w, prepared.Diagnostics, prepared.Header.Namespace, opts); derr != nil { - return derr - } - wroteBody = true - } if wroteBody { if berr := writeBlank(w); berr != nil { return berr @@ -298,9 +287,6 @@ func writeActionBody( } return writePrefixedYAML(w, text, indent+"- ", theme.StatusError, opts) case semantic.Update: - if after == nil { - return nil - } beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) default: @@ -362,32 +348,6 @@ func writeYAMLDiff(w io.Writer, beforeYAML, afterYAML, indent string, opts Optio return nil } -func writeHumanDiagnostics(w io.Writer, diags []semantic.Diagnostic, planNS string, opts Options) error { - if len(diags) == 0 { - return nil - } - if err := writeln(w, opts, theme.TextTitle, "Diagnostics"); err != nil { - return err - } - if err := writeBlank(w); err != nil { - return err - } - for _, d := range diags { - ref := "" - if d.Resource != nil { - ref = " " + formatGVK(*d.Resource) + " " + quotedName(*d.Resource) - if d.Resource.Namespace != "" && d.Resource.Namespace != planNS { - ref += fmt.Sprintf(" in namespace %q", d.Resource.Namespace) - } - } - line := fmt.Sprintf(" %s %s%s: %s", d.Severity.String(), d.Category.String(), ref, d.Message) - if err := writeln(w, opts, theme.StatusWarning, line); err != nil { - return err - } - } - return nil -} - func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { if err := writeln(w, opts, theme.TextTitle, "Summary"); err != nil { return err diff --git a/internal/plan/view/human_norm_test.go b/internal/plan/view/human_norm_test.go index 55cad21..e75cd4d 100644 --- a/internal/plan/view/human_norm_test.go +++ b/internal/plan/view/human_norm_test.go @@ -138,12 +138,10 @@ func TestWriteHuman_SecretDiffs(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(tt.before), After: snap(tt.after), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{ShowSecrets: tt.showSecrets})) text := buf.String() @@ -163,12 +161,10 @@ func TestWriteHuman_OmitsBookkeepingFields(t *testing.T) { after := noisyCM("web", "v2", "22", "u-pred") p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(before), After: snap(after), - Apply: writeApply(), - }}, nil) + }}) var human, jsonBuf bytes.Buffer require.NoError(t, view.WriteHuman(&human, p, view.Options{})) @@ -195,10 +191,8 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { name: "create", changes: []semantic.ResourceChange{{ Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(noisyCM("web", "v1", "11", "u-new")), - Apply: writeApply(), }}, want: []string{"+ key: v1", "+ labels:", "+ annotations:"}, }, @@ -206,10 +200,8 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { name: "delete", changes: []semantic.ResourceChange{{ Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(noisyCM("web", "v1", "11", "u-old")), - Apply: deleteApply(), }}, want: []string{"- key: v1", "- labels:", "- annotations:"}, }, @@ -217,7 +209,7 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, tt.changes, nil) + p := mustPlan(t, semantic.HelmUpgrade, tt.changes) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() diff --git a/internal/plan/view/human_semantic_test.go b/internal/plan/view/human_semantic_test.go index 0f71dbd..5f606a0 100644 --- a/internal/plan/view/human_semantic_test.go +++ b/internal/plan/view/human_semantic_test.go @@ -29,7 +29,6 @@ import ( "helm.sh/helm/v4/pkg/chart/v2/loader" "helm.sh/helm/v4/pkg/postrenderer" "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" @@ -37,12 +36,10 @@ import ( "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan" "deployah.dev/deployah/internal/plan/semantic" - "deployah.dev/deployah/internal/predict" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" v1 "helm.sh/helm/v4/pkg/release/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" ) const ( @@ -52,7 +49,7 @@ const ( ) // TestWriteHuman_SemanticPlan is the canonical human output of -// fixture render -> prediction -> BuildSemanticPlan -> WriteHuman. +// fixture render -> BuildSemanticPlan -> WriteHuman. // human_all_actions.golden stays the synthetic generic renderer contract. func TestWriteHuman_SemanticPlan(t *testing.T) { t.Parallel() @@ -72,8 +69,6 @@ func TestWriteHuman_SemanticPlan(t *testing.T) { assert.Contains(t, text, "deployah.dev/instance: web-prod") assert.Contains(t, text, "app.kubernetes.io/managed-by: Helm") assert.Contains(t, text, "helm.sh/chart:") - assert.Contains(t, text, "meta.helm.sh/release-name: web-prod") - assert.Contains(t, text, "meta.helm.sh/release-namespace: prod") assert.Contains(t, text, "helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded") assert.Contains(t, text, " preDeploy") assert.Contains(t, text, "~ migrate changed, will run") @@ -140,7 +135,6 @@ func TestWriteHuman_SemanticScheduleDelete(t *testing.T) { assert.Contains(t, text, `- delete batch/v1/CronJob "web-prod-cleanup"`) assertCronJobOnlyUnderTasks(t, text, "web-prod-cleanup") assertSemanticCronJobBody(t, text) - assert.Contains(t, text, "meta.helm.sh/release-name: web-prod") assert.NotContains(t, text, `create v1/Namespace`) assert.NotContains(t, text, "-/+") } @@ -178,8 +172,6 @@ func assertSemanticCronJobBody(t *testing.T, text string) { assert.Contains(t, text, "deployah.dev/project: web") assert.Contains(t, text, "app.kubernetes.io/managed-by: Helm") assert.Contains(t, text, "helm.sh/chart:") - assert.Contains(t, text, "meta.helm.sh/release-name: web-prod") - assert.Contains(t, text, "meta.helm.sh/release-namespace: prod") assert.Contains(t, text, "jobTemplate:") assert.Contains(t, text, "schedule: 0 3 * * *") } @@ -264,14 +256,6 @@ func semanticUpgradePlan(t *testing.T, previous, current *spec.Spec) semantic.Pl prevResult := renderDesired(t, cache, prevResolved) prevRelease := previousReleaseFromRender(t, prevResult) - cluster := newFakeCluster() - seedTargetNamespace(cluster, prevResult.Namespace) - installPlan := buildSemanticPlan(t, &fakeBuildClient{ - result: prevResult, - prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, - }, cluster, prevResolved) - storePredictedLive(cluster, installPlan.Changes) - currResolved := resolveProductSpec(t, current) currResult := renderDesired(t, cache, currResolved) return buildSemanticPlan(t, &fakeBuildClient{ @@ -282,7 +266,7 @@ func semanticUpgradePlan(t *testing.T, previous, current *spec.Spec) semantic.Pl Newest: prevRelease, NextRevision: 2, }, - }, cluster, currResolved) + }, currResolved) } func resolveProductSpec(t *testing.T, manifest *spec.Spec) *spec.ResolvedSpec { @@ -382,29 +366,9 @@ func nestedMap(root map[string]any, keys ...string) (map[string]any, bool) { return cur, true } -func seedTargetNamespace(cluster *fakeCluster, namespace string) { - cluster.store(&unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": namespace, - "labels": map[string]any{"name": namespace}, - }, - }}) -} - -func storePredictedLive(cluster *fakeCluster, changes []semantic.ResourceChange) { - for _, c := range changes { - if c.After == nil || c.After.Object == nil { - continue - } - cluster.store(&unstructured.Unstructured{Object: c.After.Object}) - } -} - -func buildSemanticPlan(t *testing.T, client plan.SemanticBuildClient, cluster predict.Cluster, resolved *spec.ResolvedSpec) semantic.Plan { +func buildSemanticPlan(t *testing.T, client plan.SemanticBuildClient, resolved *spec.ResolvedSpec) semantic.Plan { t.Helper() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, plan.SemanticBuildInput{ + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, productMapper{}, plan.SemanticBuildInput{ ClusterContext: productClusterContext, Resolved: resolved, }) @@ -428,66 +392,29 @@ func (c *fakeBuildClient) RenderManifestsWithPrep( return c.result, c.prep, func() {}, nil } -type fakeCluster struct { - objects map[string]*unstructured.Unstructured -} - -func newFakeCluster() *fakeCluster { - return &fakeCluster{objects: make(map[string]*unstructured.Unstructured)} -} - -func (f *fakeCluster) store(obj *unstructured.Unstructured) { - f.objects[clusterKey(identityOf(obj))] = obj.DeepCopy() -} +type productMapper struct{} -func (f *fakeCluster) Get(_ context.Context, id predict.Identity) (*unstructured.Unstructured, error) { - obj, ok := f.objects[clusterKey(id)] - if !ok { - return nil, apierrors.NewNotFound(schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, id.Name) +func (productMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + version := "" + if len(versions) > 0 { + version = versions[0] } - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) Create(_ context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) Apply(_ context.Context, obj *unstructured.Unstructured, _ predict.ApplyOptions) (*unstructured.Unstructured, error) { - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) JSONPatch(context.Context, predict.Identity, []byte) error { - return nil -} - -func (f *fakeCluster) Delete(context.Context, predict.Identity) error { - return nil -} - -func (f *fakeCluster) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { scope := meta.RESTScopeNamespace - if gvk.Group == "" && gvk.Kind == "Namespace" { + if clusterScopedKind(gk) { scope = meta.RESTScopeRoot } return &meta.RESTMapping{ - Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"}, - GroupVersionKind: gvk, + Resource: schema.GroupVersionResource{Group: gk.Group, Version: version, Resource: strings.ToLower(gk.Kind) + "s"}, + GroupVersionKind: gk.WithVersion(version), Scope: scope, }, nil } -func clusterKey(id predict.Identity) string { - gv := schema.GroupVersion{Group: id.Group, Version: id.Version} - return fmt.Sprintf("%s/%s/%s/%s", gv.String(), id.Kind, id.Namespace, id.Name) -} - -func identityOf(obj *unstructured.Unstructured) predict.Identity { - gvk := obj.GroupVersionKind() - return predict.Identity{ - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), +func clusterScopedKind(gk schema.GroupKind) bool { + switch gk.Kind { + case "Namespace", "CustomResourceDefinition", "ClusterRole", "ClusterRoleBinding": + return true + default: + return false } } diff --git a/internal/plan/view/human_test.go b/internal/plan/view/human_test.go index 853c4bb..5eb24ed 100644 --- a/internal/plan/view/human_test.go +++ b/internal/plan/view/human_test.go @@ -97,125 +97,20 @@ func TestWriteHuman_CreateUpdateDelete(t *testing.T) { assert.NotContains(t, text, "@@") } -func TestWriteHuman_NoOpLimitationDiagnosticsOnly(t *testing.T) { - t.Parallel() - widget := semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "app"} - p := mustPlanWithHeader(t, humanHeader(), semantic.HelmUpgrade, nil, nil, []semantic.Diagnostic{{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: prediction used the currently installed CRD, but that CRD's spec changes before Helm executes", - Resource: &widget, - }}) - assert.False(t, p.IsNoOp()) - text := writeHuman(t, p) - assertGolden(t, "human_noop_limitation", text) - assert.NotContains(t, text, "Warning: prediction is incomplete") - assert.Contains(t, text, "Diagnostics") - assert.Contains(t, text, `example.com/v1/Widget "app"`) - assert.NotContains(t, text, "\nResources\n") -} - -func TestWriteHuman_Prerequisites(t *testing.T) { - t.Parallel() - widgetRef := semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "app"} - p := mustPlanWithHeader(t, semantic.Header{ - Project: "web", - Environment: "prod", - Release: "web", - Namespace: "prod", - Context: "kind-dev", - Revision: 1, - FreshInstall: true, - }, semantic.HelmInstall, []semantic.ResourceChange{ - { - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, - After: snap(map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": "prod", - "labels": map[string]any{"name": "prod"}, - }, - }), - Apply: writeApply(), - ApplyOrder: 1, - }, - { - Resource: widgetRef, - Origin: helmOrigin(), - Action: semantic.Create, - After: snap(widget("app", "blue")), - Apply: writeApply(), - ApplyOrder: 1, - }, - }, nil, []semantic.Diagnostic{{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "target namespace is created earlier in this deployment", - Resource: &widgetRef, - }}) - text := writeHuman(t, p) - assertGolden(t, "human_prerequisites", text) - assert.Contains(t, text, `+ create v1/Namespace "prod"`) - assert.Contains(t, text, `+ create example.com/v1/Widget "app"`) - assert.Contains(t, text, "Diagnostics") -} - -func TestWriteHuman_UpdateWithLimitationDiagnostic(t *testing.T) { - t.Parallel() - res := ref("ConfigMap", "web") - p := mustPlanWithHeader(t, humanHeader(), semantic.HelmUpgrade, []semantic.ResourceChange{ - knownConfigMapUpdate(res), - }, nil, []semantic.Diagnostic{predictionLimitation(res)}) - text := writeHuman(t, p) - assertGolden(t, "human_limitation", text) // full-output contract; Contains below are invariants only - assert.NotContains(t, text, "Warning: prediction is incomplete") - assert.Contains(t, text, `~ update v1/ConfigMap "web"`) - assert.Contains(t, text, "- key: v1") - assert.Contains(t, text, "+ key: v2") - assert.Contains(t, text, "prediction_limitation") - assert.NotContains(t, text, "completeness:") - assert.NotContains(t, text, "Actions:") -} - -func TestWriteHuman_LimitationDiagnosticPreservesKnownDetails(t *testing.T) { - t.Parallel() - header, changes, tasks := allActionsInputs() - plain := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, nil) - withDiag := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, []semantic.Diagnostic{ - predictionLimitation(ref("ConfigMap", "web")), - }) - assert.Equal(t, plain.Summary, withDiag.Summary) - - plainText := writeHuman(t, plain) - withDiagText := writeHuman(t, withDiag) - assert.Contains(t, withDiagText, "Diagnostics") - assert.NotContains(t, plainText, "Warning: prediction is incomplete") - assert.NotContains(t, withDiagText, "Warning: prediction is incomplete") - assert.NotContains(t, plainText, "Diagnostics") - require.Equal(t, plainText, stripDiagnostics(t, withDiagText)) -} - func TestWriteHuman_DeterministicMapOrder(t *testing.T) { t.Parallel() first := map[string]any{"z": "1", "a": "1", "m": "1"} second := map[string]any{"a": "1", "m": "1", "z": "1"} p1 := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(first), - Apply: writeApply(), - }}, nil) + }}) p2 := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(second), - Apply: writeApply(), - }}, nil) + }}) var b1, b2 bytes.Buffer require.NoError(t, view.WriteHuman(&b1, p1, view.Options{})) require.NoError(t, view.WriteHuman(&b2, p2, view.Options{})) @@ -226,12 +121,10 @@ func TestWriteHuman_DoesNotHTMLEscape(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(map[string]any{"note": "plain"}), After: snap(map[string]any{"note": map[string]any{"html": "a < b & c"}}), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -252,12 +145,10 @@ func TestWriteHuman_DoesNotMutatePlan(t *testing.T) { obj := secretObj("s", "old", "tok") p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(obj), After: snap(secretObj("s", "new", "tok2")), - Apply: writeApply(), - }}, nil) + }}) before := objectString(t, p.Changes[0].Before.Object, "stringData", "password") require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) assert.Equal(t, before, objectString(t, p.Changes[0].Before.Object, "stringData", "password")) @@ -278,11 +169,9 @@ func TestWriteHuman_KubernetesKeyOrder(t *testing.T) { } p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Deployment", "web"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(obj), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -325,11 +214,9 @@ func TestWriteHuman_GenerateNameBeforeNamespace(t *testing.T) { } p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", GenerateName: "app-"}, - Origin: helmOrigin(), Action: semantic.Create, After: snap(obj), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -350,11 +237,9 @@ func TestWriteHuman_ArrayOrderPreserved(t *testing.T) { } p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(obj), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -402,7 +287,7 @@ func TestWriteHuman_HeaderMetadata(t *testing.T) { if tt.header.FreshInstall { helmAction = semantic.HelmInstall } - p := mustPlanWithHeader(t, tt.header, helmAction, nil, nil, nil) + p := mustPlanWithHeader(t, tt.header, helmAction, nil, nil) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -418,45 +303,9 @@ func TestWriteHuman_HeaderMetadata(t *testing.T) { } } -func TestWriteHuman_DiagnosticWithoutResource(t *testing.T) { - t.Parallel() - p, err := semantic.New(semantic.Header{Release: "web"}, semantic.HelmNone, nil, nil, []semantic.Diagnostic{{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: cluster-scoped", - }}) - require.NoError(t, err) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() - assert.Contains(t, text, "warning prediction_limitation: prediction is not exact: cluster-scoped") - assert.NotContains(t, text, "ConfigMap/") -} - -func TestWriteHuman_BookkeepingOnlyUpdateKeepsResource(t *testing.T) { - t.Parallel() - before := noisyCM("web", "same", "11", "u-live") - after := noisyCM("web", "same", "22", "u-pred") - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), - Action: semantic.Update, - Before: snap(before), - After: snap(after), - Apply: writeApply(), - }}, nil) - var buf bytes.Buffer - require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) - text := buf.String() - assert.Contains(t, text, `~ update v1/ConfigMap "web"`) - assert.NotContains(t, text, "- key:") - assert.NotContains(t, text, "+ key:") - assertNoBookkeeping(t, text) -} - func TestWriteHuman_EmptyPlan(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmNone, nil, nil) + p := mustPlan(t, semantic.HelmNone, nil) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -472,7 +321,7 @@ func TestWriteHuman_EmptyPlan(t *testing.T) { func TestWriteHuman_WriterError(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}, nil) + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) err := view.WriteHuman(errWriter{}, p, view.Options{}) require.Error(t, err) assert.ErrorContains(t, err, "write failed") @@ -486,7 +335,7 @@ func (errWriter) Write([]byte) (int, error) { func TestWriteHuman_ZeroThemeIsPlainText(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}, nil) + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) assert.NotContains(t, buf.String(), "\x1b[") @@ -506,10 +355,8 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { name: "create dumps full object", change: semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "w"}, - Origin: helmOrigin(), Action: semantic.Create, After: snap(widget("w", "blue")), - Apply: writeApply(), }, contains: []string{`+ create example.com/v1/Widget "w"`, "+ apiVersion: example.com/v1", "size: large"}, }, @@ -517,11 +364,9 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { name: "update projects changed leaves", change: semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "u"}, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(widget("u", "blue")), After: snap(widget("u", "red")), - Apply: writeApply(), }, contains: []string{`~ update example.com/v1/Widget "u"`, "color:"}, omits: []string{"size"}, @@ -530,10 +375,8 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { name: "delete dumps full object", change: semantic.ResourceChange{ Resource: semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "d"}, - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(widget("d", "blue")), - Apply: deleteApply(), }, contains: []string{`- delete example.com/v1/Widget "d"`, "- apiVersion: example.com/v1", "size: large"}, }, @@ -541,7 +384,7 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil) + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -598,11 +441,9 @@ func TestWriteHuman_GVKAndNamespaceHeadings(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: tt.ref, - Origin: helmOrigin(), Action: semantic.Create, After: snap(k8sObj(tt.ref.APIVersion, tt.ref.Kind, tt.ref.Namespace, tt.ref.Name)), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -700,12 +541,10 @@ func TestWriteHuman_UpdateProjection(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: tt.ref, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(tt.before), After: snap(tt.after), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -726,14 +565,11 @@ func TestWriteHuman_ScheduledTaskNotDuplicated(t *testing.T) { p := mustPlanWithTasks(t, semantic.HelmUpgrade, []semantic.ResourceChange{ { Resource: api, - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("api", "v1")), - Apply: writeApply(), }, { Resource: cron, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(map[string]any{ "apiVersion": "batch/v1", @@ -747,14 +583,13 @@ func TestWriteHuman_ScheduledTaskNotDuplicated(t *testing.T) { "metadata": map[string]any{"name": "cleanup", "namespace": "prod"}, "spec": map[string]any{"schedule": "0 3 * * *"}, }), - Apply: writeApply(), }, }, []semantic.TaskPlan{{ Name: "cleanup", Phase: semantic.TaskSchedule, Action: semantic.TaskUpdate, Resources: []semantic.ResourceRef{cron}, - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -796,10 +631,8 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { name: "create", change: semantic.ResourceChange{ Resource: cron, - Origin: helmOrigin(), Action: semantic.Create, After: snap(cronJob("web-cleanup", "cleanup", "0 3 * * *")), - Apply: writeApply(), }, task: semantic.TaskPlan{ Name: "cleanup", @@ -818,10 +651,8 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { name: "delete", change: semantic.ResourceChange{ Resource: cron, - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(cronJob("web-cleanup", "cleanup", "0 3 * * *")), - Apply: deleteApply(), }, task: semantic.TaskPlan{ Name: "cleanup", @@ -840,7 +671,7 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p := mustPlanWithTasks(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, []semantic.TaskPlan{tt.task}, nil) + p := mustPlanWithTasks(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, []semantic.TaskPlan{tt.task}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -865,7 +696,7 @@ func TestWriteHuman_UnchangedTaskWillRun(t *testing.T) { Phase: semantic.TaskPreDeploy, Action: semantic.TaskUnchanged, WillRun: true, - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -918,7 +749,7 @@ func TestWriteHuman_TaskFooter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p := mustPlanWithTasks(t, semantic.HelmUpgrade, nil, tt.tasks, nil) + p := mustPlanWithTasks(t, semantic.HelmUpgrade, nil, tt.tasks) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -947,19 +778,15 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{ { Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), }, { Resource: ref("ConfigMap", "old"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("old", "v1")), - Apply: writeApply(), }, - }, nil), + }), contains: []string{ "+ key: v1\n\n + create v1/ConfigMap \"old\"", " Resources: 2 create, 0 update, 0 delete", @@ -980,7 +807,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { Action: semantic.TaskUnchanged, WillRun: true, }, - }, nil), + }), contains: []string{"~ migrate changed, will run\n\n seed will run"}, wantTasksFooter: true, }, @@ -1003,7 +830,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { After: snap(cm("z-env", "v1")), }, }, - }}, nil), + }}), contains: []string{"+ key: v1\n\n + create v1/ConfigMap \"z-env\""}, wantTasksFooter: true, }, @@ -1022,7 +849,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { Action: semantic.TaskCreate, WillRun: true, }, - }, nil), + }), contains: []string{" seed will run\n\n postDeploy\n"}, wantTasksFooter: true, }, @@ -1044,7 +871,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { func TestWriteHuman_SummaryOmitsTasksWhenAbsent(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}, nil) + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -1059,7 +886,7 @@ func TestWriteHuman_SummaryOmitsTasksWhenAbsent(t *testing.T) { func allActionsPlan(tb testing.TB) semantic.Plan { tb.Helper() header, changes, tasks := allActionsInputs() - return mustPlanWithHeader(tb, header, semantic.HelmUpgrade, changes, tasks, nil) + return mustPlanWithHeader(tb, header, semantic.HelmUpgrade, changes, tasks) } func allActionsInputs() (semantic.Header, []semantic.ResourceChange, []semantic.TaskPlan) { @@ -1067,33 +894,25 @@ func allActionsInputs() (semantic.Header, []semantic.ResourceChange, []semantic. changes := []semantic.ResourceChange{ { Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cmWithMeta("app", "v1")), - Apply: writeApply(), }, { Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(cm("web", "v1")), After: snap(cm("web", "v2")), - Apply: writeApply(), }, { Resource: ref("ConfigMap", "old"), - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(cmWithMeta("old", "v1")), - Apply: deleteApply(), }, { Resource: cron, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(cronJob("web-cleanup", "cleanup", "0 2 * * *")), After: snap(cronJob("web-cleanup", "cleanup", "0 3 * * *")), - Apply: writeApply(), }, } tasks := []semantic.TaskPlan{ @@ -1158,35 +977,6 @@ func assertHumanLayout(t *testing.T, text string) { assert.Contains(t, text, "\n Resources:") } -func knownConfigMapUpdate(res semantic.ResourceRef) semantic.ResourceChange { - return semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Update, - Before: snap(cm(res.Name, "v1")), - After: snap(cm(res.Name, "v2")), - Apply: writeApply(), - } -} - -func predictionLimitation(res semantic.ResourceRef) semantic.Diagnostic { - return semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: managed-fields-migration", - Resource: new(res), - } -} - -func stripDiagnostics(t *testing.T, text string) string { - t.Helper() - diagStart := strings.Index(text, "\nDiagnostics\n") - summaryStart := strings.Index(text, "\nSummary\n") - require.Greater(t, diagStart, -1) - require.Greater(t, summaryStart, diagStart) - return text[:diagStart] + text[summaryStart:] -} - func assertHumanHeaderMetadata(t *testing.T, text, kubeContext string, revision int) { t.Helper() block := fmt.Sprintf("Context: %s\nNamespace: prod\nRelease: web\nRevision: %d\n", kubeContext, revision) diff --git a/internal/plan/view/json.go b/internal/plan/view/json.go index 5ae011a..fa5b5d8 100644 --- a/internal/plan/view/json.go +++ b/internal/plan/view/json.go @@ -23,14 +23,13 @@ import ( ) type document struct { - Schema string `json:"schema"` - Header headerDTO `json:"header"` - HelmAction string `json:"helmAction"` - Changes []changeDTO `json:"changes"` - Tasks []taskDTO `json:"tasks"` - ChartCRDs []chartCRDDTO `json:"chartCRDs"` - Diagnostics []diagDTO `json:"diagnostics"` - Summary summaryDTO `json:"summary"` + Schema string `json:"schema"` + Header headerDTO `json:"header"` + HelmAction string `json:"helmAction"` + Changes []changeDTO `json:"changes"` + Tasks []taskDTO `json:"tasks"` + ChartCRDs []chartCRDDTO `json:"chartCRDs"` + Summary summaryDTO `json:"summary"` } type headerDTO struct { @@ -45,12 +44,10 @@ type headerDTO struct { type changeDTO struct { Resource resourceDTO `json:"resource"` - Origin originDTO `json:"origin"` Action string `json:"action"` Before any `json:"before"` After any `json:"after"` Fields []fieldDTO `json:"fields"` - Apply applyDTO `json:"apply"` } type resourceDTO struct { @@ -61,31 +58,6 @@ type resourceDTO struct { GenerateName string `json:"generateName,omitempty"` } -type originDTO struct { - Kind string `json:"kind"` - Helm *helmDTO `json:"helm,omitempty"` -} - -type helmDTO struct { - Release string `json:"release"` - Namespace string `json:"namespace"` -} - -type applyDTO struct { - Write *writeDTO `json:"write,omitempty"` - Delete *deleteDTO `json:"delete,omitempty"` -} - -type writeDTO struct { - Method string `json:"method"` - FieldManager string `json:"fieldManager,omitempty"` - ForceConflicts bool `json:"forceConflicts"` -} - -type deleteDTO struct { - Propagation string `json:"propagation"` -} - type fieldDTO struct { Path string `json:"path"` Op string `json:"op"` @@ -93,13 +65,6 @@ type fieldDTO struct { After *any `json:"after,omitempty"` } -type diagDTO struct { - Severity string `json:"severity"` - Category string `json:"category"` - Message string `json:"message"` - Resource *resourceDTO `json:"resource,omitempty"` -} - type summaryDTO struct { Create int `json:"create"` Update int `json:"update"` @@ -158,10 +123,6 @@ func newDocument(p semantic.Plan, opts Options) (document, error) { for _, c := range prepared.Changes { changes = append(changes, toChangeDTO(c)) } - diags := make([]diagDTO, 0, len(prepared.Diagnostics)) - for _, d := range prepared.Diagnostics { - diags = append(diags, toDiagDTO(d)) - } tasks := make([]taskDTO, 0, len(prepared.Tasks)) for _, t := range prepared.Tasks { tasks = append(tasks, toTaskDTO(t)) @@ -171,14 +132,13 @@ func newDocument(p semantic.Plan, opts Options) (document, error) { crds = append(crds, toChartCRDDTO(c)) } return document{ - Schema: SchemaV1ID, - Header: toHeaderDTO(prepared.Header), - HelmAction: prepared.HelmAction.String(), - Changes: changes, - Tasks: tasks, - ChartCRDs: crds, - Diagnostics: diags, - Summary: toSummaryDTO(prepared.Summary), + Schema: SchemaV1ID, + Header: toHeaderDTO(prepared.Header), + HelmAction: prepared.HelmAction.String(), + Changes: changes, + Tasks: tasks, + ChartCRDs: crds, + Summary: toSummaryDTO(prepared.Summary), }, nil } @@ -199,32 +159,12 @@ func toChangeDTO(c semantic.ResourceChange) changeDTO { for _, f := range c.Fields { fields = append(fields, toFieldDTO(f)) } - origin := originDTO{Kind: c.Origin.Kind.String()} - if c.Origin.Helm != nil { - origin.Helm = &helmDTO{ - Release: c.Origin.Helm.Release, - Namespace: c.Origin.Helm.Namespace, - } - } - apply := applyDTO{} - if c.Apply.Write != nil { - apply.Write = &writeDTO{ - Method: c.Apply.Write.Method.String(), - FieldManager: c.Apply.Write.FieldManager, - ForceConflicts: c.Apply.Write.ForceConflicts, - } - } - if c.Apply.Delete != nil { - apply.Delete = &deleteDTO{Propagation: c.Apply.Delete.Propagation.String()} - } return changeDTO{ Resource: toResourceDTO(c.Resource), - Origin: origin, Action: c.Action.String(), Before: snapshotJSON(c.Before), After: snapshotJSON(c.After), Fields: fields, - Apply: apply, } } @@ -252,19 +192,6 @@ func toResourceDTO(r semantic.ResourceRef) resourceDTO { } } -func toDiagDTO(d semantic.Diagnostic) diagDTO { - dto := diagDTO{ - Severity: d.Severity.String(), - Category: d.Category.String(), - Message: d.Message, - } - if d.Resource != nil { - r := toResourceDTO(*d.Resource) - dto.Resource = &r - } - return dto -} - func toSummaryDTO(s semantic.Summary) summaryDTO { return summaryDTO{ Create: s.Create, diff --git a/internal/plan/view/json_test.go b/internal/plan/view/json_test.go index 67bf7a2..ea3b008 100644 --- a/internal/plan/view/json_test.go +++ b/internal/plan/view/json_test.go @@ -17,6 +17,7 @@ package view_test import ( "bytes" "encoding/json" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -30,7 +31,7 @@ import ( func TestWriteJSON_HeaderContextAndRevision(t *testing.T) { t.Parallel() - p := mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil, nil) + p := mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) assertJSONAt(t, buf.Bytes(), `"production-eu"`, "header", "context") @@ -40,7 +41,7 @@ func TestWriteJSON_HeaderContextAndRevision(t *testing.T) { func TestWriteJSON_SchemaAndTasks(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmNone, nil, nil) + p := mustPlan(t, semantic.HelmNone, nil) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) assert.JSONEq(t, `{ @@ -55,7 +56,6 @@ func TestWriteJSON_SchemaAndTasks(t *testing.T) { "changes": [], "tasks": [], "chartCRDs": [], - "diagnostics": [], "summary": { "create": 0, "update": 0, @@ -75,25 +75,21 @@ func TestWriteJSON_SchemaAndTasks(t *testing.T) { validatePlanSchema(t, buf.Bytes()) } -func TestWriteJSON_WriteCreateOmitsFieldManager(t *testing.T) { +func TestWriteJSON_CreateChangeShape(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeCreate(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) - var doc map[string]any - require.NoError(t, json.Unmarshal(buf.Bytes(), &doc)) - write := jsonObject(jsonObject(jsonObjects(t, doc["changes"])[0]["apply"])["write"]) - require.NotNil(t, write) - assert.Equal(t, "create", write["method"]) - _, hasManager := write["fieldManager"] - assert.False(t, hasManager) - assert.Equal(t, false, write["forceConflicts"]) + change := jsonObjects(t, jsonObject(mustJSON(t, buf.Bytes()))["changes"])[0] + assert.Equal(t, []string{"action", "after", "before", "fields", "resource"}, sortedKeys(change)) + assert.Equal(t, "create", change["action"]) + assert.Nil(t, change["before"]) + assert.NotNil(t, change["after"]) + assert.Empty(t, change["fields"]) validatePlanSchema(t, buf.Bytes()) } @@ -109,7 +105,7 @@ func TestWriteJSON_TasksContract(t *testing.T) { Action: semantic.Create, After: snap(cm("migrate", "v1")), }}, - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) assert.JSONEq(t, `{ @@ -142,7 +138,6 @@ func TestWriteJSON_TasksContract(t *testing.T) { "resources": [] }], "chartCRDs": [], - "diagnostics": [], "summary": {"create": 0, "update": 0, "delete": 0, "total": 0} }`, buf.String()) validatePlanSchema(t, buf.Bytes()) @@ -165,7 +160,7 @@ func TestWriteJSON_ChartCRDs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - p := mustPlanWithHeader(t, tc.header, tc.action, nil, nil, nil) + p := mustPlanWithHeader(t, tc.header, tc.action, nil, nil) var err error p, err = semantic.AttachChartCRDs(p, []semantic.ChartCRD{{ Source: ".deployah/crds/widgets.yaml", @@ -203,12 +198,10 @@ func TestWriteJSON_Deterministic(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(map[string]any{"z": "1", "a": "1"}), After: snap(map[string]any{"a": "2", "z": "2"}), - Apply: writeApply(), - }}, nil) + }}) var b1, b2 bytes.Buffer require.NoError(t, view.WriteJSON(&b1, p, view.Options{})) require.NoError(t, view.WriteJSON(&b2, p, view.Options{})) @@ -234,11 +227,9 @@ func TestWriteJSON_CamelCasePropertyNames(t *testing.T) { Namespace: "prod", GenerateName: "app-", }, - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), - }}, nil, nil) + }}, nil) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) raw := buf.Bytes() @@ -256,11 +247,9 @@ func TestWriteJSON_CamelCasePropertyNames(t *testing.T) { "name": "", "generateName": "app-" }`, "changes", 0, "resource") - assertJSONAt(t, raw, `{ - "method": "server_side_apply", - "fieldManager": "deployah", - "forceConflicts": false - }`, "changes", 0, "apply", "write") + change := jsonObjects(t, jsonObject(mustJSON(t, raw))["changes"])[0] + assert.Equal(t, "create", change["action"]) + assert.Empty(t, change["fields"]) assertNoJSONKeysFromBytes(t, raw) validatePlanSchema(t, raw) } @@ -276,12 +265,10 @@ func TestWriteJSON_ShowSecrets(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(secretObj("s", "old-pass", "old-tok")), After: snap(secretObj("s", "new-pass", "new-tok")), - Apply: writeApply(), - }}, nil) + }}) tests := []struct { name string opts view.Options @@ -333,12 +320,10 @@ func TestWriteJSON_DoesNotMutatePlan(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(secretObj("s", "old-pass", "old-tok")), After: snap(secretObj("s", "new-pass", "new-tok")), - Apply: writeApply(), - }}, nil) + }}) require.NoError(t, view.WriteJSON(&bytes.Buffer{}, p, view.Options{})) assert.Equal(t, "old-pass", objectString(t, p.Changes[0].Before.Object, "stringData", "password")) } @@ -347,7 +332,6 @@ func TestWriteJSON_ExplicitNullFields(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(map[string]any{ "keep": "x", @@ -361,8 +345,7 @@ func TestWriteJSON_ExplicitNullFields(t *testing.T) { "swap": true, "stays": nil, }), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) assertJSONAt(t, buf.Bytes(), `[ @@ -381,36 +364,24 @@ func TestWriteJSON_MatchesSchemaForRepresentativePlans(t *testing.T) { name string plan semantic.Plan }{ - {name: "empty", plan: mustPlan(t, semantic.HelmNone, nil, nil)}, + {name: "empty", plan: mustPlan(t, semantic.HelmNone, nil)}, {name: "create", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), - }}, nil)}, + }})}, {name: "delete", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(cm("app", "v1")), - Apply: deleteApply(), - }}, nil)}, - {name: "update without after", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ + }})}, + {name: "update", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(cm("app", "v1")), - Apply: writeApply(), - }}, []semantic.Diagnostic{limitationFor(res)})}, - {name: "create write create", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Create, - After: snap(cm("app", "v1")), - Apply: writeCreate(), - }}, nil)}, - {name: "origin namespace", plan: mustPlanWithHeader(t, semantic.Header{ + After: snap(cm("app", "v2")), + }})}, + {name: "namespace create", plan: mustPlanWithHeader(t, semantic.Header{ Project: "web", Environment: "prod", Release: "web", @@ -418,15 +389,13 @@ func TestWriteJSON_MatchesSchemaForRepresentativePlans(t *testing.T) { FreshInstall: true, }, semantic.HelmInstall, []semantic.ResourceChange{{ Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, Action: semantic.Create, After: snap(map[string]any{ "apiVersion": "v1", "kind": "Namespace", "metadata": map[string]any{"name": "prod"}, }), - Apply: writeApply(), - }}, nil, nil)}, + }}, nil)}, {name: "task create", plan: mustPlanWithTasks(t, semantic.HelmUpgrade, nil, []semantic.TaskPlan{{ Name: "migrate", Phase: semantic.TaskPreDeploy, @@ -437,7 +406,7 @@ func TestWriteJSON_MatchesSchemaForRepresentativePlans(t *testing.T) { Action: semantic.Create, After: snap(cm("migrate", "v1")), }}, - }}, nil)}, + }})}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -460,11 +429,9 @@ func TestWriteJSON_SnapshotMayUseProtocolKeyNames(t *testing.T) { } p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(obj), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteJSON(&buf, p, view.Options{})) assertJSONAt(t, buf.Bytes(), `{ @@ -476,15 +443,6 @@ func TestWriteJSON_SnapshotMayUseProtocolKeyNames(t *testing.T) { validatePlanSchema(t, buf.Bytes()) } -func limitationFor(res semantic.ResourceRef) semantic.Diagnostic { - return semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: managed-fields-migration", - Resource: &res, - } -} - func compilePlanSchema(t *testing.T) *jsonschema.Schema { t.Helper() compiler := jsonschema.NewCompiler() @@ -531,9 +489,22 @@ func assertNoJSONKeysFromBytes(t *testing.T, raw []byte) { for _, item := range jsonObjects(t, doc["chartCRDs"]) { assertNoJSONKeys(t, item) } - for _, item := range jsonObjects(t, doc["diagnostics"]) { - assertNoJSONDiagKeys(t, item) +} + +func mustJSON(t *testing.T, raw []byte) map[string]any { + t.Helper() + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + return doc +} + +func sortedKeys(obj map[string]any) []string { + keys := make([]string, 0, len(obj)) + for key := range obj { + keys = append(keys, key) } + slices.Sort(keys) + return keys } func jsonObject(v any) map[string]any { @@ -564,21 +535,6 @@ func assertNoJSONChangeKeys(t *testing.T, change map[string]any) { t.Helper() assertNoJSONKeys(t, change) assertNoJSONResourceKeys(t, change["resource"]) - if origin := jsonObject(change["origin"]); origin != nil { - assertNoJSONKeys(t, origin) - if helm := jsonObject(origin["helm"]); helm != nil { - assertNoJSONKeys(t, helm) - } - } - if apply := jsonObject(change["apply"]); apply != nil { - assertNoJSONKeys(t, apply) - if write := jsonObject(apply["write"]); write != nil { - assertNoJSONKeys(t, write) - } - if del := jsonObject(apply["delete"]); del != nil { - assertNoJSONKeys(t, del) - } - } assertNoJSONFieldKeys(t, change["fields"]) } @@ -595,14 +551,6 @@ func assertNoJSONTaskKeys(t *testing.T, task map[string]any) { } } -func assertNoJSONDiagKeys(t *testing.T, diag map[string]any) { - t.Helper() - assertNoJSONKeys(t, diag) - if res := diag["resource"]; res != nil { - assertNoJSONResourceKeys(t, res) - } -} - func assertNoJSONResourceKeys(t *testing.T, raw any) { t.Helper() res, isObject := raw.(map[string]any) diff --git a/internal/plan/view/prepare.go b/internal/plan/view/prepare.go index a300f83..313a42a 100644 --- a/internal/plan/view/prepare.go +++ b/internal/plan/view/prepare.go @@ -43,22 +43,6 @@ func validateRenderable(p semantic.Plan) error { semantic.Delete.String()); err != nil { return err } - if err := requireEnum(fmt.Sprintf("change %d origin", i), c.Origin.Kind.String(), - semantic.OriginHelm.String(), semantic.OriginNamespace.String()); err != nil { - return err - } - if c.Apply.Write != nil { - if err := requireEnum(fmt.Sprintf("change %d write method", i), c.Apply.Write.Method.String(), - semantic.WriteCreate.String(), semantic.WriteServerSide.String()); err != nil { - return err - } - } - if c.Apply.Delete != nil { - if err := requireEnum(fmt.Sprintf("change %d delete propagation", i), c.Apply.Delete.Propagation.String(), - semantic.PropagationBackground.String()); err != nil { - return err - } - } for j, f := range c.Fields { if err := requireEnum(fmt.Sprintf("change %d field %d op", i, j), f.Op.String(), semantic.FieldAdd.String(), semantic.FieldRemove.String(), semantic.FieldReplace.String()); err != nil { @@ -133,16 +117,6 @@ func validateRenderable(p semantic.Plan) error { return err } } - for i, d := range p.Diagnostics { - if err := requireEnum(fmt.Sprintf("diagnostic %d severity", i), d.Severity.String(), - semantic.DiagnosticWarning.String()); err != nil { - return err - } - if err := requireEnum(fmt.Sprintf("diagnostic %d category", i), d.Category.String(), - semantic.CategoryPredictionLimitation.String()); err != nil { - return err - } - } return nil } diff --git a/internal/plan/view/prepare_test.go b/internal/plan/view/prepare_test.go index 120e356..9174419 100644 --- a/internal/plan/view/prepare_test.go +++ b/internal/plan/view/prepare_test.go @@ -30,10 +30,8 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { validChange := func() semantic.ResourceChange { return semantic.ResourceChange{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), } } tests := []struct { @@ -45,44 +43,10 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { name: "invalid action", plan: semantic.Plan{ HelmAction: semantic.HelmUpgrade, - Changes: []semantic.ResourceChange{{Origin: helmOrigin()}}, + Changes: []semantic.ResourceChange{{}}, }, wantErr: "invalid change 0 action", }, - { - name: "invalid origin", - plan: semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Changes: []semantic.ResourceChange{{ - Action: semantic.Create, - }}, - }, - wantErr: "invalid change 0 origin", - }, - { - name: "invalid write method", - plan: semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Changes: []semantic.ResourceChange{{ - Action: semantic.Create, - Origin: helmOrigin(), - Apply: semantic.ApplySemantics{Write: &semantic.WriteSemantics{}}, - }}, - }, - wantErr: "invalid change 0 write method", - }, - { - name: "invalid delete propagation", - plan: semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Changes: []semantic.ResourceChange{{ - Action: semantic.Delete, - Origin: helmOrigin(), - Apply: semantic.ApplySemantics{Delete: &semantic.DeleteSemantics{}}, - }}, - }, - wantErr: "invalid change 0 delete propagation", - }, { name: "invalid field op", plan: func() semantic.Plan { @@ -92,28 +56,6 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { }(), wantErr: "invalid change 0 field 0 op", }, - { - name: "invalid diagnostic severity", - plan: semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Diagnostics: []semantic.Diagnostic{{ - Category: semantic.CategoryPredictionLimitation, - Message: "x", - }}, - }, - wantErr: "invalid diagnostic 0 severity", - }, - { - name: "invalid diagnostic category", - plan: semantic.Plan{ - HelmAction: semantic.HelmUpgrade, - Diagnostics: []semantic.Diagnostic{{ - Severity: semantic.DiagnosticWarning, - Message: "x", - }}, - }, - wantErr: "invalid diagnostic 0 category", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/plan/view/redact.go b/internal/plan/view/redact.go index 7a8a39a..a343f7f 100644 --- a/internal/plan/view/redact.go +++ b/internal/plan/view/redact.go @@ -31,25 +31,6 @@ func copyPlan(p semantic.Plan) semantic.Plan { c.Before = copySnapshot(c.Before) c.After = copySnapshot(c.After) c.Fields = copyFields(c.Fields) - if c.Origin.Helm != nil { - h := *c.Origin.Helm - c.Origin.Helm = &h - } - if c.Apply.Write != nil { - w := *c.Apply.Write - c.Apply.Write = &w - } - if c.Apply.Delete != nil { - d := *c.Apply.Delete - c.Apply.Delete = &d - } - } - out.Diagnostics = slices.Clone(p.Diagnostics) - for i := range out.Diagnostics { - if out.Diagnostics[i].Resource != nil { - r := *out.Diagnostics[i].Resource - out.Diagnostics[i].Resource = &r - } } out.Tasks = slices.Clone(p.Tasks) if out.Tasks == nil { diff --git a/internal/plan/view/redact_test.go b/internal/plan/view/redact_test.go index a00a41a..9d58431 100644 --- a/internal/plan/view/redact_test.go +++ b/internal/plan/view/redact_test.go @@ -30,12 +30,10 @@ func TestSecretRedaction_DefaultAndShowSecrets(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(secretObj("s", "old-pass", "old-tok")), After: snap(secretObj("s", "new-pass", "new-tok")), - Apply: writeApply(), - }}, nil) + }}) var hidden bytes.Buffer require.NoError(t, view.WriteHuman(&hidden, p, view.Options{})) @@ -60,12 +58,10 @@ func TestSecretRedaction_ConfigMapNotRedacted(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(cm("app", "old")), After: snap(cm("app", "new")), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) assert.Contains(t, buf.String(), "old") @@ -77,12 +73,10 @@ func TestSecretRedaction_FieldChangeComputedBeforeRedaction(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(secretObj("s", "old-pass", "same")), After: snap(secretObj("s", "new-pass", "same")), - Apply: writeApply(), - }}, nil) + }}) require.NotEmpty(t, p.Changes[0].Fields) idx := slices.IndexFunc(p.Changes[0].Fields, func(f semantic.FieldChange) bool { return f.Path == "/stringData/password" @@ -119,11 +113,9 @@ func TestSecretRedaction_CoreAPIVersions(t *testing.T) { res.APIVersion = tt.apiVersion p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, After: snap(obj), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -141,7 +133,6 @@ func TestSecretRedaction_MissingSectionsStayAbsent(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(map[string]any{ "apiVersion": "v1", @@ -149,8 +140,7 @@ func TestSecretRedaction_MissingSectionsStayAbsent(t *testing.T) { "metadata": map[string]any{"name": "s", "namespace": "prod"}, "type": "Opaque", }), - Apply: writeApply(), - }}, nil) + }}) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() @@ -181,7 +171,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { name: "nested values", change: semantic.ResourceChange{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(map[string]any{ "apiVersion": "v1", @@ -194,7 +183,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { }, "stringData": map[string]string{"plain": "secret"}, }), - Apply: writeApply(), }, omit: []string{`"secret"`}, at: []jsonPathWant{{ @@ -216,7 +204,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { name: "null leaf stays null", change: semantic.ResourceChange{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(map[string]any{ "apiVersion": "v1", @@ -224,7 +211,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { "metadata": map[string]any{"name": "s", "namespace": "prod"}, "data": map[string]any{"token": nil}, }), - Apply: writeApply(), }, humanContains: []string{"token: null"}, at: []jsonPathWant{{ @@ -236,7 +222,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { name: "whole map add keeps keys", change: semantic.ResourceChange{ Resource: ref("Secret", "s"), - Origin: helmOrigin(), Action: semantic.Update, Before: snap(map[string]any{ "apiVersion": "v1", @@ -245,7 +230,6 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { "type": "Opaque", }), After: snap(secretObj("s", "new-pass", "new-tok")), - Apply: writeApply(), }, omit: []string{"new-pass", "new-tok"}, humanContains: []string{"token: (redacted)", "password: (redacted)"}, @@ -264,7 +248,7 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}, nil) + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{tt.change}) var human, jsonBuf bytes.Buffer require.NoError(t, view.WriteHuman(&human, p, view.Options{})) require.NoError(t, view.WriteJSON(&jsonBuf, p, view.Options{})) @@ -282,44 +266,20 @@ func TestSecretRedaction_JSONValueShapes(t *testing.T) { func TestWriteRenderers_CopyIsolation(t *testing.T) { t.Parallel() - helm := &semantic.HelmOrigin{Release: "web", Namespace: "prod"} - write := &semantic.WriteSemantics{Method: semantic.WriteServerSide, FieldManager: "deployah"} res := ref("Secret", "s") - diag := semantic.Diagnostic{ - Severity: semantic.DiagnosticWarning, - Category: semantic.CategoryPredictionLimitation, - Message: "prediction is not exact: managed-fields-migration", - Resource: &res, - } - labels := map[string]string{"app": "web"} - args := []string{"serve"} - beforeObj := secretObj("s", "old", "tok") - meta, ok := beforeObj["metadata"].(map[string]any) - require.True(t, ok) - meta["labels"] = labels - beforeObj["args"] = args - p, err := semantic.New(semantic.Header{Release: "web", Namespace: "prod"}, semantic.HelmUpgrade, []semantic.ResourceChange{{ + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: helm}, Action: semantic.Update, - Before: snap(beforeObj), + Before: snap(secretObj("s", "old", "tok")), After: snap(secretObj("s", "new", "tok2")), - Apply: semantic.ApplySemantics{Write: write}, - }}, nil, []semantic.Diagnostic{diag}) - require.NoError(t, err) + }}) require.NotEmpty(t, p.Changes[0].Fields) fieldBefore := p.Changes[0].Fields[0].Before fieldAfter := p.Changes[0].Fields[0].After - helmRelease := p.Changes[0].Origin.Helm.Release - fieldManager := p.Changes[0].Apply.Write.FieldManager - diagName := p.Diagnostics[0].Resource.Name require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) require.NoError(t, view.WriteJSON(&bytes.Buffer{}, p, view.Options{})) - assert.Equal(t, helmRelease, p.Changes[0].Origin.Helm.Release) - assert.Equal(t, fieldManager, p.Changes[0].Apply.Write.FieldManager) - assert.Equal(t, diagName, p.Diagnostics[0].Resource.Name) assert.Equal(t, "old", objectString(t, p.Changes[0].Before.Object, "stringData", "password")) assert.Equal(t, "new", objectString(t, p.Changes[0].After.Object, "stringData", "password")) assert.Equal(t, fieldBefore, p.Changes[0].Fields[0].Before) @@ -336,7 +296,6 @@ func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { Changes: []semantic.ResourceChange{ { Resource: ref("ConfigMap", "app"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{Object: map[string]any{ "labels": map[string]string{"app": "web"}, @@ -344,14 +303,11 @@ func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { "nested": []any{"x"}, "empty": map[string]any(nil), }}, - Apply: writeApply(), }, { Resource: ref("ConfigMap", "blank"), - Origin: helmOrigin(), Action: semantic.Create, After: &semantic.ResourceSnapshot{}, - Apply: writeApply(), }, }, } @@ -401,19 +357,15 @@ func TestSecretRedaction_OmitsPlaintext(t *testing.T) { plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{ { Resource: ref("Secret", "created"), - Origin: helmOrigin(), Action: semantic.Create, After: snap(secretObj("created", "c-pass", "c-tok")), - Apply: writeApply(), }, { Resource: ref("Secret", "removed"), - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(secretObj("removed", "d-pass", "d-tok")), - Apply: deleteApply(), }, - }, nil), + }), omit: []string{"c-pass", "d-pass", "c-tok", "d-tok"}, at: []jsonPathWant{ {want: redactedData, path: []any{"changes", 0, "after", "data"}}, @@ -449,7 +401,7 @@ func TestSecretRedaction_OmitsPlaintext(t *testing.T) { Before: snap(secretObj("removed", "d-pass", "d-tok")), }, }, - }}, nil), + }}), omit: []string{"old-pass", "new-pass", "c-pass", "d-pass", "old-tok", "new-tok", "c-tok", "d-tok"}, at: []jsonPathWant{ {want: redactedData, path: []any{"tasks", 0, "definitions", 0, "after", "data"}}, diff --git a/internal/plan/view/schema/semantic_plan.v1.json b/internal/plan/view/schema/semantic_plan.v1.json index f2eb86c..2f8de10 100644 --- a/internal/plan/view/schema/semantic_plan.v1.json +++ b/internal/plan/view/schema/semantic_plan.v1.json @@ -12,7 +12,6 @@ "changes", "tasks", "chartCRDs", - "diagnostics", "summary" ], "properties": { @@ -45,12 +44,6 @@ "$ref": "#/$defs/ChartCRD" } }, - "diagnostics": { - "type": "array", - "items": { - "$ref": "#/$defs/Diagnostic" - } - }, "summary": { "$ref": "#/$defs/Summary" } @@ -121,21 +114,7 @@ } }, "changes": { - "items": { - "not": { - "properties": { - "origin": { - "properties": { - "kind": { - "const": "helm" - } - }, - "required": ["kind"] - } - }, - "required": ["origin"] - } - } + "maxItems": 0 } } } @@ -197,20 +176,15 @@ "additionalProperties": false, "required": [ "resource", - "origin", "action", "before", "after", - "fields", - "apply" + "fields" ], "properties": { "resource": { "$ref": "#/$defs/Resource" }, - "origin": { - "$ref": "#/$defs/Origin" - }, "action": { "type": "string", "enum": ["create", "update", "delete"] @@ -226,9 +200,6 @@ "items": { "$ref": "#/$defs/FieldChange" } - }, - "apply": { - "$ref": "#/$defs/Apply" } }, "allOf": [ @@ -251,12 +222,6 @@ }, "fields": { "maxItems": 0 - }, - "apply": { - "required": ["write"], - "not": { - "required": ["delete"] - } } } } @@ -275,21 +240,11 @@ "before": { "type": "object" }, - "apply": { - "required": ["write"], - "not": { - "required": ["delete"] - }, - "properties": { - "write": { - "properties": { - "method": { - "const": "server_side_apply" - } - }, - "required": ["method"] - } - } + "after": { + "type": "object" + }, + "fields": { + "minItems": 1 } } } @@ -313,12 +268,6 @@ }, "fields": { "maxItems": 0 - }, - "apply": { - "required": ["delete"], - "not": { - "required": ["write"] - } } } } @@ -347,144 +296,6 @@ } } }, - "Origin": { - "type": "object", - "additionalProperties": false, - "required": ["kind"], - "properties": { - "kind": { - "type": "string", - "enum": ["helm", "namespace"] - }, - "helm": { - "$ref": "#/$defs/HelmOrigin" - } - }, - "allOf": [ - { - "if": { - "properties": { - "kind": { - "const": "helm" - } - }, - "required": ["kind"] - }, - "then": { - "required": ["helm"] - } - }, - { - "if": { - "properties": { - "kind": { - "const": "namespace" - } - }, - "required": ["kind"] - }, - "then": { - "not": { - "required": ["helm"] - } - } - } - ] - }, - "HelmOrigin": { - "type": "object", - "additionalProperties": false, - "required": ["release", "namespace"], - "properties": { - "release": { - "type": "string" - }, - "namespace": { - "type": "string" - } - } - }, - "Apply": { - "type": "object", - "additionalProperties": false, - "properties": { - "write": { - "$ref": "#/$defs/WriteSemantics" - }, - "delete": { - "$ref": "#/$defs/DeleteSemantics" - } - } - }, - "WriteSemantics": { - "type": "object", - "additionalProperties": false, - "required": ["method", "forceConflicts"], - "properties": { - "method": { - "type": "string", - "enum": ["create", "server_side_apply"] - }, - "fieldManager": { - "type": "string" - }, - "forceConflicts": { - "type": "boolean" - } - }, - "allOf": [ - { - "if": { - "properties": { - "method": { - "const": "server_side_apply" - } - }, - "required": ["method"] - }, - "then": { - "required": ["fieldManager"], - "properties": { - "fieldManager": { - "type": "string", - "minLength": 1 - } - } - } - }, - { - "if": { - "properties": { - "method": { - "const": "create" - } - }, - "required": ["method"] - }, - "then": { - "properties": { - "forceConflicts": { - "const": false - } - }, - "not": { - "required": ["fieldManager"] - } - } - } - ] - }, - "DeleteSemantics": { - "type": "object", - "additionalProperties": false, - "required": ["propagation"], - "properties": { - "propagation": { - "type": "string", - "enum": ["background"] - } - } - }, "FieldChange": { "type": "object", "additionalProperties": false, @@ -548,27 +359,6 @@ } ] }, - "Diagnostic": { - "type": "object", - "additionalProperties": false, - "required": ["severity", "category", "message"], - "properties": { - "severity": { - "type": "string", - "enum": ["warning"] - }, - "category": { - "type": "string", - "enum": ["prediction_limitation"] - }, - "message": { - "type": "string" - }, - "resource": { - "$ref": "#/$defs/Resource" - } - } - }, "Summary": { "type": "object", "additionalProperties": false, @@ -770,6 +560,9 @@ }, "after": { "type": "object" + }, + "fields": { + "minItems": 1 } } } diff --git a/internal/plan/view/schema_test.go b/internal/plan/view/schema_test.go index 6bd59ad..a759365 100644 --- a/internal/plan/view/schema_test.go +++ b/internal/plan/view/schema_test.go @@ -53,16 +53,11 @@ func TestSchemaV1ID_MatchesEmbeddedAndRendered(t *testing.T) { require.True(t, ok) _, hasReplace := summaryProps["replace"] assert.False(t, hasReplace) - origin, ok := defs["Origin"].(map[string]any) + change, ok := defs["ResourceChange"].(map[string]any) require.True(t, ok) - originProps, ok := origin["properties"].(map[string]any) - require.True(t, ok) - kind, ok := originProps["kind"].(map[string]any) - require.True(t, ok) - assert.Equal(t, []any{"helm", "namespace"}, kind["enum"]) - assert.NotContains(t, kind["enum"], "crd") + assert.Equal(t, []any{"resource", "action", "before", "after", "fields"}, change["required"]) - doc := mustPlanDoc(t, mustPlan(t, semantic.HelmNone, nil, nil)) + doc := mustPlanDoc(t, mustPlan(t, semantic.HelmNone, nil)) assert.Equal(t, view.SchemaV1ID, doc["schema"]) } @@ -71,40 +66,21 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { res := ref("ConfigMap", "app") update := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Update, Before: snap(cm("app", "v1")), After: snap(cm("app", "v2")), - Apply: writeApply(), - }}, nil)) + }})) create := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Create, After: snap(cm("app", "v1")), - Apply: writeApply(), - }}, nil)) + }})) deleteDoc := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, - Origin: helmOrigin(), Action: semantic.Delete, Before: snap(cm("app", "v1")), - Apply: deleteApply(), - }}, nil)) - createWrite := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Create, - After: snap(cm("app", "v1")), - Apply: writeCreate(), - }}, nil)) - noneDoc := mustPlanDoc(t, mustPlan(t, semantic.HelmNone, nil, nil)) - ssaWrite := map[string]any{ - "method": "server_side_apply", - "fieldManager": "deployah", - "forceConflicts": false, - } - bgDelete := map[string]any{"propagation": "background"} + }})) + noneDoc := mustPlanDoc(t, mustPlan(t, semantic.HelmNone, nil)) addField := map[string]any{"path": "/data/extra", "op": "add", "after": "x"} tests := []struct { @@ -141,12 +117,6 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "field replace missing after", raw: patched(t, update, func(d map[string]any) { delete(firstField(t, d), "after") })}, - {name: "origin helm missing helm", raw: patched(t, update, func(d map[string]any) { - delete(asObject(t, firstChange(t, d)["origin"]), "helm") - })}, - {name: "empty fieldManager", raw: patched(t, update, func(d map[string]any) { - asObject(t, applyOf(t, d)["write"])["fieldManager"] = "" - })}, {name: "create with before object", raw: patched(t, create, func(d map[string]any) { firstChange(t, d)["before"] = map[string]any{"kind": "ConfigMap"} })}, @@ -156,20 +126,14 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "create with fields", raw: patched(t, create, func(d map[string]any) { firstChange(t, d)["fields"] = []any{addField} })}, - {name: "create missing write", raw: patched(t, create, func(d map[string]any) { - delete(applyOf(t, d), "write") - })}, - {name: "create with delete", raw: patched(t, create, func(d map[string]any) { - applyOf(t, d)["delete"] = bgDelete - })}, {name: "update with before null", raw: patched(t, update, func(d map[string]any) { firstChange(t, d)["before"] = nil })}, - {name: "update missing write", raw: patched(t, update, func(d map[string]any) { - delete(applyOf(t, d), "write") + {name: "update missing after", raw: patched(t, update, func(d map[string]any) { + firstChange(t, d)["after"] = nil })}, - {name: "update with delete", raw: patched(t, update, func(d map[string]any) { - applyOf(t, d)["delete"] = bgDelete + {name: "update with empty fields", raw: patched(t, update, func(d map[string]any) { + firstChange(t, d)["fields"] = []any{} })}, {name: "delete with after object", raw: patched(t, deleteDoc, func(d map[string]any) { firstChange(t, d)["after"] = map[string]any{"kind": "ConfigMap"} @@ -177,12 +141,6 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "delete with fields", raw: patched(t, deleteDoc, func(d map[string]any) { firstChange(t, d)["fields"] = []any{addField} })}, - {name: "delete with write", raw: patched(t, deleteDoc, func(d map[string]any) { - applyOf(t, d)["write"] = ssaWrite - })}, - {name: "delete missing delete", raw: patched(t, deleteDoc, func(d map[string]any) { - delete(applyOf(t, d), "delete") - })}, {name: "top-level completeness", raw: patched(t, update, func(d map[string]any) { d["completeness"] = "complete" })}, @@ -195,40 +153,6 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "unknown top-level field", raw: patched(t, update, func(d map[string]any) { d["unknown"] = true })}, - {name: "hook definition apply", raw: patched(t, mustPlanDoc(t, mustPlanWithTasks(t, semantic.HelmUpgrade, nil, []semantic.TaskPlan{{ - Name: "migrate", - Phase: semantic.TaskPreDeploy, - Action: semantic.TaskCreate, - WillRun: true, - Definitions: []semantic.HookDefinition{{ - Resource: ref("Job", "migrate"), - Action: semantic.Create, - After: snap(cm("migrate", "v1")), - }}, - }}, nil)), func(d map[string]any) { - tasks, ok := d["tasks"].([]any) - require.True(t, ok) - require.NotEmpty(t, tasks) - defs, ok := asObject(t, tasks[0])["definitions"].([]any) - require.True(t, ok) - require.NotEmpty(t, defs) - asObject(t, defs[0])["apply"] = map[string]any{"write": ssaWrite} - })}, - {name: "namespace origin with helm", raw: patched(t, update, func(d map[string]any) { - origin := asObject(t, firstChange(t, d)["origin"]) - origin["kind"] = "namespace" - })}, - {name: "create write with fieldManager", raw: patched(t, createWrite, func(d map[string]any) { - asObject(t, applyOf(t, d)["write"])["fieldManager"] = "deployah" - })}, - {name: "create write with forceConflicts", raw: patched(t, createWrite, func(d map[string]any) { - asObject(t, applyOf(t, d)["write"])["forceConflicts"] = true - })}, - {name: "update write method create", raw: patched(t, update, func(d map[string]any) { - write := asObject(t, applyOf(t, d)["write"]) - write["method"] = "create" - delete(write, "fieldManager") - })}, {name: "install without freshInstall", raw: patched(t, noneDoc, func(d map[string]any) { d["helmAction"] = "install" })}, @@ -238,12 +162,9 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "freshInstall with upgrade", raw: patched(t, update, func(d map[string]any) { asObject(t, d["header"])["freshInstall"] = true })}, - {name: "none with origin helm", raw: patched(t, update, func(d map[string]any) { + {name: "none with changes", raw: patched(t, update, func(d map[string]any) { d["helmAction"] = "none" })}, - {name: "origin kind crd", raw: patched(t, update, func(d map[string]any) { - asObject(t, firstChange(t, d)["origin"])["kind"] = "crd" - })}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -253,13 +174,39 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { } } -func TestSchemaV1_AcceptsHelmActionOrigins(t *testing.T) { +func TestSchemaV1_RejectsHookUpdateWithoutFields(t *testing.T) { + t.Parallel() + doc := mustPlanDoc(t, mustPlanWithTasks(t, semantic.HelmUpgrade, nil, []semantic.TaskPlan{{ + Name: "migrate", + Phase: semantic.TaskPreDeploy, + Action: semantic.TaskUpdate, + WillRun: true, + Definitions: []semantic.HookDefinition{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.Update, + Before: snap(cm("app", "v1")), + After: snap(cm("app", "v2")), + }}, + }})) + tasks, ok := doc["tasks"].([]any) + require.True(t, ok) + require.NotEmpty(t, tasks) + defs, ok := asObject(t, tasks[0])["definitions"].([]any) + require.True(t, ok) + require.NotEmpty(t, defs) + asObject(t, defs[0])["fields"] = []any{} + raw, err := json.Marshal(doc) + require.NoError(t, err) + assertSchemaRejects(t, raw) +} + +func TestSchemaV1_AcceptsNamespaceCreate(t *testing.T) { t.Parallel() tests := []struct { name string plan semantic.Plan }{ - {name: "install with origin namespace", plan: mustPlanWithHeader(t, semantic.Header{ + {name: "install namespace", plan: mustPlanWithHeader(t, semantic.Header{ Project: "web", Environment: "prod", Release: "web", @@ -267,15 +214,13 @@ func TestSchemaV1_AcceptsHelmActionOrigins(t *testing.T) { FreshInstall: true, }, semantic.HelmInstall, []semantic.ResourceChange{{ Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, Action: semantic.Create, After: snap(map[string]any{ "apiVersion": "v1", "kind": "Namespace", "metadata": map[string]any{"name": "prod"}, }), - Apply: writeApply(), - }}, nil, nil)}, + }}, nil)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -303,7 +248,7 @@ func TestSchemaV1_ChartCRDs(t *testing.T) { for _, tc := range valid { t.Run(tc.name, func(t *testing.T) { t.Parallel() - p := mustPlanWithHeader(t, tc.header, tc.action, nil, nil, nil) + p := mustPlanWithHeader(t, tc.header, tc.action, nil, nil) var attachErr error p, attachErr = semantic.AttachChartCRDs(p, []semantic.ChartCRD{{ Source: ".deployah/crds/widgets.yaml", @@ -319,7 +264,7 @@ func TestSchemaV1_ChartCRDs(t *testing.T) { }) } - p := mustPlan(t, semantic.HelmUpgrade, nil, nil) + p := mustPlan(t, semantic.HelmUpgrade, nil) var err error p, err = semantic.AttachChartCRDs(p, []semantic.ChartCRD{{ Source: ".deployah/crds/widgets.yaml", @@ -335,7 +280,6 @@ func TestSchemaV1_ChartCRDs(t *testing.T) { fn func(map[string]any) }{ {name: "unknown property", fn: func(d map[string]any) { firstChartCRD(t, d)["action"] = "create" }}, - {name: "origin field", fn: func(d map[string]any) { firstChartCRD(t, d)["origin"] = "helm" }}, {name: "lifecycle create", fn: func(d map[string]any) { firstChartCRD(t, d)["lifecycle"] = "create" }}, {name: "upgrade willProcess true", fn: func(d map[string]any) { firstChartCRD(t, d)["willProcess"] = true }}, {name: "process willProcess false", fn: func(d map[string]any) { @@ -401,11 +345,6 @@ func asObject(t *testing.T, v any) map[string]any { return m } -func applyOf(t *testing.T, doc map[string]any) map[string]any { - t.Helper() - return asObject(t, firstChange(t, doc)["apply"]) -} - func firstChange(t *testing.T, doc map[string]any) map[string]any { t.Helper() changes, ok := doc["changes"].([]any) diff --git a/internal/plan/view/testdata/golden/human_limitation.golden b/internal/plan/view/testdata/golden/human_limitation.golden deleted file mode 100644 index 3df5d22..0000000 --- a/internal/plan/view/testdata/golden/human_limitation.golden +++ /dev/null @@ -1,20 +0,0 @@ -Plan for project "web" on environment "prod" - -Context: production-eu -Namespace: prod -Release: web -Revision: 12 - -Resources - - ~ update v1/ConfigMap "web" - data: - - key: v1 - + key: v2 - -Diagnostics - - warning prediction_limitation v1/ConfigMap "web": prediction is not exact: managed-fields-migration - -Summary - Resources: 0 create, 1 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_noop_limitation.golden b/internal/plan/view/testdata/golden/human_noop_limitation.golden deleted file mode 100644 index 6cd6d46..0000000 --- a/internal/plan/view/testdata/golden/human_noop_limitation.golden +++ /dev/null @@ -1,13 +0,0 @@ -Plan for project "web" on environment "prod" - -Context: production-eu -Namespace: prod -Release: web -Revision: 12 - -Diagnostics - - warning prediction_limitation example.com/v1/Widget "app": prediction is not exact: prediction used the currently installed CRD, but that CRD's spec changes before Helm executes - -Summary - Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_prerequisites.golden b/internal/plan/view/testdata/golden/human_prerequisites.golden deleted file mode 100644 index ea93219..0000000 --- a/internal/plan/view/testdata/golden/human_prerequisites.golden +++ /dev/null @@ -1,33 +0,0 @@ -Plan for project "web" on environment "prod" - -Context: kind-dev -Namespace: prod -Release: web -Revision: 1 - -Resources - - + create v1/Namespace "prod" - + apiVersion: v1 - + kind: Namespace - + metadata: - + name: prod - + labels: - + name: prod - - + create example.com/v1/Widget "app" - + apiVersion: example.com/v1 - + kind: Widget - + metadata: - + name: app - + namespace: prod - + spec: - + color: blue - + size: large - -Diagnostics - - warning prediction_limitation example.com/v1/Widget "app": target namespace is created earlier in this deployment - -Summary - Resources: 2 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_semantic_plan.golden b/internal/plan/view/testdata/golden/human_semantic_plan.golden index fec8f22..60c7cea 100644 --- a/internal/plan/view/testdata/golden/human_semantic_plan.golden +++ b/internal/plan/view/testdata/golden/human_semantic_plan.golden @@ -25,8 +25,6 @@ Resources + deployah.dev/environment-instance: prod + deployah.dev/project: web + deployah.dev/source: spec - + meta.helm.sh/release-name: web-prod - + meta.helm.sh/release-namespace: prod + labels: + app.kubernetes.io/instance: web-prod + app.kubernetes.io/managed-by: Helm @@ -93,8 +91,6 @@ Resources - deployah.dev/environment-instance: prod - deployah.dev/project: web - deployah.dev/source: spec - - meta.helm.sh/release-name: web-prod - - meta.helm.sh/release-namespace: prod - labels: - app.kubernetes.io/instance: web-prod - app.kubernetes.io/managed-by: Helm diff --git a/internal/plan/view/testdata/golden/json_update.golden b/internal/plan/view/testdata/golden/json_update.golden index 55f8aa3..ef28383 100644 --- a/internal/plan/view/testdata/golden/json_update.golden +++ b/internal/plan/view/testdata/golden/json_update.golden @@ -15,13 +15,6 @@ "namespace": "prod", "name": "app" }, - "origin": { - "kind": "helm", - "helm": { - "release": "web", - "namespace": "prod" - } - }, "action": "update", "before": { "a": "1", @@ -44,19 +37,11 @@ "before": "1", "after": "2" } - ], - "apply": { - "write": { - "method": "server_side_apply", - "fieldManager": "deployah", - "forceConflicts": false - } - } + ] } ], "tasks": [], "chartCRDs": [], - "diagnostics": [], "summary": { "create": 0, "update": 1, diff --git a/internal/predict/cluster.go b/internal/predict/cluster.go deleted file mode 100644 index 39922e9..0000000 --- a/internal/predict/cluster.go +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "context" - "fmt" - - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - memcached "k8s.io/client-go/discovery/cached/memory" -) - -// Cluster is the Kubernetes surface [Predict] uses. The production -// implementation dry-runs every write. Apply is server-side apply with -// caller [ApplyOptions]. Create is a Kubernetes create and is unused -// by Helm [Predict]. Tests substitute a fake. -type Cluster interface { - Get(ctx context.Context, id Identity) (*unstructured.Unstructured, error) - Create(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) - Apply(ctx context.Context, obj *unstructured.Unstructured, opts ApplyOptions) (*unstructured.Unstructured, error) - JSONPatch(ctx context.Context, id Identity, patch []byte) error - Delete(ctx context.Context, id Identity) error - Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) -} - -// ApplyOptions configures a dry-run server-side apply. -type ApplyOptions struct { - FieldManager string - ForceConflicts bool -} - -type restCluster struct { - dyn dynamic.Interface - mapper meta.RESTMapper -} - -var _ Cluster = (*restCluster)(nil) - -// NewCluster builds the production [Cluster] for cfg. Construction matches -// the existing dynamic-client plus discovery REST-mapper stack. Create, -// Apply, JSONPatch, and Delete always send server dry-run. -func NewCluster(cfg *rest.Config) (Cluster, error) { - dyn, err := dynamic.NewForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("build dynamic client: %w", err) - } - disco, err := discovery.NewDiscoveryClientForConfig(cfg) - if err != nil { - return nil, fmt.Errorf("build discovery client: %w", err) - } - mapper := restmapper.NewDeferredDiscoveryRESTMapper(memcached.NewMemCacheClient(disco)) - return newRESTCluster(dyn, mapper), nil -} - -func newRESTCluster(dyn dynamic.Interface, mapper meta.RESTMapper) *restCluster { - return &restCluster{dyn: dyn, mapper: mapper} -} - -func (c *restCluster) Get(ctx context.Context, id Identity) (*unstructured.Unstructured, error) { - ri, err := c.resource(id.GroupVersionKind(), id.Namespace) - if err != nil { - return nil, err - } - return ri.Get(ctx, id.Name, metav1.GetOptions{}) -} - -func (c *restCluster) Create(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - ri, err := c.resource(obj.GroupVersionKind(), obj.GetNamespace()) - if err != nil { - return nil, err - } - predicted, err := ri.Create(ctx, obj, metav1.CreateOptions{ - DryRun: []string{metav1.DryRunAll}, - FieldValidation: metav1.FieldValidationStrict, - }) - if err != nil { - return nil, fmt.Errorf("create failed for object %s/%s %s: %w", - obj.GetNamespace(), obj.GetName(), obj.GroupVersionKind().String(), err) - } - return predicted, nil -} - -func (c *restCluster) Apply(ctx context.Context, obj *unstructured.Unstructured, opts ApplyOptions) (*unstructured.Unstructured, error) { - data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) - if err != nil { - return nil, fmt.Errorf("failed to encode object %s/%s %s: %w", - obj.GetNamespace(), obj.GetName(), obj.GroupVersionKind().String(), err) - } - ri, err := c.resource(obj.GroupVersionKind(), obj.GetNamespace()) - if err != nil { - return nil, err - } - predicted, err := ri.Patch(ctx, obj.GetName(), types.ApplyPatchType, data, applyPatchOptions(opts)) - if err != nil { - if apierrors.IsConflict(err) { - return nil, fmt.Errorf("conflict occurred while applying object %s/%s %s: %w", - obj.GetNamespace(), obj.GetName(), obj.GroupVersionKind().String(), err) - } - return nil, fmt.Errorf("server-side apply failed for object %s/%s %s: %w", - obj.GetNamespace(), obj.GetName(), obj.GroupVersionKind().String(), err) - } - return predicted, nil -} - -func (c *restCluster) JSONPatch(ctx context.Context, id Identity, patch []byte) error { - ri, err := c.resource(id.GroupVersionKind(), id.Namespace) - if err != nil { - return err - } - _, err = ri.Patch(ctx, id.Name, types.JSONPatchType, patch, jsonPatchOptions()) - return err -} - -func (c *restCluster) Delete(ctx context.Context, id Identity) error { - ri, err := c.resource(id.GroupVersionKind(), id.Namespace) - if err != nil { - return err - } - return ri.Delete(ctx, id.Name, deleteOptions()) -} - -func (c *restCluster) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { - return c.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) -} - -func (c *restCluster) resource(gvk schema.GroupVersionKind, namespace string) (dynamic.ResourceInterface, error) { - mapping, err := c.Mapping(gvk) - if err != nil { - return nil, err - } - nri := c.dyn.Resource(mapping.Resource) - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - return nri.Namespace(namespace), nil - } - return nri, nil -} - -func applyPatchOptions(opts ApplyOptions) metav1.PatchOptions { - return metav1.PatchOptions{ - DryRun: []string{metav1.DryRunAll}, - FieldManager: opts.FieldManager, - Force: new(opts.ForceConflicts), - FieldValidation: metav1.FieldValidationStrict, - } -} - -func jsonPatchOptions() metav1.PatchOptions { - return metav1.PatchOptions{ - DryRun: []string{metav1.DryRunAll}, - FieldManager: kube.ManagedFieldsManager, - FieldValidation: metav1.FieldValidationStrict, - } -} - -func deleteOptions() metav1.DeleteOptions { - return metav1.DeleteOptions{ - DryRun: []string{metav1.DryRunAll}, - PropagationPolicy: new(metav1.DeletePropagationBackground), - } -} diff --git a/internal/predict/cluster_test.go b/internal/predict/cluster_test.go deleted file mode 100644 index 36a0055..0000000 --- a/internal/predict/cluster_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "encoding/json" - "slices" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/api/meta/testrestmapper" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - dynamicfake "k8s.io/client-go/dynamic/fake" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - clienttesting "k8s.io/client-go/testing" -) - -func interceptApplyJSON(client *dynamicfake.FakeDynamicClient) { - client.PrependReactor("patch", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) { - patchAction, ok := action.(clienttesting.PatchActionImpl) - if !ok { - return false, nil, nil - } - if patchAction.GetPatchType() != types.ApplyPatchType { - return false, nil, nil - } - obj := &unstructured.Unstructured{Object: map[string]any{}} - if err := json.Unmarshal(patchAction.GetPatch(), &obj.Object); err != nil { - return true, nil, err - } - obj.SetName(patchAction.GetName()) - obj.SetNamespace(patchAction.GetNamespace()) - return true, obj, nil - }) -} - -func testRESTCluster(t *testing.T) (*restCluster, *dynamicfake.FakeDynamicClient) { - t.Helper() - fakeClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) - interceptApplyJSON(fakeClient) - mapper := testrestmapper.TestOnlyStaticRESTMapper(clientgoscheme.Scheme) - return newRESTCluster(fakeClient, mapper), fakeClient -} - -func testConfigMap(name, namespace, value string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - }, - "data": map[string]any{"key": value}, - }} -} - -func TestRESTCluster_ApplyRequestShape(t *testing.T) { - t.Parallel() - cluster, fakeClient := testRESTCluster(t) - obj := testConfigMap("app", "prod", "next") - - got, err := cluster.Apply(t.Context(), obj, ApplyOptions{ - FieldManager: kube.ManagedFieldsManager, - ForceConflicts: false, - }) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "app", got.GetName()) - assert.Equal(t, "prod", got.GetNamespace()) - - patch := lastPatch(t, fakeClient) - assert.Equal(t, types.ApplyPatchType, patch.GetPatchType()) - assert.Equal(t, "app", patch.GetName()) - assert.Equal(t, "prod", patch.GetNamespace()) - require.True(t, json.Valid(patch.GetPatch()), "apply body must be JSON") - - opts := patch.GetPatchOptions() - assert.Equal(t, []string{metav1.DryRunAll}, opts.DryRun) - assert.Equal(t, kube.ManagedFieldsManager, opts.FieldManager) - require.NotNil(t, opts.Force) - assert.False(t, *opts.Force) - assert.Equal(t, metav1.FieldValidationStrict, opts.FieldValidation) -} - -func TestRESTCluster_ApplyCallerForceConflicts(t *testing.T) { - t.Parallel() - cluster, fakeClient := testRESTCluster(t) - obj := testConfigMap("app", "prod", "next") - - _, err := cluster.Apply(t.Context(), obj, ApplyOptions{ - FieldManager: "other-manager", - ForceConflicts: true, - }) - require.NoError(t, err) - opts := lastPatch(t, fakeClient).GetPatchOptions() - assert.Equal(t, "other-manager", opts.FieldManager) - require.NotNil(t, opts.Force) - assert.True(t, *opts.Force) - assert.Equal(t, []string{metav1.DryRunAll}, opts.DryRun) - assert.Equal(t, metav1.FieldValidationStrict, opts.FieldValidation) -} - -func TestRESTCluster_CreateRequestShape(t *testing.T) { - t.Parallel() - cluster, fakeClient := testRESTCluster(t) - obj := testConfigMap("app", "prod", "next") - - got, err := cluster.Create(t.Context(), obj) - require.NoError(t, err) - require.NotNil(t, got) - assert.Equal(t, "app", got.GetName()) - assert.Equal(t, "prod", got.GetNamespace()) - - create := lastCreate(t, fakeClient) - obj, ok := create.GetObject().(*unstructured.Unstructured) - require.True(t, ok) - assert.Equal(t, "app", obj.GetName()) - assert.Equal(t, "prod", obj.GetNamespace()) - assert.Equal(t, "prod", create.GetNamespace()) - opts := create.GetCreateOptions() - assert.Equal(t, []string{metav1.DryRunAll}, opts.DryRun) - assert.Equal(t, metav1.FieldValidationStrict, opts.FieldValidation) - assert.Empty(t, opts.FieldManager) -} - -func TestRESTCluster_JSONPatchRequestShape(t *testing.T) { - t.Parallel() - cluster, fakeClient := testRESTCluster(t) - id := Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"} - patchBody := []byte(`[{"op":"replace","path":"/metadata/managedFields","value":[]}]`) - require.NoError(t, fakeClient.Tracker().Add(testConfigMap("app", "prod", "live"))) - - require.NoError(t, cluster.JSONPatch(t.Context(), id, patchBody)) - - patch := lastPatch(t, fakeClient) - assert.Equal(t, types.JSONPatchType, patch.GetPatchType()) - assert.Equal(t, patchBody, []byte(patch.GetPatch())) - opts := patch.GetPatchOptions() - assert.Equal(t, []string{metav1.DryRunAll}, opts.DryRun) - assert.Equal(t, kube.ManagedFieldsManager, opts.FieldManager) - assert.Nil(t, opts.Force) - assert.Equal(t, metav1.FieldValidationStrict, opts.FieldValidation) -} - -func TestRESTCluster_DeleteRequestShape(t *testing.T) { - t.Parallel() - cluster, fakeClient := testRESTCluster(t) - id := Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"} - - // Seed so the fake tracker can delete. - require.NoError(t, fakeClient.Tracker().Add(testConfigMap("app", "prod", "live"))) - require.NoError(t, cluster.Delete(t.Context(), id)) - - var del clienttesting.DeleteActionImpl - found := false - for _, action := range fakeClient.Actions() { - if d, ok := action.(clienttesting.DeleteActionImpl); ok { - del = d - found = true - } - } - require.True(t, found, "expected a delete action") - assert.Equal(t, []string{metav1.DryRunAll}, del.DeleteOptions.DryRun) - require.NotNil(t, del.DeleteOptions.PropagationPolicy) - assert.Equal(t, metav1.DeletePropagationBackground, *del.DeleteOptions.PropagationPolicy) - assert.Nil(t, del.DeleteOptions.Preconditions) - assert.Nil(t, del.DeleteOptions.GracePeriodSeconds) -} - -func lastPatch(t *testing.T, fakeClient *dynamicfake.FakeDynamicClient) clienttesting.PatchActionImpl { - t.Helper() - for _, action := range slices.Backward(fakeClient.Actions()) { - if p, ok := action.(clienttesting.PatchActionImpl); ok { - return p - } - } - t.Fatal("expected a patch action") - return clienttesting.PatchActionImpl{} -} - -func lastCreate(t *testing.T, fakeClient *dynamicfake.FakeDynamicClient) clienttesting.CreateActionImpl { - t.Helper() - for _, action := range slices.Backward(fakeClient.Actions()) { - if c, ok := action.(clienttesting.CreateActionImpl); ok { - return c - } - } - t.Fatal("expected a create action") - return clienttesting.CreateActionImpl{} -} diff --git a/internal/predict/delete.go b/internal/predict/delete.go deleted file mode 100644 index e8d8e33..0000000 --- a/internal/predict/delete.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "context" - "fmt" - - "helm.sh/helm/v4/pkg/kube" - "k8s.io/client-go/util/retry" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -func predictPrune(ctx context.Context, cluster Cluster, previous resourceObj) (Result, error) { - live, err := cluster.Get(ctx, previous.id) - if apierrors.IsNotFound(err) { - return Result{Identity: previous.id, Action: ActionNoOp}, nil - } - if err != nil { - // Prediction-safety: Helm may log-and-continue on this GET. - // Predict refuses to claim a resulting state. - return Result{}, fmt.Errorf("could not get information about the resource %s: %w", - resourceString(previous.obj), err) - } - if live.GetAnnotations()[kube.ResourcePolicyAnno] == kube.KeepPolicy { - return Result{ - Identity: previous.id, - Action: ActionNoOp, - Live: live, - Predicted: live.DeepCopy(), - }, nil - } - delErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { - return cluster.Delete(ctx, previous.id) - }) - if delErr != nil && !apierrors.IsNotFound(delErr) { - return Result{}, fmt.Errorf( - "failed to delete resource namespace=%s, name=%s, kind=%s: %w", - previous.id.Namespace, previous.id.Name, previous.id.Kind, delErr) - } - return Result{ - Identity: previous.id, - Action: ActionDelete, - Live: live, - }, nil -} diff --git a/internal/predict/delete_test.go b/internal/predict/delete_test.go deleted file mode 100644 index ada5f8f..0000000 --- a/internal/predict/delete_test.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -func TestPredict_KeepPolicy(t *testing.T) { - t.Parallel() - - t.Run("keep on live snapshots predicted", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("old", "prod", "web") - ann := live.GetAnnotations() - ann[kube.ResourcePolicyAnno] = kube.KeepPolicy - live.SetAnnotations(ann) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionNoOp, got.Action) - require.NotNil(t, got.Live) - require.NotNil(t, got.Predicted) - assert.NotSame(t, got.Live, got.Predicted) - assert.Equal(t, got.Live.GetAnnotations()[kube.ResourcePolicyAnno], kube.KeepPolicy) - assert.Equal(t, got.Predicted.GetAnnotations()[kube.ResourcePolicyAnno], kube.KeepPolicy) - assert.Empty(t, cluster.deletes) - }) - - t.Run("keep only on previous does not skip", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - previous := configMapYAMLWith("old", "prod", ` annotations: - helm.sh/resource-policy: keep -`, "prev") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: previous, - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, got.Action) - require.Len(t, cluster.deletes, 1) - }) -} - -func TestPredict_PruneGetErrorFailsClosed(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - id := predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "old"} - cluster.getErr[objectKey(id)] = apierrors.NewForbidden( - schema.GroupResource{Resource: "configmaps"}, "old", errors.New("denied")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "could not get information about the resource") - assert.True(t, apierrors.IsForbidden(err), "Predict() prune GET = %v, want forbidden", err) - assert.Empty(t, cluster.deletes) -} - -func TestPredict_PruneDelete(t *testing.T) { - t.Parallel() - - t.Run("retries conflict then succeeds", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - cluster.deleteConflict = 1 - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, got.Action) - assert.GreaterOrEqual(t, len(cluster.deletes), 2) - }) - - t.Run("delete not found after get is not an error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - cluster.deleteErr = apierrors.NewNotFound( - schema.GroupResource{Resource: "configmaps"}, "old") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, got.Action) - assert.NotNil(t, got.Live) - assert.Nil(t, got.Predicted) - require.NotEmpty(t, cluster.deletes) - }) - - t.Run("non-not-found delete failure is an error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - cluster.deleteErr = apierrors.NewForbidden( - schema.GroupResource{Resource: "configmaps"}, "old", errors.New("denied")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsForbidden(err), "Predict() delete = %v, want forbidden", err) - }) -} - -func TestPredict_DeleteDryRunFailure(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - cluster.deleteErr = apierrors.NewForbidden( - schema.GroupResource{Resource: "configmaps"}, "old", errors.New("denied")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsForbidden(err), "Predict() delete = %v, want forbidden", err) -} diff --git a/internal/predict/doc.go b/internal/predict/doc.go deleted file mode 100644 index a5ba249..0000000 --- a/internal/predict/doc.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package predict answers what Kubernetes resource state Deployah's Helm -// server-side apply (SSA) path would produce, without mutating the cluster. -// -// [Predict] is a library. It is not the deployah plan command and is not -// wired into plan or deploy. -// -// Helm [Predict] stays on server-side apply. There is no client-side -// apply predictor and no apply-method switch for Helm resources. -// [Cluster] is a generic dry-run write surface: Apply is SSA with -// caller FieldManager and ForceConflicts, and Create is a Kubernetes -// create. All production writes use server dry-run. Predict does not -// call Create. -// -// Exact Helm-resource prediction requires that the cluster API surface -// already exists at prediction time: -// -// - the target release namespace already exists for namespaced resources -// - every Desired and Previous resource group/version/kind is currently -// discoverable and REST-mappable -// -// A missing namespace ([apierrors.IsNotFound]) or an unmapped kind -// ([meta.IsNoMatchError]) is returned as the native typed error. Those -// errors are not rewritten to [ActionNoOp]. They do not necessarily prove -// that a real Deployah deploy would fail: install sets CreateNamespace, and -// Deployah applies .deployah/crds/ before Helm. Namespace and CRD -// prerequisite prediction live in the plan package, which wraps [Cluster] -// for same-deploy fallback. This package stays Helm-strict. -package predict diff --git a/internal/predict/fake_test.go b/internal/predict/fake_test.go deleted file mode 100644 index 69d350f..0000000 --- a/internal/predict/fake_test.go +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "context" - "fmt" - "strings" - - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type recordedApply struct { - Name string - Namespace string - Obj *unstructured.Unstructured - Opts predict.ApplyOptions -} - -type recordedJSONPatch struct { - ID predict.Identity - Patch []byte -} - -type recordedDelete struct { - ID predict.Identity -} - -type fakeCluster struct { - objects map[string]*unstructured.Unstructured - getErr map[string]error - mappingErr map[schema.GroupVersionKind]error - clusterScoped map[schema.GroupVersionKind]bool - applyErr error - applySeq []error - applyCalls int - createCalls int - lastCreate *unstructured.Unstructured - jsonPatchErr error - jsonPatchCalls int - jsonPatchConflict int - deleteErr error - deleteConflict int - gets []predict.Identity - applies []recordedApply - jsonPatches []recordedJSONPatch - deletes []recordedDelete -} - -func newFakeCluster() *fakeCluster { - return &fakeCluster{ - objects: make(map[string]*unstructured.Unstructured), - getErr: make(map[string]error), - mappingErr: make(map[schema.GroupVersionKind]error), - clusterScoped: make(map[schema.GroupVersionKind]bool), - } -} - -func objectKey(id predict.Identity) string { - gv := schema.GroupVersion{Group: id.Group, Version: id.Version} - return fmt.Sprintf("%s/%s/%s/%s", gv.String(), id.Kind, id.Namespace, id.Name) -} - -func identityOf(obj *unstructured.Unstructured) predict.Identity { - gvk := obj.GroupVersionKind() - return predict.Identity{ - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } -} - -func (f *fakeCluster) store(obj *unstructured.Unstructured) { - f.objects[objectKey(identityOf(obj))] = obj.DeepCopy() -} - -func (f *fakeCluster) Get(_ context.Context, id predict.Identity) (*unstructured.Unstructured, error) { - f.gets = append(f.gets, id) - if err, ok := f.getErr[objectKey(id)]; ok { - return nil, err - } - obj, ok := f.objects[objectKey(id)] - if !ok { - return nil, apierrors.NewNotFound(schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, id.Name) - } - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) Create(_ context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - f.createCalls++ - f.lastCreate = obj.DeepCopy() - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) Apply(_ context.Context, obj *unstructured.Unstructured, opts predict.ApplyOptions) (*unstructured.Unstructured, error) { - f.applyCalls++ - f.applies = append(f.applies, recordedApply{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - Obj: obj.DeepCopy(), - Opts: opts, - }) - if len(f.applySeq) > 0 { - err := f.applySeq[0] - f.applySeq = f.applySeq[1:] - if err != nil { - return nil, err - } - } else if f.applyErr != nil { - return nil, f.applyErr - } - return obj.DeepCopy(), nil -} - -func (f *fakeCluster) JSONPatch(_ context.Context, id predict.Identity, patch []byte) error { - f.jsonPatchCalls++ - f.jsonPatches = append(f.jsonPatches, recordedJSONPatch{ID: id, Patch: append([]byte(nil), patch...)}) - if f.jsonPatchConflict > 0 { - f.jsonPatchConflict-- - return apierrors.NewConflict( - schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, - id.Name, - fmt.Errorf("the object has been modified"), - ) - } - return f.jsonPatchErr -} - -func (f *fakeCluster) Delete(_ context.Context, id predict.Identity) error { - f.deletes = append(f.deletes, recordedDelete{ID: id}) - if f.deleteConflict > 0 { - f.deleteConflict-- - return apierrors.NewConflict( - schema.GroupResource{Resource: strings.ToLower(id.Kind) + "s"}, - id.Name, - fmt.Errorf("the object has been modified"), - ) - } - return f.deleteErr -} - -func (f *fakeCluster) Mapping(gvk schema.GroupVersionKind) (*meta.RESTMapping, error) { - if err, ok := f.mappingErr[gvk]; ok { - return nil, err - } - scope := meta.RESTScopeNamespace - if f.clusterScoped[gvk] { - scope = meta.RESTScopeRoot - } - return &meta.RESTMapping{ - Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"}, - GroupVersionKind: gvk, - Scope: scope, - }, nil -} - -func configMapYAML(name, namespace, data string) string { - nsLine := "" - if namespace != "" { - nsLine = "\n namespace: " + namespace - } - return fmt.Sprintf(`apiVersion: v1 -kind: ConfigMap -metadata: - name: %s%s -data: - key: %s -`, name, nsLine, data) -} - -func configMapYAMLWith(name, namespace, extraMeta, data string) string { - nsLine := "" - if namespace != "" { - nsLine = "\n namespace: " + namespace - } - return fmt.Sprintf(`apiVersion: v1 -kind: ConfigMap -metadata: - name: %s%s -%s -data: - key: %s -`, name, nsLine, extraMeta, data) -} - -func ownedConfigMap(name, namespace, release string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - "labels": map[string]any{ - "app.kubernetes.io/managed-by": "Helm", - }, - "annotations": map[string]any{ - "meta.helm.sh/release-name": release, - "meta.helm.sh/release-namespace": namespace, - }, - }, - "data": map[string]any{"key": "live"}, - }} -} - -func clusterWidgetGVK() schema.GroupVersionKind { - return schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "ClusterWidget"} -} - -func clusterWidgetYAML(name, namespace string) string { - nsLine := "" - if namespace != "" { - nsLine = "\n namespace: " + namespace - } - return fmt.Sprintf(`apiVersion: example.com/v1 -kind: ClusterWidget -metadata: - name: %s%s -`, name, nsLine) -} - -func clusterWidget(name, namespace string) *unstructured.Unstructured { - meta := map[string]any{"name": name} - if namespace != "" { - meta["namespace"] = namespace - } - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "example.com/v1", - "kind": "ClusterWidget", - "metadata": meta, - }} -} - -func foreignConfigMap(name, namespace string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - }, - "data": map[string]any{"key": "foreign"}, - }} -} - -func findResult(results []predict.Result, name string) (predict.Result, bool) { - for _, r := range results { - if r.Identity.Name == name { - return r, true - } - } - return predict.Result{}, false -} - -func resourceQuotaConflict() error { - return apierrors.NewConflict( - schema.GroupResource{Resource: "resourcequotas"}, - "default", - fmt.Errorf("Operation cannot be fulfilled on resourcequotas \"default\": the object has been modified"), - ) -} - -func fieldManagerConflict() error { - return apierrors.NewConflict( - schema.GroupResource{Resource: "configmaps"}, - "app", - fmt.Errorf("conflict with \"other-manager\""), - ) -} - -func deployahUpdateManagedFields() []metav1.ManagedFieldsEntry { - return []metav1.ManagedFieldsEntry{{ - Manager: "deployah", - Operation: metav1.ManagedFieldsOperationUpdate, - APIVersion: "v1", - FieldsType: "FieldsV1", - FieldsV1: metav1.NewFieldsV1(`{"f:data":{"f:key":{}}}`), - }} -} diff --git a/internal/predict/identity.go b/internal/predict/identity.go deleted file mode 100644 index 369f51b..0000000 --- a/internal/predict/identity.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "bytes" - "errors" - "fmt" - "strings" - - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/cli-runtime/pkg/resource" -) - -type resourceObj struct { - id Identity - obj *unstructured.Unstructured -} - -// GroupVersionKind returns the API group, version, and kind of id. -func (id Identity) GroupVersionKind() schema.GroupVersionKind { - return schema.GroupVersionKind{Group: id.Group, Version: id.Version, Kind: id.Kind} -} - -func identityOf(obj *unstructured.Unstructured) Identity { - gvk := obj.GroupVersionKind() - return Identity{ - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } -} - -// objectKey matches Helm pkg/action/upgrade.go: groupVersion/kind/ns/name. -func objectKey(id Identity) string { - gv := schema.GroupVersion{Group: id.Group, Version: id.Version} - return fmt.Sprintf("%s/%s/%s/%s", gv.String(), id.Kind, id.Namespace, id.Name) -} - -// sameResource matches Helm isMatchingInfo: name, namespace, GroupKind. -func sameResource(a, b Identity) bool { - return a.Name == b.Name && a.Namespace == b.Namespace && a.Group == b.Group && a.Kind == b.Kind -} - -// FlattenManifest parses a multi-document Kubernetes YAML manifest into -// unstructured objects. List items are flattened the same way [Predict] -// loads Desired and Previous. -func FlattenManifest(manifest string) ([]*unstructured.Unstructured, error) { - if strings.TrimSpace(manifest) == "" { - return nil, nil - } - infos, err := resource.NewLocalBuilder(). - ContinueOnError(). - Flatten(). - Unstructured(). - Stream(bytes.NewBufferString(manifest), "manifest"). - Do().Infos() - if err != nil { - return nil, fmt.Errorf("parse manifest: %w", err) - } - out := make([]*unstructured.Unstructured, 0, len(infos)) - for _, info := range infos { - if u, ok := info.Object.(*unstructured.Unstructured); ok { - out = append(out, u.DeepCopy()) - continue - } - m, convErr := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) - if convErr != nil { - return nil, fmt.Errorf("convert object to unstructured: %w", convErr) - } - out = append(out, &unstructured.Unstructured{Object: m}) - } - return out, nil -} - -func loadResources(cluster Cluster, manifest, defaultNamespace string) ([]resourceObj, error) { - objs, err := FlattenManifest(manifest) - if err != nil { - return nil, err - } - out := make([]resourceObj, 0, len(objs)) - for _, obj := range objs { - if _, genErr := generateNameState(obj); genErr != nil { - return nil, genErr - } - mapping, mapErr := cluster.Mapping(obj.GroupVersionKind()) - if mapErr != nil { - return nil, fmt.Errorf("resolve resource mapping for %s %s: %w", - obj.GroupVersionKind(), obj.GetName(), mapErr) - } - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - if obj.GetNamespace() == "" { - obj.SetNamespace(defaultNamespace) - } - } else { - obj.SetNamespace("") - } - out = append(out, resourceObj{id: identityOf(obj), obj: obj}) - } - return out, nil -} - -// generateNameState mirrors Helm validateNameAndGenerateName. A generateName -// without a name skips ownership. Name and generateName together is an error. -func generateNameState(obj *unstructured.Unstructured) (bool, error) { - name := obj.GetName() - generateName := obj.GetGenerateName() - if name == "" && generateName != "" { - return true, nil - } - if name != "" && generateName != "" { - return false, errors.New("metadata.name and metadata.generateName cannot both be set") - } - return false, nil -} diff --git a/internal/predict/identity_test.go b/internal/predict/identity_test.go deleted file mode 100644 index 1c441ca..0000000 --- a/internal/predict/identity_test.go +++ /dev/null @@ -1,444 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - apimeta "k8s.io/apimachinery/pkg/api/meta" -) - -func TestPredict_ObjectKeyIncludesVersion(t *testing.T) { - t.Parallel() - // Same name/namespace/kind, different API version is new-to-release. - cluster := newFakeCluster() - live := foreignConfigMap("app", "prod") - live.SetAPIVersion("example.com/v2") - cluster.store(live) - previous := `apiVersion: example.com/v1 -kind: ConfigMap -metadata: - name: app - namespace: prod -data: - key: prev -` - desired := `apiVersion: example.com/v2 -kind: ConfigMap -metadata: - name: app - namespace: prod -data: - key: next -` - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: previous, - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") -} - -func TestPredict_GroupKindPruneIgnoresVersion(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - previous := `apiVersion: example.com/v1 -kind: Widget -metadata: - name: app - namespace: prod -` - desired := `apiVersion: example.com/v2 -kind: Widget -metadata: - name: app - namespace: prod -` - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: previous, - Desired: desired, - }) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, predict.ActionCreate, results[0].Action) - assert.Equal(t, "v2", results[0].Identity.Version) - assert.Empty(t, cluster.deletes) -} - -func TestPredict_GenerateName(t *testing.T) { - t.Parallel() - - t.Run("skips ownership get", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := `apiVersion: v1 -kind: ConfigMap -metadata: - generateName: app- - namespace: prod -data: - key: next -` - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, predict.ActionCreate, results[0].Action) - assert.Empty(t, cluster.gets) - require.Len(t, cluster.applies, 1) - assert.Equal(t, "app-", cluster.applies[0].Obj.GetGenerateName()) - }) - - t.Run("name and generateName error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := `apiVersion: v1 -kind: ConfigMap -metadata: - name: app - generateName: app- - namespace: prod -data: - key: next -` - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "metadata.name and metadata.generateName cannot both be set") - }) -} - -func TestPredict_Flatten(t *testing.T) { - t.Parallel() - - t.Run("multi-document yaml", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := configMapYAML("one", "prod", "a") + "---\n" + configMapYAML("two", "prod", "b") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - require.Len(t, results, 2) - _, ok := findResult(results, "one") - assert.True(t, ok) - _, ok = findResult(results, "two") - assert.True(t, ok) - }) - - t.Run("list expands to items", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := `apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: ConfigMap - metadata: - name: one - namespace: prod - data: - key: a -- apiVersion: v1 - kind: ConfigMap - metadata: - name: two - namespace: prod - data: - key: b -` - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - require.Len(t, results, 2) - for _, r := range results { - assert.NotEqual(t, "List", r.Identity.Kind) - assert.Equal(t, predict.ActionCreate, r.Action) - } - }) - - t.Run("list items own independently", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(foreignConfigMap("two", "prod")) - desired := `apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: ConfigMap - metadata: - name: one - namespace: prod - data: - key: a -- apiVersion: v1 - kind: ConfigMap - metadata: - name: two - namespace: prod - data: - key: b -` - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") - }) - - t.Run("list items prune independently", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - live := ownedConfigMap("two", "prod", "web") - cluster.store(live) - previous := `apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: ConfigMap - metadata: - name: one - namespace: prod - data: - key: a -- apiVersion: v1 - kind: ConfigMap - metadata: - name: two - namespace: prod - data: - key: b -` - desired := configMapYAML("one", "prod", "a") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: previous, - Desired: desired, - }) - require.NoError(t, err) - pruned, ok := findResult(results, "two") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, pruned.Action) - require.Len(t, cluster.deletes, 1) - assert.Equal(t, "two", cluster.deletes[0].ID.Name) - }) - - t.Run("mapping error on flattened item", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - gvk := schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"} - cluster.mappingErr[gvk] = &apimeta.NoKindMatchError{GroupKind: gvk.GroupKind()} - desired := `apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: ConfigMap - metadata: - name: one - namespace: prod -- apiVersion: example.com/v1 - kind: Widget - metadata: - name: two - namespace: prod -` - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.True(t, apimeta.IsNoMatchError(err), "Predict() mapping error = %v, want IsNoMatchError", err) - }) - - t.Run("defaults empty namespace", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := configMapYAML("app", "", "next") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, "prod", got.Identity.Namespace) - require.Len(t, cluster.applies, 1) - assert.Equal(t, "prod", cluster.applies[0].Namespace) - }) -} - -func TestPredict_ClusterScopedClearsNamespace(t *testing.T) { - t.Parallel() - gvk := clusterWidgetGVK() - - t.Run("desired clears supplied namespace", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.clusterScoped[gvk] = true - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: clusterWidgetYAML("app", "prod"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Empty(t, got.Identity.Namespace) - require.Len(t, cluster.applies, 1) - assert.Empty(t, cluster.applies[0].Namespace) - assert.Empty(t, cluster.applies[0].Obj.GetNamespace()) - }) - - t.Run("previous matching uses cleared namespace", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.clusterScoped[gvk] = true - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: clusterWidgetYAML("app", "prod"), - Desired: clusterWidgetYAML("app", "staging"), - }) - require.NoError(t, err) - require.Len(t, results, 1) - assert.Equal(t, predict.ActionCreate, results[0].Action) - assert.Empty(t, results[0].Identity.Namespace) - assert.Empty(t, cluster.deletes) - require.Len(t, cluster.applies, 1) - assert.Empty(t, cluster.applies[0].Namespace) - }) - - t.Run("previous prune uses cleared namespace", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.clusterScoped[gvk] = true - cluster.store(clusterWidget("old", "")) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: clusterWidgetYAML("old", "prod"), - Desired: clusterWidgetYAML("app", "prod"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, got.Action) - assert.Empty(t, got.Identity.Namespace) - require.Len(t, cluster.deletes, 1) - assert.Empty(t, cluster.deletes[0].ID.Namespace) - }) -} - -func TestPredict_PrerequisiteErrors(t *testing.T) { - t.Parallel() - - t.Run("missing namespace not found", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.applyErr = apierrors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, "missing") - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "missing", - Desired: configMapYAML("app", "missing", "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsNotFound(err), "Predict() = %v, want IsNotFound", err) - }) - - t.Run("no match mapping", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - gvk := schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"} - cluster.mappingErr[gvk] = &apimeta.NoKindMatchError{GroupKind: gvk.GroupKind()} - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: `apiVersion: example.com/v1 -kind: Widget -metadata: - name: app - namespace: prod -`, - }) - require.Error(t, err) - assert.True(t, apimeta.IsNoMatchError(err), "Predict() = %v, want IsNoMatchError", err) - }) -} - -func TestFlattenManifest_ListItems(t *testing.T) { - t.Parallel() - objs, err := predict.FlattenManifest(`apiVersion: v1 -kind: List -items: -- apiVersion: v1 - kind: Namespace - metadata: - name: prod -- apiVersion: v1 - kind: ConfigMap - metadata: - name: app - namespace: prod -`) - require.NoError(t, err) - require.Len(t, objs, 2) - assert.Equal(t, "Namespace", objs[0].GetKind()) - assert.Equal(t, "prod", objs[0].GetName()) - assert.Equal(t, "ConfigMap", objs[1].GetKind()) - assert.Equal(t, "app", objs[1].GetName()) -} diff --git a/internal/predict/normalize.go b/internal/predict/normalize.go deleted file mode 100644 index 0dcfda3..0000000 --- a/internal/predict/normalize.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -// EqualPredictedState reports whether live and predicted represent the -// same Kubernetes object after predictor bookkeeping fields are -// stripped. Helm [Predict] uses this same comparison to emit -// [ActionNoOp]. -func EqualPredictedState(live, predicted *unstructured.Unstructured) bool { - if live == nil || predicted == nil { - return false - } - return equality.Semantic.DeepEqual(bookkeepingCopy(live).Object, bookkeepingCopy(predicted).Object) -} - -func bookkeepingCopy(obj *unstructured.Unstructured) *unstructured.Unstructured { - cp := obj.DeepCopy() - unstructured.RemoveNestedField(cp.Object, "status") - unstructured.RemoveNestedField(cp.Object, "metadata", "resourceVersion") - unstructured.RemoveNestedField(cp.Object, "metadata", "uid") - unstructured.RemoveNestedField(cp.Object, "metadata", "generation") - unstructured.RemoveNestedField(cp.Object, "metadata", "creationTimestamp") - unstructured.RemoveNestedField(cp.Object, "metadata", "managedFields") - return cp -} diff --git a/internal/predict/normalize_test.go b/internal/predict/normalize_test.go deleted file mode 100644 index 7092762..0000000 --- a/internal/predict/normalize_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2026 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "deployah.dev/deployah/internal/predict" -) - -func TestEqualPredictedState(t *testing.T) { - t.Parallel() - live := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]any{ - "name": "prod", - "uid": "live-uid", - "resourceVersion": "12", - "generation": int64(3), - "creationTimestamp": "2020-01-01T00:00:00Z", - "managedFields": []any{map[string]any{"manager": "kube-apiserver"}}, - "labels": map[string]any{"name": "prod"}, - }, - "status": map[string]any{"phase": "Active"}, - }} - predicted := live.DeepCopy() - predictedMeta := requireNestedMap(t, predicted.Object, "metadata") - predictedMeta["uid"] = "pred-uid" - predictedMeta["resourceVersion"] = "99" - predictedMeta["generation"] = int64(9) - predictedMeta["creationTimestamp"] = "2024-01-01T00:00:00Z" - predictedMeta["managedFields"] = []any{map[string]any{"manager": "deployah"}} - predicted.Object["status"] = map[string]any{"phase": "Terminating"} - changed := predicted.DeepCopy() - requireNestedMap(t, changed.Object, "metadata")["labels"] = map[string]any{"name": "other"} - - tests := []struct { - name string - a, b *unstructured.Unstructured - want bool - }{ - {name: "ignores bookkeeping", a: live, b: predicted, want: true}, - {name: "label change", a: live, b: changed, want: false}, - {name: "nil predicted", a: live, want: false}, - {name: "nil live", b: predicted, want: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, predict.EqualPredictedState(tt.a, tt.b)) - }) - } -} - -func requireNestedMap(t *testing.T, obj map[string]any, key string) map[string]any { - t.Helper() - m, ok := obj[key].(map[string]any) - if !ok { - t.Fatalf("EqualPredictedState fixture missing %q map", key) - } - return m -} diff --git a/internal/predict/ownership.go b/internal/predict/ownership.go deleted file mode 100644 index d84ddb5..0000000 --- a/internal/predict/ownership.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "errors" - "fmt" - "maps" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -const ( - appManagedByLabel = "app.kubernetes.io/managed-by" - appManagedByHelm = "Helm" - helmReleaseNameAnnotation = "meta.helm.sh/release-name" - helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace" -) - -func stampMetadata(obj *unstructured.Unstructured, releaseName, releaseNamespace string) { - obj.SetLabels(mergeStrStrMaps(obj.GetLabels(), map[string]string{ - appManagedByLabel: appManagedByHelm, - })) - obj.SetAnnotations(mergeStrStrMaps(obj.GetAnnotations(), map[string]string{ - helmReleaseNameAnnotation: releaseName, - helmReleaseNamespaceAnnotation: releaseNamespace, - })) -} - -func mergeStrStrMaps(current, desired map[string]string) map[string]string { - result := make(map[string]string, len(current)+len(desired)) - maps.Copy(result, current) - maps.Copy(result, desired) - return result -} - -func checkOwnership(obj *unstructured.Unstructured, releaseName, releaseNamespace string) error { - var errs []error - if err := requireValue(obj.GetLabels(), appManagedByLabel, appManagedByHelm); err != nil { - errs = append(errs, fmt.Errorf("label validation error: %w", err)) - } - if err := requireValue(obj.GetAnnotations(), helmReleaseNameAnnotation, releaseName); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %w", err)) - } - if err := requireValue(obj.GetAnnotations(), helmReleaseNamespaceAnnotation, releaseNamespace); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %w", err)) - } - if len(errs) > 0 { - return fmt.Errorf("invalid ownership metadata; %w", errors.Join(errs...)) - } - return nil -} - -func requireValue(meta map[string]string, k, v string) error { - actual, ok := meta[k] - if !ok { - return fmt.Errorf("missing key %q: must be set to %q", k, v) - } - if actual != v { - return fmt.Errorf("key %q must equal %q: current value is %q", k, v, actual) - } - return nil -} - -func resourceString(obj *unstructured.Unstructured) string { - return fmt.Sprintf("%s %q in namespace %q", obj.GetKind(), obj.GetName(), obj.GetNamespace()) -} - -func ownershipConflict(obj *unstructured.Unstructured, err error) error { - return fmt.Errorf("%s exists and cannot be imported into the current release: %w", - resourceString(obj), err) -} diff --git a/internal/predict/ownership_test.go b/internal/predict/ownership_test.go deleted file mode 100644 index 9e7fd54..0000000 --- a/internal/predict/ownership_test.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" -) - -func TestPredict_Ownership(t *testing.T) { - t.Parallel() - - desired := configMapYAML("app", "prod", "next") - - t.Run("correctly owned install adoption", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("app", "prod", "web")) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionUpdate, got.Action) - assert.Empty(t, got.Limitation) - }) - - t.Run("missing managed-by", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, map[string]any{}, "metadata", "labels")) - cluster := newFakeCluster() - cluster.store(live) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") - assert.ErrorContains(t, err, "app.kubernetes.io/managed-by") - }) - - t.Run("wrong managed-by", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - live.SetLabels(map[string]string{"app.kubernetes.io/managed-by": "Helmfile"}) - cluster := newFakeCluster() - cluster.store(live) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") - assert.ErrorContains(t, err, "app.kubernetes.io/managed-by") - }) - - t.Run("wrong release name", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("app", "prod", "other")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "meta.helm.sh/release-name") - }) - - t.Run("wrong release namespace", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - ann := live.GetAnnotations() - ann["meta.helm.sh/release-namespace"] = "other" - live.SetAnnotations(ann) - cluster := newFakeCluster() - cluster.store(live) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "meta.helm.sh/release-namespace") - }) - - t.Run("foreign existing resource on install", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(foreignConfigMap("app", "prod")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") - }) - - t.Run("foreign new resource on upgrade", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(foreignConfigMap("app", "prod")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: desired, - }) - require.Error(t, err) - assert.ErrorContains(t, err, "cannot be imported") - }) - - t.Run("already in previous skips ownership", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(foreignConfigMap("app", "prod")) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "prev"), - Desired: desired, - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionUpdate, got.Action) - }) -} - -func TestPredict_MetadataStamp(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - desired := configMapYAMLWith("app", "prod", ` labels: - app.kubernetes.io/managed-by: Other - app: web - annotations: - meta.helm.sh/release-name: stale - owner: team -`, "next") - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: desired, - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - require.NotNil(t, got.Predicted) - assert.Equal(t, "Helm", got.Predicted.GetLabels()["app.kubernetes.io/managed-by"]) - assert.Equal(t, "web", got.Predicted.GetLabels()["app"]) - assert.Equal(t, "web", got.Predicted.GetAnnotations()["meta.helm.sh/release-name"]) - assert.Equal(t, "prod", got.Predicted.GetAnnotations()["meta.helm.sh/release-namespace"]) - assert.Equal(t, "team", got.Predicted.GetAnnotations()["owner"]) - require.Len(t, cluster.applies, 1) - applied := cluster.applies[0].Obj - assert.Equal(t, "Helm", applied.GetLabels()["app.kubernetes.io/managed-by"]) - assert.Equal(t, "web", applied.GetAnnotations()["meta.helm.sh/release-name"]) -} diff --git a/internal/predict/predict.go b/internal/predict/predict.go deleted file mode 100644 index 574609b..0000000 --- a/internal/predict/predict.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "context" - "errors" - "fmt" - "strings" - - "deployah.dev/deployah/internal/helm" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -// Predict reports the Kubernetes resource state Deployah's Helm -// server-side apply path would produce for in. It does not mutate the -// cluster. Production [Cluster] implementations send server dry-run on -// every write. -// -// Exact prediction requires that the release namespace already exists -// for namespaced resources and that every resource group/version/kind -// is REST-mappable. Missing-namespace [apierrors.IsNotFound] and -// unmapped-kind [meta.IsNoMatchError] are returned as native typed -// errors, not rewritten to [ActionNoOp]. Those errors do not -// necessarily prove a real Deployah deploy would fail. -func Predict(ctx context.Context, cluster Cluster, in Input) ([]Result, error) { - if err := validateInput(in); err != nil { - return nil, err - } - if cluster == nil { - return nil, errors.New("cluster is required") - } - - previous, err := loadResources(cluster, in.Previous, in.Namespace) - if err != nil { - return nil, err - } - desired, err := loadResources(cluster, in.Desired, in.Namespace) - if err != nil { - return nil, err - } - for i := range desired { - stampMetadata(desired[i].obj, in.ReleaseName, in.Namespace) - desired[i].id = identityOf(desired[i].obj) - } - - prevByObjectKey := make(map[string]resourceObj, len(previous)) - for _, p := range previous { - prevByObjectKey[objectKey(p.id)] = p - } - - results := make([]Result, 0, len(desired)+len(previous)) - for _, d := range desired { - r, predErr := predictDesired(ctx, cluster, in, d, prevByObjectKey) - if predErr != nil { - return nil, predErr - } - results = append(results, r) - } - for _, p := range previous { - if desiredHasResource(desired, p.id) { - continue - } - r, pruneErr := predictPrune(ctx, cluster, p) - if pruneErr != nil { - return nil, pruneErr - } - results = append(results, r) - } - if len(results) == 0 { - return nil, nil - } - return results, nil -} - -func validateInput(in Input) error { - switch in.Operation { - case helm.OperationInstall, helm.OperationUpgrade: - default: - return fmt.Errorf("invalid helm operation %d", in.Operation) - } - if in.ReleaseName == "" { - return errors.New("release name is required") - } - if in.Namespace == "" { - return errors.New("release namespace is required") - } - if in.Operation == helm.OperationInstall && strings.TrimSpace(in.Previous) != "" { - return errors.New("previous manifest is not allowed on install") - } - return nil -} - -func desiredHasResource(desired []resourceObj, id Identity) bool { - for _, d := range desired { - if sameResource(d.id, id) { - return true - } - } - return false -} - -func isNewToRelease(op helm.Operation, id Identity, previous map[string]resourceObj) bool { - if op == helm.OperationInstall { - return true - } - _, ok := previous[objectKey(id)] - return !ok -} - -func predictDesired( - ctx context.Context, - cluster Cluster, - in Input, - desired resourceObj, - previous map[string]resourceObj, -) (Result, error) { - generateName, err := generateNameState(desired.obj) - if err != nil { - return Result{}, err - } - if generateName { - predicted, applyErr := applyCreate(ctx, cluster, desired.obj) - if applyErr != nil { - return Result{}, applyErr - } - return Result{ - Identity: desired.id, - Action: ActionCreate, - Predicted: predicted, - }, nil - } - - needOwnership := isNewToRelease(in.Operation, desired.id, previous) - live, err := cluster.Get(ctx, desired.id) - if err != nil && !apierrors.IsNotFound(err) { - return Result{}, fmt.Errorf("could not get information about the resource %s: %w", - resourceString(desired.obj), err) - } - if apierrors.IsNotFound(err) { - live = nil - } - - if needOwnership && live != nil { - if ownErr := checkOwnership(live, in.ReleaseName, in.Namespace); ownErr != nil { - return Result{}, ownershipConflict(live, ownErr) - } - } - - if live == nil { - predicted, applyErr := applyCreate(ctx, cluster, desired.obj) - if applyErr != nil { - return Result{}, applyErr - } - return Result{ - Identity: desired.id, - Action: ActionCreate, - Predicted: predicted, - }, nil - } - - limitation := "" - if in.Operation == helm.OperationInstall && needOwnership { - var migErr error - limitation, migErr = migrateManagedFields(ctx, cluster, desired.id, &live) - if migErr != nil { - return Result{}, migErr - } - } - - if limitation != "" { - predicted, applyErr := applyUpdate(ctx, cluster, desired.obj) - if applyErr != nil { - return Result{ - Identity: desired.id, - Action: ActionUpdate, - Live: live, - Limitation: limitation, - }, nil - } - return Result{ - Identity: desired.id, - Action: ActionUpdate, - Live: live, - Predicted: predicted, - Limitation: limitation, - }, nil - } - - predicted, applyErr := applyUpdate(ctx, cluster, desired.obj) - if applyErr != nil { - return Result{}, applyErr - } - if EqualPredictedState(live, predicted) { - return Result{ - Identity: desired.id, - Action: ActionNoOp, - Live: live, - Predicted: predicted, - }, nil - } - return Result{ - Identity: desired.id, - Action: ActionUpdate, - Live: live, - Predicted: predicted, - }, nil -} diff --git a/internal/predict/predict_test.go b/internal/predict/predict_test.go deleted file mode 100644 index a741198..0000000 --- a/internal/predict/predict_test.go +++ /dev/null @@ -1,544 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestPredict_Actions(t *testing.T) { - t.Parallel() - - t.Run("missing live is create", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionCreate, got.Action) - assert.Nil(t, got.Live) - require.NotNil(t, got.Predicted) - }) - - t.Run("changed live is update", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("app", "prod", "web")) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionUpdate, got.Action) - }) - - t.Run("identical normalized state is noop", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - live.SetResourceVersion("9") - live.SetUID("uid-1") - live.SetManagedFields(deployahUpdateManagedFields()) - require.NoError(t, unstructured.SetNestedField(live.Object, map[string]any{"ready": true}, "status")) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "next"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionNoOp, got.Action) - require.NotNil(t, got.Live) - require.NotNil(t, got.Predicted) - }) - - t.Run("removed previous is delete", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("old", "prod", "web")) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionDelete, got.Action) - assert.NotNil(t, got.Live) - assert.Nil(t, got.Predicted) - }) - - t.Run("removed previous already gone is noop", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "old") - require.True(t, ok) - assert.Equal(t, predict.ActionNoOp, got.Action) - assert.Nil(t, got.Live) - assert.Nil(t, got.Predicted) - assert.Empty(t, cluster.deletes) - }) -} - -func TestPredict_Normalize(t *testing.T) { - t.Parallel() - - t.Run("status only is noop", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - require.NoError(t, unstructured.SetNestedField(live.Object, map[string]any{"phase": "Ready"}, "status")) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "next"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionNoOp, got.Action) - }) - - t.Run("resourceVersion uid managedFields only is noop", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - live.SetResourceVersion("42") - live.SetUID("abc") - live.SetGeneration(7) - live.SetCreationTimestamp(metav1.Now()) - live.SetManagedFields(deployahUpdateManagedFields()) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "next"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionNoOp, got.Action) - }) - - t.Run("external annotation stays update", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - ann := live.GetAnnotations() - ann["deployah.dev/external"] = "keep-me" - live.SetAnnotations(ann) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "next"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionUpdate, got.Action) - }) - - t.Run("does not mutate live or predicted", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - live.SetResourceVersion("9") - require.NoError(t, unstructured.SetNestedField(live.Object, map[string]any{"ready": true}, "status")) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("app", "prod", "next"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, "9", got.Live.GetResourceVersion()) - _, found, nestErr := unstructured.NestedFieldNoCopy(got.Live.Object, "status") - require.NoError(t, nestErr) - assert.True(t, found) - _, found, nestErr = unstructured.NestedFieldNoCopy(got.Predicted.Object, "status") - require.NoError(t, nestErr) - assert.False(t, found) - }) -} - -func TestPredict_CreateOnlyResourceQuotaRetry(t *testing.T) { - t.Parallel() - - t.Run("create retries quota conflict", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.applySeq = []error{resourceQuotaConflict(), nil} - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionCreate, got.Action) - assert.GreaterOrEqual(t, cluster.applyCalls, 2) - }) - - t.Run("update does not retry quota conflict", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("app", "prod", "web")) - cluster.applyErr = resourceQuotaConflict() - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsConflict(err), "Predict() = %v, want conflict", err) - assert.Equal(t, 1, cluster.applyCalls) - }) - - t.Run("field manager conflict is an error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.applyErr = fieldManagerConflict() - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.True(t, apierrors.IsConflict(err), "Predict() = %v, want conflict", err) - }) -} - -func TestPredict_ManagedFieldsMigration(t *testing.T) { - t.Parallel() - - seedAdopted := func() *unstructured.Unstructured { - live := ownedConfigMap("app", "prod", "web") - live.SetResourceVersion("7") - live.SetManagedFields(deployahUpdateManagedFields()) - return live - } - - t.Run("empty patch is exact ssa", func(t *testing.T) { - t.Parallel() - live := ownedConfigMap("app", "prod", "web") - live.SetManagedFields([]metav1.ManagedFieldsEntry{{ - Manager: "deployah", - Operation: metav1.ManagedFieldsOperationApply, - APIVersion: "v1", - FieldsType: "FieldsV1", - FieldsV1: metav1.NewFieldsV1(`{"f:data":{"f:key":{}}}`), - }}) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Empty(t, got.Limitation) - assert.Empty(t, cluster.jsonPatches) - assert.Equal(t, predict.ActionUpdate, got.Action) - }) - - t.Run("migration dry-run failure is an error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(seedAdopted()) - cluster.jsonPatchErr = apierrors.NewForbidden( - schema.GroupResource{Resource: "configmaps"}, "app", errors.New("denied")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "failed to patch object to upgrade CSA field manager") - assert.True(t, apierrors.IsForbidden(err), "Predict() = %v, want forbidden", err) - }) - - t.Run("migration success and ssa success keeps limitation", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(seedAdopted()) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.LimitationManagedFieldsMigration, got.Limitation) - assert.Equal(t, predict.ActionUpdate, got.Action) - require.NotNil(t, got.Predicted) - require.NotEmpty(t, cluster.jsonPatches) - assert.NotEmpty(t, cluster.jsonPatches[0].Patch) - }) - - t.Run("migration success and ssa failure is not a deploy error", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(seedAdopted()) - cluster.applyErr = fieldManagerConflict() - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.LimitationManagedFieldsMigration, got.Limitation) - assert.Equal(t, predict.ActionUpdate, got.Action) - assert.Nil(t, got.Predicted) - assert.NotNil(t, got.Live) - }) - - t.Run("migration success skips noop", func(t *testing.T) { - t.Parallel() - live := seedAdopted() - require.NoError(t, unstructured.SetNestedField(live.Object, "next", "data", "key")) - cluster := newFakeCluster() - cluster.store(live) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.ActionUpdate, got.Action) - assert.Equal(t, predict.LimitationManagedFieldsMigration, got.Limitation) - }) - - t.Run("retry on conflict re-gets", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(seedAdopted()) - cluster.jsonPatchConflict = 1 - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Equal(t, predict.LimitationManagedFieldsMigration, got.Limitation) - assert.GreaterOrEqual(t, cluster.jsonPatchCalls, 2) - }) - - t.Run("upgrade does not migrate", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(seedAdopted()) - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationUpgrade, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("other", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - got, ok := findResult(results, "app") - require.True(t, ok) - assert.Empty(t, got.Limitation) - assert.Empty(t, cluster.jsonPatches) - }) -} - -func TestPredict_DesiredGetError(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - id := predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"} - cluster.getErr[objectKey(id)] = apierrors.NewForbidden( - schema.GroupResource{Resource: "configmaps"}, "app", errors.New("denied")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "could not get information about the resource") - assert.True(t, apierrors.IsForbidden(err), "Predict() desired GET = %v, want forbidden", err) - assert.Empty(t, cluster.applies) -} - -func TestPredict_HelmPathUsesSSAApply(t *testing.T) { - t.Parallel() - t.Run("create", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - assert.Equal(t, 0, cluster.createCalls) - require.NotEmpty(t, cluster.applies) - for _, got := range cluster.applies { - assert.Equal(t, kube.ManagedFieldsManager, got.Opts.FieldManager) - assert.False(t, got.Opts.ForceConflicts) - } - }) - t.Run("update", func(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - cluster.store(ownedConfigMap("app", "prod", "web")) - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.NoError(t, err) - assert.Equal(t, 0, cluster.createCalls) - require.NotEmpty(t, cluster.applies) - for _, got := range cluster.applies { - assert.Equal(t, kube.ManagedFieldsManager, got.Opts.FieldManager) - assert.False(t, got.Opts.ForceConflicts) - } - }) -} - -func TestPredict_EmptyManifests(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - results, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - }) - require.NoError(t, err) - assert.Nil(t, results) -} - -func TestPredict_RejectsInvalidInput(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - ReleaseName: "web", - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "invalid helm operation") - - _, err = predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - Namespace: "prod", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "release name is required") - - _, err = predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "release namespace is required") -} - -func TestPredict_RejectsInstallPrevious(t *testing.T) { - t.Parallel() - cluster := newFakeCluster() - _, err := predict.Predict(t.Context(), cluster, predict.Input{ - Operation: helm.OperationInstall, - ReleaseName: "web", - Namespace: "prod", - Previous: configMapYAML("old", "prod", "prev"), - Desired: configMapYAML("app", "prod", "next"), - }) - require.Error(t, err) - assert.ErrorContains(t, err, "previous manifest is not allowed on install") - assert.Empty(t, cluster.gets) - assert.Empty(t, cluster.applies) - assert.Empty(t, cluster.jsonPatches) - assert.Empty(t, cluster.deletes) -} diff --git a/internal/predict/ssa.go b/internal/predict/ssa.go deleted file mode 100644 index 7a1b9f4..0000000 --- a/internal/predict/ssa.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "context" - "fmt" - "strings" - - "helm.sh/helm/v4/pkg/kube" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/client-go/util/csaupgrade" - "k8s.io/client-go/util/retry" - - apierrors "k8s.io/apimachinery/pkg/api/errors" -) - -func applyCreate(ctx context.Context, cluster Cluster, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - var predicted *unstructured.Unstructured - err := retry.OnError(retry.DefaultRetry, isResourceQuotaConflict, func() error { - var applyErr error - predicted, applyErr = cluster.Apply(ctx, obj, ApplyOptions{ - FieldManager: kube.ManagedFieldsManager, - ForceConflicts: false, - }) - return applyErr - }) - if err != nil { - return nil, err - } - return predicted, nil -} - -func applyUpdate(ctx context.Context, cluster Cluster, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return cluster.Apply(ctx, obj, ApplyOptions{ - FieldManager: kube.ManagedFieldsManager, - ForceConflicts: false, - }) -} - -// isResourceQuotaConflict matches Helm pkg/kube isResourceQuotaConflict. -// The string match is Helm's predicate, not a local invention. -func isResourceQuotaConflict(err error) bool { - if err == nil || !apierrors.IsConflict(err) { - return false - } - msg := err.Error() - return strings.Contains(msg, "Operation cannot be fulfilled on resourcequotas") && - strings.Contains(msg, "the object has been modified") -} - -// migrateManagedFields dry-runs Helm's install-adoption CSA-to-SSA -// managed-fields patch. An empty computed patch is exact SSA. A -// non-empty patch that the API accepts sets -// [LimitationManagedFieldsMigration]. A failed dry-run is an error. -func migrateManagedFields(ctx context.Context, cluster Cluster, id Identity, live **unstructured.Unstructured) (string, error) { - limitation := "" - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - fresh, getErr := cluster.Get(ctx, id) - if getErr != nil { - return fmt.Errorf("failed to get object %s/%s %s: %w", - id.Namespace, id.Name, id.GroupVersionKind().String(), getErr) - } - *live = fresh - manager := kube.ManagedFieldsManager - patch, patchErr := csaupgrade.UpgradeManagedFieldsPatch( - fresh, sets.New(manager), manager) - if patchErr != nil { - return fmt.Errorf("failed to upgrade managed fields for object %s/%s %s: %w", - id.Namespace, id.Name, id.GroupVersionKind().String(), patchErr) - } - if len(patch) == 0 { - limitation = "" - return nil - } - limitation = LimitationManagedFieldsMigration - if jsonErr := cluster.JSONPatch(ctx, id, patch); jsonErr != nil { - if !apierrors.IsConflict(jsonErr) { - return fmt.Errorf("failed to patch object to upgrade CSA field manager %s/%s %s: %w", - id.Namespace, id.Name, id.GroupVersionKind().String(), jsonErr) - } - return jsonErr - } - return nil - }) - return limitation, err -} diff --git a/internal/predict/types.go b/internal/predict/types.go deleted file mode 100644 index fc4cdd0..0000000 --- a/internal/predict/types.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict - -import ( - "errors" - "fmt" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "deployah.dev/deployah/internal/helm" -) - -// LimitationManagedFieldsMigration marks a [Result] whose Predicted object -// is not an exact Helm write: install adoption needed a managed-fields -// JSON patch that server dry-run accepted, but that patch was not -// persisted before the SSA dry-run. -const LimitationManagedFieldsMigration = "managed-fields-migration" - -// Action is the semantic Kubernetes operation [Predict] would perform for -// one resource. The zero value is invalid. -type Action int - -const ( - // ActionCreate is a Desired resource with no live object. - ActionCreate Action = iota + 1 - // ActionUpdate is a Desired write that would change live state, or an - // install adoption whose prediction is limited. - ActionUpdate - // ActionDelete is a Previous resource that Helm would prune. - ActionDelete - // ActionNoOp is no observable change: already gone, keep policy, or - // normalized Live equals Predicted. - ActionNoOp -) - -func (a Action) String() string { - switch a { - case ActionCreate: - return "Create" - case ActionUpdate: - return "Update" - case ActionDelete: - return "Delete" - case ActionNoOp: - return "NoOp" - default: - return fmt.Sprintf("Action(%d)", int(a)) - } -} - -// Identity is one Kubernetes object's Helm-facing identity. Group, Kind, -// Namespace, and Name are used for prune matching. Version is used for -// new-to-release matching and API requests. -type Identity struct { - Group string - Version string - Kind string - Namespace string - Name string -} - -// Input is one Helm-resource prediction request. SSA is an invariant, not -// a field. Previous must be [helm.ReleasePrep.Current] Manifest on -// upgrade and empty on install. -type Input struct { - Operation helm.Operation - ReleaseName string - Namespace string - Previous string - Desired string -} - -// Result is the prediction for one flattened resource. -// -// Predicted is nil when the object would be gone after delete or was -// already absent, or when Limitation is set and an exact predicted -// state is unavailable. Keep-policy NoOp sets Predicted to a DeepCopy -// of Live. Action plus Limitation disambiguates a nil Predicted. -type Result struct { - Identity Identity - Action Action - Live *unstructured.Unstructured - Predicted *unstructured.Unstructured - Limitation string -} - -// InputFromPrep builds an [Input] from Stage A release prep. It fails -// closed: nil prep, an unknown or zero Operation, upgrade with a nil -// Current, and install with a non-nil Current are errors. -func InputFromPrep(prep *helm.ReleasePrep, name, namespace, desired string) (Input, error) { - if prep == nil { - return Input{}, errors.New("release prep is required") - } - switch prep.Operation { - case helm.OperationInstall: - if prep.Current != nil { - return Input{}, errors.New("install prep must not have a current release") - } - return Input{ - Operation: helm.OperationInstall, - ReleaseName: name, - Namespace: namespace, - Desired: desired, - }, nil - case helm.OperationUpgrade: - if prep.Current == nil { - return Input{}, errors.New("upgrade prep requires a current release") - } - return Input{ - Operation: helm.OperationUpgrade, - ReleaseName: name, - Namespace: namespace, - Previous: prep.Current.Manifest, - Desired: desired, - }, nil - default: - return Input{}, fmt.Errorf("invalid helm operation %d", prep.Operation) - } -} diff --git a/internal/predict/types_test.go b/internal/predict/types_test.go deleted file mode 100644 index 49e8f8f..0000000 --- a/internal/predict/types_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2025 The Deployah Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package predict_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "deployah.dev/deployah/internal/helm" - "deployah.dev/deployah/internal/predict" - - v1 "helm.sh/helm/v4/pkg/release/v1" -) - -func TestActionString(t *testing.T) { - t.Parallel() - assert.Equal(t, "Create", predict.ActionCreate.String()) - assert.Equal(t, "Update", predict.ActionUpdate.String()) - assert.Equal(t, "Delete", predict.ActionDelete.String()) - assert.Equal(t, "NoOp", predict.ActionNoOp.String()) - assert.Equal(t, "Action(0)", predict.Action(0).String()) -} - -func TestInputFromPrep(t *testing.T) { - t.Parallel() - - desired := configMapYAML("app", "prod", "next") - current := &v1.Release{Name: "web", Manifest: configMapYAML("app", "prod", "prev")} - - t.Run("nil prep", func(t *testing.T) { - t.Parallel() - _, err := predict.InputFromPrep(nil, "web", "prod", desired) - require.Error(t, err) - assert.ErrorContains(t, err, "release prep is required") - }) - - t.Run("zero operation", func(t *testing.T) { - t.Parallel() - _, err := predict.InputFromPrep(&helm.ReleasePrep{}, "web", "prod", desired) - require.Error(t, err) - assert.ErrorContains(t, err, "invalid helm operation") - }) - - t.Run("upgrade without current", func(t *testing.T) { - t.Parallel() - _, err := predict.InputFromPrep(&helm.ReleasePrep{Operation: helm.OperationUpgrade}, "web", "prod", desired) - require.Error(t, err) - assert.ErrorContains(t, err, "upgrade prep requires a current release") - }) - - t.Run("install with current", func(t *testing.T) { - t.Parallel() - _, err := predict.InputFromPrep(&helm.ReleasePrep{ - Operation: helm.OperationInstall, - Current: current, - }, "web", "prod", desired) - require.Error(t, err) - assert.ErrorContains(t, err, "install prep must not have a current release") - }) - - t.Run("install without current", func(t *testing.T) { - t.Parallel() - in, err := predict.InputFromPrep(&helm.ReleasePrep{Operation: helm.OperationInstall}, "web", "prod", desired) - require.NoError(t, err) - assert.Equal(t, helm.OperationInstall, in.Operation) - assert.Equal(t, "web", in.ReleaseName) - assert.Equal(t, "prod", in.Namespace) - assert.Empty(t, in.Previous) - assert.Equal(t, desired, in.Desired) - }) - - t.Run("upgrade uses current manifest", func(t *testing.T) { - t.Parallel() - in, err := predict.InputFromPrep(&helm.ReleasePrep{ - Operation: helm.OperationUpgrade, - Current: current, - }, "web", "prod", desired) - require.NoError(t, err) - assert.Equal(t, helm.OperationUpgrade, in.Operation) - assert.Equal(t, current.Manifest, in.Previous) - assert.Equal(t, desired, in.Desired) - }) -}