From 14eef6dea12d471d506f8f1f23033457cee592d1 Mon Sep 17 00:00:00 2001 From: Andrew Mello Date: Thu, 29 Oct 2026 13:52:00 -0700 Subject: [PATCH 1/5] eas: k3s/k3d patch overlay that drops each patch once upstream carries the fix integrations/eas/overlay.py decides, per row of integrations/eas/overlay.json and at one EAS commit, whether a k3s/k3d patch is skipped (not needed on this cluster), dropped because upstream merged it (git apply -R --check), dropped because upstream fixed it another way (a probe matches and the knob the generator writes is still there), applied (first variant whose git apply --check passes), or a STOP that names the upstream change that moved the hunk and says to regenerate. Every decision prints as `fact -> choice [source]`, with --explain giving permalinks. Decisions read the commit through a scratch index, so they do not change once the overlay is applied; --apply writes the working tree only when nothing STOPs, and a re-run is a no-op. Patches are looked up in integrations/eas/patches, then in the upstream drafts, so the drafts stay the only copy. The one new patch is the release-1.2 form of 0006, where the EnvoyProxy task is still in roles/envoy_gateway/tasks/install.yaml. tests/eas_test.sh (section overlay) runs it on synthetic git repos built from byte-identical copies of the EAS files the patches touch, at main 97f6157 and release-1.2 5c27fb3. apply-check.sh gains --overlay, which checks the recorded decisions on a real clone and prints them for branch tips and open EAS PRs #50, #52 and #61. --- .github/workflows/ci.yml | 1 + integrations/eas/overlay.json | 164 ++++++ integrations/eas/overlay.py | 469 ++++++++++++++++ ...ase-1.2-envoy-control-plane-affinity.patch | 77 +++ integrations/eat/upstream/README.md | 17 +- .../eat/upstream/checks/apply-check.sh | 30 +- tests/eas_test.sh | 437 +++++++++++++++ .../configs/defaults/global_config.yaml | 152 ++++++ .../fixtures/eas/trees/5c27fb3/docs/README.md | 61 +++ .../5c27fb3/docs/customize/node_topology.md | 267 +++++++++ .../trees/5c27fb3/docs/deploy/topologies.md | 241 ++++++++ .../roles/envoy_gateway/tasks/install.yaml | 513 ++++++++++++++++++ .../5c27fb3/roles/istio/defaults/main.yaml | 55 ++ .../5c27fb3/roles/istio/tasks/install.yaml | 122 +++++ .../5c27fb3/roles/istio/tasks/upgrade.yaml | 23 + .../kubernetes/tasks/apply_node_topology.yaml | 106 ++++ .../roles/kubernetes/tasks/install.yaml | 219 ++++++++ .../roles/nri_cpu_balloons/defaults/main.yaml | 61 +++ .../tasks/install_advanced.yaml | 396 ++++++++++++++ .../configs/defaults/global_config.yaml | 159 ++++++ .../fixtures/eas/trees/97f6157/docs/README.md | 62 +++ .../97f6157/docs/customize/node_topology.md | 267 +++++++++ .../trees/97f6157/docs/deploy/topologies.md | 240 ++++++++ .../roles/envoy_gateway/tasks/controller.yaml | 195 +++++++ .../97f6157/roles/istio/defaults/main.yaml | 50 ++ .../97f6157/roles/istio/tasks/install.yaml | 117 ++++ .../97f6157/roles/istio/tasks/upgrade.yaml | 18 + .../kubernetes/tasks/apply_node_topology.yaml | 106 ++++ .../roles/kubernetes/tasks/install.yaml | 217 ++++++++ .../roles/nri_cpu_balloons/defaults/main.yaml | 61 +++ .../tasks/install_advanced.yaml | 396 ++++++++++++++ tests/fixtures/eas/trees/LICENSE | 223 ++++++++ tests/fixtures/eas/trees/SOURCES | 35 ++ 33 files changed, 5555 insertions(+), 2 deletions(-) create mode 100644 integrations/eas/overlay.json create mode 100755 integrations/eas/overlay.py create mode 100644 integrations/eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch create mode 100755 tests/eas_test.sh create mode 100644 tests/fixtures/eas/trees/5c27fb3/configs/defaults/global_config.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/docs/README.md create mode 100644 tests/fixtures/eas/trees/5c27fb3/docs/customize/node_topology.md create mode 100644 tests/fixtures/eas/trees/5c27fb3/docs/deploy/topologies.md create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/envoy_gateway/tasks/install.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/istio/defaults/main.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/istio/tasks/install.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/istio/tasks/upgrade.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/kubernetes/tasks/apply_node_topology.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/kubernetes/tasks/install.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/nri_cpu_balloons/defaults/main.yaml create mode 100644 tests/fixtures/eas/trees/5c27fb3/roles/nri_cpu_balloons/tasks/install_advanced.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/configs/defaults/global_config.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/docs/README.md create mode 100644 tests/fixtures/eas/trees/97f6157/docs/customize/node_topology.md create mode 100644 tests/fixtures/eas/trees/97f6157/docs/deploy/topologies.md create mode 100644 tests/fixtures/eas/trees/97f6157/roles/envoy_gateway/tasks/controller.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/istio/defaults/main.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/istio/tasks/install.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/istio/tasks/upgrade.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/kubernetes/tasks/apply_node_topology.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/kubernetes/tasks/install.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/nri_cpu_balloons/defaults/main.yaml create mode 100644 tests/fixtures/eas/trees/97f6157/roles/nri_cpu_balloons/tasks/install_advanced.yaml create mode 100644 tests/fixtures/eas/trees/LICENSE create mode 100644 tests/fixtures/eas/trees/SOURCES diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00bf2c28..f8dc763a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: - run: tests/image_test.sh - run: tests/host_setup_test.sh - run: tests/eat_test.sh + - run: tests/eas_test.sh - run: tests/platforms_doc_test.sh - run: tests/reserved_cpus_test.sh - run: tests/experimental_test.sh diff --git a/integrations/eas/overlay.json b/integrations/eas/overlay.json new file mode 100644 index 00000000..f44b425a --- /dev/null +++ b/integrations/eas/overlay.json @@ -0,0 +1,164 @@ +{ + "schema": "k3d-gpu.eas-overlay/v1", + "checked": "2026-10-29", + "repo": "intel/enterprise-ai-solutions", + "patch_dirs": ["integrations/eas/patches", "integrations/eat/upstream/patches"], + "rules": { + "always": "every k3d-gpu cluster", + "eas_owns_nri": "only when kubernetes_cpu_policy is nri-balloons: EAS installs the nri-plugins balloons chart only then (configs/components.yaml, nri_cpu_balloons.enabled)", + "multi_node": "only on a cluster with more than one node; k3d-gpu runs one server", + "never": "documentation only; nothing at install time reads it" + }, + "rows": [ + { + "id": "0002", + "what": "istio: pass istio_platform to the Istio chart as global.platform, so istio-cni uses the k3s/k3d CNI paths", + "variants": [ + {"name": "a", "files": ["0002-eas-istio-global-platform.patch"], "for": "main and release-1.2"} + ], + "needed_when": "always", + "fixed_probe": [ + { + "glob": "roles/istio/**", + "regex": "(?m)global\\.platform\\s*=|['\"]platform['\"]\\s*:|global:\\s*\\{\\s*platform\\s*:|^\\s*global:[ \\t]*\\n\\s+platform:", + "knob": "istio_platform", + "what": "an Istio task or default sets global.platform from a variable" + } + ], + "moved": [ + { + "glob": "configs/defaults/global_config.yaml", + "regex": "(?m)^headlamp_enabled:", + "upstream": "https://github.com/intel/enterprise-ai-solutions/pull/61", + "what": "headlamp_enabled now follows velero_enabled, the context of 0002's global_config.yaml hunk" + } + ], + "upstream": { + "filed": false, + "evidence": "https://istio.io/latest/docs/ambient/install/platform-prerequisites/" + } + }, + { + "id": "0003", + "what": "nri_cpu_balloons: pin the nri-plugins balloons chart to v0.15.0", + "variants": [ + {"name": "a", "files": ["0003-eas-pin-nri-balloons-chart.patch"], "for": "main and release-1.2"} + ], + "needed_when": "eas_owns_nri", + "fixed_probe": [ + { + "glob": "roles/nri_cpu_balloons/tasks/install_advanced.yaml", + "regex": "(?m)^\\s*chart_version:", + "knob": null, + "what": "the balloons Helm task passes a chart_version" + } + ], + "moved": [], + "upstream": { + "filed": false, + "evidence": "https://github.com/containers/nri-plugins/releases/tag/v0.15.0" + } + }, + { + "id": "0004", + "what": "kubernetes: apply the workload-class node labels on multi-node BYO clusters", + "variants": [ + {"name": "a", "files": ["0004-eas-byo-node-topology-labels.patch"], "for": "main and release-1.2"} + ], + "needed_when": "multi_node", + "fixed_probe": [ + { + "glob": "roles/kubernetes/tasks/install.yaml", + "regex": "apply_node_topology\\.yaml", + "knob": null, + "what": "the role's install entry point runs apply_node_topology.yaml itself" + } + ], + "moved": [], + "upstream": { + "filed": false, + "evidence": "https://github.com/intel/enterprise-ai-solutions/blob/97f6157d6a72f7173c698c474d4e51d2c79f1fd6/roles/kubernetes/tasks/install.yaml#L23-L25" + } + }, + { + "id": "0005", + "what": "docs: Single-Node Dev on k3d", + "variants": [ + {"name": "a", "files": ["0005-eas-docs-single-node-k3d.patch"], "for": "main"} + ], + "needed_when": "never", + "fixed_probe": [], + "moved": [], + "upstream": { + "filed": false, + "evidence": "https://github.com/intel/enterprise-ai-solutions/blob/97f6157d6a72f7173c698c474d4e51d2c79f1fd6/docs/deploy/topologies.md#L189" + } + }, + { + "id": "0006", + "what": "envoy_gateway: select control-plane nodes by label presence (k3s stamps \"true\", kubeadm \"\")", + "variants": [ + {"name": "a", "files": ["0006-eas-envoy-control-plane-affinity.patch"], "for": "main after EAS #42 (roles/envoy_gateway/tasks/controller.yaml)"}, + {"name": "b", "files": ["0006-release-1.2-envoy-control-plane-affinity.patch"], "for": "release-1.2 (roles/envoy_gateway/tasks/install.yaml)"} + ], + "needed_when": "always", + "fixed_probe": [ + { + "glob": "roles/envoy_gateway/**", + "regex": "(?s)\\A(?!.*node-role\\.kubernetes\\.io/control-plane:[ \\t]*(?:\"\"|''))(?=.*?(?PmatchExpressions:\\s*-[ \\t]*key:[ \\t]*[\"']?node-role\\.kubernetes\\.io/control-plane[\"']?\\s+operator:[ \\t]*[\"']?Exists))", + "knob": null, + "what": "a file selects control-plane nodes with a matchExpressions Exists term and has no exact \"\" selector" + }, + { + "glob": "roles/envoy_gateway/vars/k3s.yaml", + "regex": "(?m)^_envoy_gateway_proxy_shape:", + "knob": null, + "what": "a k3s entry in the per-distribution vars layout of EAS #50" + } + ], + "moved": [ + { + "glob": "roles/envoy_gateway/tasks/controller.yaml", + "regex": "_eg_k8s_provider:", + "upstream": "https://github.com/intel/enterprise-ai-solutions/pull/50", + "what": "the EnvoyProxy provider moved into the _eg_k8s_provider var, still an exact \"\" selector (#52 carries the same change)" + } + ], + "upstream": { + "filed": false, + "evidence": "https://github.com/k3s-io/k3s/blob/843c32de489c25ccc1bde53d21c5fd7df23c706e/pkg/server/server.go#L550" + } + } + ], + "verified": [ + { + "profile": "main", + "rev": "97f6157d6a72f7173c698c474d4e51d2c79f1fd6", + "fixture": "tests/fixtures/eas/trees/97f6157", + "cases": [ + {"policy": "best-effort", "nodes": 1, + "expect": {"0002": "apply:a", "0003": "skip", "0004": "skip", "0005": "skip", "0006": "apply:a"}}, + {"policy": "nri-balloons", "nodes": 3, + "expect": {"0002": "apply:a", "0003": "apply:a", "0004": "apply:a", "0005": "skip", "0006": "apply:a"}} + ] + }, + { + "profile": "v1.2.0-rc.1", + "rev": "5c27fb39a95eecec41aa4e29a1188984e9c726f3", + "fixture": "tests/fixtures/eas/trees/5c27fb3", + "cases": [ + {"policy": "best-effort", "nodes": 1, + "expect": {"0002": "apply:a", "0003": "skip", "0004": "skip", "0005": "skip", "0006": "apply:b"}}, + {"policy": "nri-balloons", "nodes": 3, + "expect": {"0002": "apply:a", "0003": "apply:a", "0004": "apply:a", "0005": "skip", "0006": "apply:b"}} + ] + } + ], + "watch": [ + {"ref": "origin/main", "what": "EAS main tip"}, + {"ref": "origin/release-1.2", "what": "EAS release-1.2 tip"}, + {"ref": "pull/50/head", "what": "open: OpenShift platform layer, moves the EnvoyProxy provider into _eg_k8s_provider"}, + {"ref": "pull/52/head", "what": "open: OpenShift ownership labels, stacked on #50"}, + {"ref": "pull/61/head", "what": "open: Headlamp SSO, changes 0002's global_config.yaml context and 0004/0005"} + ] +} diff --git a/integrations/eas/overlay.py b/integrations/eas/overlay.py new file mode 100755 index 00000000..c4c39b47 --- /dev/null +++ b/integrations/eas/overlay.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""The k3s/k3d patch overlay for an intel/enterprise-ai-solutions (EAS) checkout. + +For each row of overlay.json it decides, at one EAS commit, and prints the +decision as `fact -> choice [source]`: + + skip the row's needed_when rule is false for these facts + drop-merged a variant reverse-applies (git apply -R --check): upstream has it + drop-fixed a fixed_probe regex matches a file its glob selects, and the knob + the generator writes appears in that file (no knob: the match + alone). A match whose knob is gone is a STOP: upstream fixed it + under another name, and the generator's knob would do nothing. + apply the first variant, in order, that applies (git apply --check) + STOP none of the above: the patch has to be regenerated. The line + names the upstream change that moved the hunk: a `moved` hint of + the row when one matches, else the last commit on the patch's files. + +Decisions read the commit (--rev, default HEAD) through a scratch index, never +the working tree, so they do not change once the overlay is applied. --apply +patches the working tree of the checkout, only when --rev is HEAD and no row +STOPs: every chosen patch the working tree lacks, in one `git apply`. A patch +it already carries (a re-run) is left as it is; a patch's files in any other +state STOP the run before anything is written. + +Patch files are looked up in the overlay.json patch_dirs in order +(integrations/eas/patches, then the upstream drafts in +integrations/eat/upstream/patches), so a draft is never copied. + +Usage: + overlay.py --eas DIR --policy POLICY --nodes N [--rev REV] [--apply] + [--json FILE|-] [--explain] + overlay.py --eas DIR --check-verified every overlay.json `verified` case at its sha + overlay.py --eas DIR --watch decisions at every `watch` ref present in DIR + +POLICY is the kubernetes_cpu_policy the generator chose (nri-balloons, +kubelet-static or best-effort); N is the cluster's node count. Exit 0 when +nothing STOPs, 3 on a STOP (nothing applied), 1 when --check-verified finds a +moved decision, 2 on bad input. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +POLICIES = ("nri-balloons", "kubelet-static", "best-effort") +RULES = { + "always": lambda f: True, + "eas_owns_nri": lambda f: f["policy"] == "nri-balloons", + "multi_node": lambda f: f["nodes"] > 1, + "never": lambda f: False, +} +# The k3d-gpu cluster: one server, its own balloons policy kept (--watch). +K3D_GPU_FACTS = {"policy": "best-effort", "nodes": 1} + + +class Bad(Exception): + """Input the overlay cannot work with (exit 2).""" + + +def glob_re(glob): + """A path glob as a regex: ** spans directories, * and ? stay within one.""" + out, i = "", 0 + while i < len(glob): + if glob.startswith("**/", i): + out, i = out + "(?:.*/)?", i + 3 + elif glob.startswith("**", i): + out, i = out + ".*", i + 2 + elif glob[i] == "*": + out, i = out + "[^/]*", i + 1 + elif glob[i] == "?": + out, i = out + "[^/]", i + 1 + else: + out, i = out + re.escape(glob[i]), i + 1 + return re.compile(out + r"\Z") + + +def first_hunk(path): + """The first hunk of a patch: (file, {"old"|"new": (stated start, lines)}, leading context).""" + target, start, sides, ctx, changed = None, None, {"old": [], "new": []}, 0, False + with open(path, encoding="utf-8") as f: + for line in f: + if start is None: + if line.startswith("+++ "): + target = line[4:].rstrip("\n") + target = target[2:] if target.startswith("b/") else target + m = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?", line) + if m and target: + start = {"old": int(m.group(1)), "new": int(m.group(3))} + want = {"old": int(m.group(2) or 1), "new": int(m.group(4) or 1)} + continue + if all(len(sides[s]) >= want[s] for s in sides): + break + tag, body = line[:1], line[1:] + if tag == " ": + sides["old"].append(body) + sides["new"].append(body) + ctx += 0 if changed else 1 + elif tag in ("-", "+"): + sides["old" if tag == "-" else "new"].append(body) + changed = True + elif tag != "\\": + break + if start is None: + raise Bad("no hunk in %s" % path) + return target, {s: (start[s], sides[s]) for s in sides}, ctx + + +def landing(eas, path, side): + """(file, line) where a patch's first change sits in the commit: the hunk's `side` + lines ("old" before applying, "new" once applied) located nearest the line the + hunk header states, as git apply locates them.""" + target, sides, ctx = first_hunk(path) + stated, lines = sides[side] + have = eas.text(target).splitlines(keepends=True) + hits = [i + 1 for i in range(len(have) - len(lines) + 1) if have[i:i + len(lines)] == lines] if lines else [] + at = min(hits, key=lambda i: abs(i - stated)) if hits else stated + return target, max(at + ctx, 1) + + +def touched(paths): + """Files a set of patches change.""" + out = [] + for p in paths: + with open(p, encoding="utf-8") as f: + for line in f: + if line.startswith("+++ ") and line[4:].strip() != "/dev/null": + t = line[4:].strip() + t = t[2:] if t.startswith("b/") else t + if t not in out: + out.append(t) + return out + + +class Eas: + """Read-only views of one EAS commit, plus the working-tree apply.""" + + def __init__(self, path, rev): + self.path = os.path.abspath(path) + top = self.git("rev-parse", "--show-toplevel", check=False) + if top.returncode or os.path.realpath(top.stdout.strip()) != os.path.realpath(self.path): + raise Bad("%s is not the top of a git checkout" % path) + self.sha = self.resolve(rev) + if not self.sha: + raise Bad("%s: no commit %s" % (path, rev)) + self._files = None + self._text = {} + self._tmp = tempfile.TemporaryDirectory(prefix="eas-overlay-") + self.index = os.path.join(self._tmp.name, "index") + self.git("read-tree", "--index-output=" + self.index, self.sha) + + def git(self, *args, env=None, check=True): + e = dict(os.environ, **(env or {})) + r = subprocess.run(["git", "-C", self.path] + list(args), capture_output=True, text=True, env=e) + if check and r.returncode: + raise Bad("git %s: %s" % (" ".join(args), r.stderr.strip())) + return r + + def resolve(self, rev): + r = self.git("rev-parse", "--verify", "-q", rev + "^{commit}", check=False) + return r.stdout.strip() if r.returncode == 0 else None + + def files(self): + if self._files is None: + out = self.git("ls-tree", "-r", "-z", "--name-only", self.sha).stdout + self._files = [p for p in out.split("\0") if p] + return self._files + + def text(self, path): + if path not in self._text: + r = self.git("cat-file", "blob", "%s:%s" % (self.sha, path), check=False) + self._text[path] = r.stdout if r.returncode == 0 else "" + return self._text[path] + + def check(self, patches, reverse=False): + """git apply --check at the commit, through the scratch index.""" + args = ["apply", "--cached", "--check", "--whitespace=nowarn"] + (["-R"] if reverse else []) + patches + return self.git(*args, env={"GIT_INDEX_FILE": self.index}, check=False).returncode == 0 + + def last_commit(self, paths): + r = self.git("log", "-1", "--format=%h %s", self.sha, "--", *paths, check=False) + return r.stdout.strip() + + def worktree(self, patches, reverse=False, check=True): + args = ["apply", "--whitespace=nowarn"] + (["--check"] if check else []) + (["-R"] if reverse else []) + patches + return self.git(*args, check=False).returncode == 0 + + +def load_spec(path): + try: + with open(path, encoding="utf-8") as f: + spec = json.load(f) + except (OSError, ValueError) as e: + raise Bad("%s: %s" % (path, e)) from None + if spec.get("schema") != "k3d-gpu.eas-overlay/v1": + raise Bad("%s: schema %r, want k3d-gpu.eas-overlay/v1" % (path, spec.get("schema"))) + for row in spec["rows"]: + if row["needed_when"] not in RULES: + raise Bad("row %s: needed_when %r is not one of %s" % (row["id"], row["needed_when"], ", ".join(RULES))) + for v in row["variants"]: + v["paths"] = [patch_path(spec, f) for f in v["files"]] + for p in row["fixed_probe"] + row["moved"]: + try: + p["re"], p["glob_re"] = re.compile(p["regex"]), glob_re(p["glob"]) + except re.error as e: + raise Bad("row %s: regex %r: %s" % (row["id"], p["regex"], e)) from None + return spec + + +def patch_path(spec, name): + for d in spec["patch_dirs"]: + p = os.path.join(ROOT, d, name) + if os.path.isfile(p): + return p + raise Bad("patch %s is in none of %s" % (name, ", ".join(spec["patch_dirs"]))) + + +def rel(path): + return os.path.relpath(path, ROOT) + + +def line_of(text, pos): + return text.count("\n", 0, pos) + 1 + + +def probe(eas, row): + """(state, file, line, knob): state "fixed", "knob-missing" or None.""" + missing = None + for p in row["fixed_probe"]: + for path in eas.files(): + if not p["glob_re"].match(path): + continue + text = eas.text(path) + m = p["re"].search(text) + if not m: + continue + at = m.start("at") if "at" in p["re"].groupindex and m.start("at") >= 0 else m.start() + knob = p.get("knob") + if knob is None or re.search(r"(? %s [%s]" % (d["fact"], d["choice"], d["permalink"] if explain else d["source"]) for d in decisions] + + +def run(args, spec): + if args.policy not in POLICIES: + raise Bad("--policy %r: want one of %s" % (args.policy, ", ".join(POLICIES))) + if args.nodes < 1: + raise Bad("--nodes %d: a cluster has at least one node" % args.nodes) + facts = {"policy": args.policy, "nodes": args.nodes} + eas = Eas(args.eas, args.rev) + if args.apply and eas.sha != eas.resolve("HEAD"): + raise Bad("--apply patches the working tree, so --rev must be HEAD (%s is not)" % args.rev) + decisions = decide(spec, eas, facts) + stop = any(d["decision"] == "STOP" for d in decisions) + if args.json: + doc = {"schema": "k3d-gpu.eas-overlay-decisions/v1", "repo": spec["repo"], "rev": eas.sha, + "facts": facts, "stop": stop, "decisions": decisions} + if args.json == "-": + json.dump(doc, sys.stdout, indent=2) + print() + else: + with open(args.json, "w", encoding="utf-8") as f: + json.dump(doc, f, indent=2) + f.write("\n") + if args.json != "-": + print("# overlay at eas@%s (%s), kubernetes_cpu_policy=%s, nodes=%d" + % (eas.sha[:7], args.rev, facts["policy"], facts["nodes"])) + print("\n".join(text_lines(decisions, args.explain))) + if stop: + print("STOP: the overlay does not fit eas@%s; regenerate the rows above that say STOP. Nothing was applied." + % eas.sha[:7], file=sys.stderr) + return 3 + if args.apply: + return apply(args, eas, decisions) + return 0 + + +def apply(args, eas, decisions): + """Each chosen patch the working tree lacks, in one git apply; one it already + carries is left alone (a re-run). Any other state of a patch's files: STOP.""" + todo, done = [], [] + for d in decisions: + if d["decision"] != "apply": + continue + patches = [os.path.join(ROOT, f) for f in d["files"]] + if eas.worktree(patches): + todo += patches + elif eas.worktree(patches, reverse=True): + done += patches + else: + todo = None + break + if todo is None or (todo and not eas.worktree(todo)): + bad = touched([os.path.join(ROOT, f) for d in decisions if d["decision"] == "apply" for f in d["files"]]) + print("STOP: the working tree of %s differs from eas@%s in %s; reset it to the commit (git checkout -f %s) " + "and run again. Nothing was applied." % (args.eas, eas.sha[:7], ", ".join(bad), eas.sha[:7]), + file=sys.stderr) + return 3 + if todo and not eas.worktree(todo, check=False): + raise Bad("git apply failed in %s after its --check passed" % args.eas) + if todo: + print("applied %s to %s" % (" ".join(os.path.basename(p) for p in todo), args.eas)) + if done: + print("already applied in %s: %s" % (args.eas, " ".join(os.path.basename(p) for p in done))) + if not todo and not done: + print("nothing to apply to %s" % args.eas) + return 0 + + +def check_verified(args, spec): + bad = 0 + for v in spec["verified"]: + try: + eas = Eas(args.eas, v["rev"]) + except Bad as e: + print("FAIL %s %s: %s (fetch it)" % (v["profile"], v["rev"][:7], e)) + bad += 1 + continue + for c in v["cases"]: + decisions = decide(spec, eas, {"policy": c["policy"], "nodes": c["nodes"]}) + got = {d["id"]: d["brief"] for d in decisions} + label = "%s %s %s nodes=%d" % (v["profile"], eas.sha[:7], c["policy"], c["nodes"]) + moved = ["%s want %s, got %s" % (i, c["expect"].get(i), got.get(i)) + for i in sorted(set(got) | set(c["expect"])) if got.get(i) != c["expect"].get(i)] + summary = " ".join("%s=%s" % kv for kv in sorted(got.items())) + if moved: + bad += 1 + print("FAIL %s: %s" % (label, "; ".join(moved))) + else: + print("ok %s: %s" % (label, summary)) + for line in text_lines(decisions, args.explain): + print(" " + line) + return 1 if bad else 0 + + +def watch(args, spec): + for w in spec["watch"]: + try: + eas = Eas(args.eas, w["ref"]) + except Bad: + print("# %s: not in this clone (%s)" % (w["ref"], w["what"])) + continue + print("# %s = eas@%s (%s), kubernetes_cpu_policy=%s, nodes=%d" + % (w["ref"], eas.sha[:7], w["what"], K3D_GPU_FACTS["policy"], K3D_GPU_FACTS["nodes"])) + print("\n".join(text_lines(decide(spec, eas, K3D_GPU_FACTS), args.explain))) + return 0 + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--eas", required=True, help="EAS git checkout") + ap.add_argument("--rev", default="HEAD", help="commit to decide at (default HEAD)") + ap.add_argument("--policy", help="kubernetes_cpu_policy the generator chose") + ap.add_argument("--nodes", type=int, help="nodes in the cluster") + ap.add_argument("--apply", action="store_true", help="apply the chosen patches to the working tree") + ap.add_argument("--json", metavar="FILE", help="also write the decisions as JSON to FILE (- for stdout only)") + ap.add_argument("--explain", action="store_true", help="full permalinks instead of short sources") + ap.add_argument("--check-verified", action="store_true", help="check every overlay.json verified case") + ap.add_argument("--watch", action="store_true", help="decisions at every overlay.json watch ref") + ap.add_argument("--spec", default=os.path.join(HERE, "overlay.json"), help=argparse.SUPPRESS) + args = ap.parse_args(argv) + try: + spec = load_spec(args.spec) + if args.check_verified: + return check_verified(args, spec) + if args.watch: + return watch(args, spec) + if args.policy is None or args.nodes is None: + raise Bad("--policy and --nodes are required") + return run(args, spec) + except Bad as e: + print("overlay: %s" % e, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/integrations/eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch b/integrations/eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch new file mode 100644 index 00000000..48cf316a --- /dev/null +++ b/integrations/eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch @@ -0,0 +1,77 @@ +From 0d13b34a8ab3c5b271ab8fad3a82b726c013a4b1 Mon Sep 17 00:00:00 2001 +From: Andrew Mello +Date: Thu, 29 Oct 2026 13:35:38 -0700 +Subject: [PATCH] envoy_gateway: select control-plane nodes by label presence + (release-1.2) + +The release-1.2 form of the main-branch change in +roles/envoy_gateway/tasks/controller.yaml. On release-1.2 the EnvoyProxy +task still lives in roles/envoy_gateway/tasks/install.yaml (EAS #42 +moved it to controller.yaml on main), so the same change is cut against +that file. + +The EnvoyProxy DaemonSet pins envoy to control-plane nodes with +nodeSelector node-role.kubernetes.io/control-plane: "". That matches +kubeadm / kubespray nodes, which carry the label with an empty value, +but k3s sets it to "true" (k3s-io/k3s pkg/server/server.go, +util.ControlPlaneRoleLabelKey), so on a BYO k3s or k3d cluster the +DaemonSet schedules no pods and the gateway has no data plane. + +Use a required node affinity with operator Exists on the same key, and +set nodeSelector to null so a re-install over an older EnvoyProxy (a +JSON merge patch, which keeps every key it does not null) drops the old +selector. Behaviour on kubeadm / kubespray clusters is unchanged; the +toleration is untouched. + +Checked statically: release-1.2 pins envoy_gateway_version 1.7.1, the +same gateway-helm whose v1alpha1 EnvoyProxy CRD includes pod.affinity. +Modelling create (null pruned) and re-install over the old object +(RFC 7386 merge) both leave affinity and no nodeSelector. +--- + roles/envoy_gateway/tasks/install.yaml | 23 ++++++++++++++++------- + 1 file changed, 16 insertions(+), 7 deletions(-) + +diff --git a/roles/envoy_gateway/tasks/install.yaml b/roles/envoy_gateway/tasks/install.yaml +index 9f3d423..72c3444 100644 +--- a/roles/envoy_gateway/tasks/install.yaml ++++ b/roles/envoy_gateway/tasks/install.yaml +@@ -191,11 +191,12 @@ + # MetalLB elects at any given moment: every CP node always has a local + # envoy. Survives CP failover, no hostname pinning, no cp[0] dance. + # +-# nodeSelector keys on the standard control-plane label (empty string is +-# how kubespray / kubeadm stamp it) rather than the taint, because +-# operators can remove the taint; the label is the stable Kubernetes +-# control-plane marker. Toleration is kept for the case where the taint +-# is still in place. ++# Node affinity keys on the standard control-plane label rather than the ++# taint, because operators can remove the taint; the label is the stable ++# Kubernetes control-plane marker. It matches on the label's presence, not ++# its value: kubespray / kubeadm stamp it with "" while k3s stamps ++# it with "true", and an exact-value nodeSelector schedules no envoy there. ++# Toleration is kept for the case where the taint is still in place. + - name: "envoy_gateway | Create EnvoyProxy config (DaemonSet on control-plane nodes)" + kubernetes.core.k8s: + state: present +@@ -211,8 +212,16 @@ + kubernetes: + envoyDaemonSet: + pod: +- nodeSelector: +- node-role.kubernetes.io/control-plane: "" ++ # null drops the nodeSelector an older install left behind: ++ # this update is a JSON merge patch, which keeps omitted keys. ++ nodeSelector: null ++ affinity: ++ nodeAffinity: ++ requiredDuringSchedulingIgnoredDuringExecution: ++ nodeSelectorTerms: ++ - matchExpressions: ++ - key: node-role.kubernetes.io/control-plane ++ operator: Exists + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists +-- +2.53.0 + diff --git a/integrations/eat/upstream/README.md b/integrations/eat/upstream/README.md index 59f72604..e24abf01 100644 --- a/integrations/eat/upstream/README.md +++ b/integrations/eat/upstream/README.md @@ -8,7 +8,7 @@ These are drafts for Intel's repos. **None of them has been filed, opened or pus - It deletes the toolkit's own installer: `deploy-agentic-stack.sh`, `core/lib/brownfield/`, `core/playbooks/setup-bastion.yml`, `core/playbooks/deploy-istio.yml`, `core/roles/nri_cpu_balloons/`. - It moves the toolkit onto `es_auto_installer.sh install agent-toolkit` in [intel/enterprise-ai-solutions](https://github.com/intel/enterprise-ai-solutions) (EAS) and intel/enterprise-inference. - It depends on intel/enterprise-inference#22 and goes in ahead of intel/enterprise-ai-solutions#40. Both are open. - - Re-read 2026-10-29: #19 merged on 2026-10-27 into the `release-2.0.0` branch, not `main`, and no tag carries it. intel/enterprise-inference#22 merged on 2026-10-22. intel/enterprise-ai-solutions#40 was closed unmerged on 2026-10-22, and intel/enterprise-ai-solutions#66 ("Integrate Agentic Toolkit"), opened a minute before, merged on 2026-10-29. Every patch here still passes `checks/apply-check.sh` on EAS `main` `5767995`. + - Re-read 2026-10-29: #19 merged on 2026-10-27 into the `release-2.0.0` branch, not `main`, and no tag carries it. intel/enterprise-inference#22 merged on 2026-10-22. intel/enterprise-ai-solutions#40 was closed unmerged on 2026-10-22, and intel/enterprise-ai-solutions#66 ("Integrate Agentic Toolkit"), opened a minute before, merged on 2026-10-29. Every patch here still passes `checks/apply-check.sh` on EAS `main` `5767995`, and on `97f6157` (#69), the `main` head later that day. - The platform roles that matter for k3s (istio, envoy_gateway, kubernetes, nri_cpu_balloons) already live in EAS `main`. **EAS is the target for every patch here.** - There is no toolkit (EAT) patch. Its k3s platform-detection gap sits in `brownfield_deployment()`, which no entry point calls: it is sourced at [deploy-agentic-stack.sh#L631](https://github.com/intel/enterprise-agent-toolkit/blob/0ecf67a8331e8b502ef41e08bf6d2881416178c5/deploy-agentic-stack.sh#L631) and called only from inside itself, and #19 deletes the file. The issue draft lists it as an observation only. @@ -50,6 +50,18 @@ The patches carry no `Signed-off-by:`. EAS's CONTRIBUTING.md requires a DCO sign git am --signoff integrations/eat/upstream/patches/0002-eas-istio-global-platform.patch ``` +## The k3d-gpu overlay + +Until upstream carries them, k3d-gpu applies these drafts itself to the EAS checkout it installs from. [integrations/eas/overlay.json](../../eas/overlay.json) lists each patch with its variants, when it is needed (`always`, `eas_owns_nri`, `multi_node`, `never`), a probe for an upstream fix done another way, and the knob the generator writes. [integrations/eas/overlay.py](../../eas/overlay.py) decides per patch at the pinned EAS commit and prints `fact -> choice [source]`: + +- `skip`: not needed on this cluster. +- `drop-merged`: the patch reverse-applies. +- `drop-fixed`: the probe matches and the knob is still there. +- `apply`: the first variant that applies. +- `STOP`, before anything changes: it names the upstream change that moved the hunk and says to regenerate. + +The drafts here stay the only copy. The one overlay-only patch is the release-1.2 form of 0006, [integrations/eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch](../../eas/patches/0006-release-1.2-envoy-control-plane-affinity.patch); on that branch the EnvoyProxy task is still in `install.yaml`. + ## Static validation | Check | Result | @@ -63,11 +75,14 @@ git am --signoff integrations/eat/upstream/patches/0002-eas-istio-global-platfor | 0004 behaviour, [checks/eas-byo-topology.sh](checks/eas-byo-topology.sh) (stub `kubernetes.core`) | upstream: BYO 3 nodes → no labels (FAIL); patched: server `platform`, agents `inference`. BYO with one node already labelled → that node untouched; BYO with node list or patch denied → install succeeds, no labels; managed with patch denied → still fails. BYO 1 node, topology off, managed single- and multi-node all unchanged | | 0006 behaviour | the patched `EnvoyProxy` validates against the v1alpha1 CRD in gateway-helm v1.7.1, which includes `pod.affinity` and does not mark `nodeSelector` nullable | | 0006 re-install, [checks/eas-envoy-reinstall.py](checks/eas-envoy-reinstall.py) | models kubernetes.core 6.3.0 updating a custom resource (JSON merge patch, RFC 7386) over the object the unpatched task created. Without `nodeSelector: null` the old selector survives next to the affinity (FAIL); with it, gone. Fresh install: the null is pruned on create | +| 0006 release-1.2 variant, same script on EAS `release-1.2` `5c27fb3` before and after the variant | fresh install and re-install over the unpatched object both leave `nodeSelector: None` and the `Exists` affinity (2026-10-29). release-1.2 pins `envoy_gateway_version: "1.7.1"`, the gateway-helm the 0006 CRD check used | +| Overlay decisions, `checks/apply-check.sh --overlay` on a fresh clone (2026-10-29) | EAS `main` `97f6157`: 0002 and 0006 apply (0006 variant a), 0003 skips unless the policy is nri-balloons, 0004 skips on one node, 0005 always skips. `release-1.2` `5c27fb3`: the same, with 0006 variant b. Open PRs: #50 and #52 STOP 0006 (the EnvoyProxy provider moves into `_eg_k8s_provider`), #61 STOPs 0002 (`headlamp_enabled` in its `global_config.yaml` context) | How to rerun the checks (an EAS clone; `ansible-core` and `jmespath` for the topology check; PyYAML for the envoy check): ```bash integrations/eat/upstream/checks/apply-check.sh ../enterprise-ai-solutions +integrations/eat/upstream/checks/apply-check.sh --overlay # clones EAS itself; needs the network integrations/eat/upstream/checks/eas-byo-topology.sh integrations/eat/upstream/checks/eas-envoy-reinstall.py ``` diff --git a/integrations/eat/upstream/checks/apply-check.sh b/integrations/eat/upstream/checks/apply-check.sh index cc8429bc..dc9cfa57 100755 --- a/integrations/eat/upstream/checks/apply-check.sh +++ b/integrations/eat/upstream/checks/apply-check.sh @@ -4,16 +4,44 @@ # that the series applies in order with git am. The clone is copied, never # modified. # +# With --overlay it prints the decisions of integrations/eas/overlay.py, the +# overlay `k3d-gpu eas up` applies, against a real EAS clone: every +# overlay.json `verified` case at its pinned sha (fails when a recorded decision +# moved), then every `watch` ref (branch tips and open upstream PRs) for +# k3d-gpu's own facts. Without a clone it clones intel/enterprise-ai-solutions; +# a given clone is copied first. Fetches branches and PR heads, so it needs the +# network and stays out of CI. +# # Usage: integrations/eat/upstream/checks/apply-check.sh +# integrations/eat/upstream/checks/apply-check.sh --overlay [] set -uo pipefail -EAS=${1:?usage: $0 } PATCHES="$(cd "$(dirname "$0")/../patches" && pwd)" +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" WORK=$(mktemp -d) trap 'rm -rf "${WORK}"' EXIT FAILS=0 +if [ "${1:-}" = --overlay ]; then + if [ -n "${2:-}" ]; then + cp -a "$2" "${WORK}/eas" + else + git clone -q https://github.com/intel/enterprise-ai-solutions.git "${WORK}/eas" || exit 2 + fi + git -C "${WORK}/eas" fetch -q origin '+refs/heads/*:refs/remotes/origin/*' \ + '+refs/pull/*/head:refs/remotes/pull/*/head' || exit 2 + echo "## verified pins (integrations/eas/overlay.json)" + python3 "${ROOT}/integrations/eas/overlay.py" --eas "${WORK}/eas" --check-verified || FAILS=$((FAILS + 1)) + echo + echo "## watch refs, k3d-gpu facts" + python3 "${ROOT}/integrations/eas/overlay.py" --eas "${WORK}/eas" --watch || FAILS=$((FAILS + 1)) + [ "${FAILS}" -eq 0 ] + exit +fi + +EAS=${1:?usage: $0 | --overlay []} + # fresh: a throwaway copy with the clone's HEAD checked out clean. fresh() { rm -rf "${WORK:?}/eas" diff --git a/tests/eas_test.sh b/tests/eas_test.sh new file mode 100755 index 00000000..6c27dc3c --- /dev/null +++ b/tests/eas_test.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# Checks for integrations/eas without a cluster. With no argument every section +# runs; name sections to run only those (tests/eas_test.sh overlay). +# +# overlay integrations/eas/overlay.py, the k3s/k3d patch overlay, on synthetic +# git repos built from verbatim EAS files (tests/fixtures/eas/trees, +# EAS main 97f6157 and release-1.2 5c27fb3): +# - the fixtures are byte-identical to upstream (blob shas in SOURCES) +# - overlay.json resolves every patch, upstream drafts first-class, no copies +# - main: 0002 and 0006 apply, 0003 only for nri-balloons, 0004 only on +# more than one node, 0005 never; the recorded `verified` cases hold +# - patches already in the tree: drop-merged (git apply -R --check) +# - the fix done another way with the knob present: drop-fixed +# - the fix done another way with the knob renamed: STOP +# - the hunk moved as open EAS #50/#52 move it: STOP naming #50 +# - release-1.2: 0006 variant b applies, 0006a and 0005 do not +# - --apply changes the checkout only when no row STOPs, and a re-run is a no-op +# - every decision prints as `fact -> choice [source]`; --explain gives permalinks +# Needs bash, git, python3. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PASS=0 +FAILS=0 +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +# check