mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
7bbd99c792
* feat(eks): multinode control-plane networking, IAM, and CoreDNS parity
Integrates the EKS multinode fix set for the managed-control-plane topology
so a 3-worker cluster brings up ingress/ALB and cross-node DNS the way AWS
EKS does. Squashed from feat/eks-workload-lifecycle, rebased onto dev; the
node-IAM piece (4f6ea8cf) is dropped as dev's lazy IAMProvider supersedes it.
EKS:
- Route apiserver egress (exec/logs/webhooks) through konnectivity
(apiserver-network-proxy): CP servers run konnectivity-server behind the
cluster NLB (:8132), workers run konnectivity-agent, so apiserver->pod
traffic rides the agent-initiated reverse tunnel instead of an unroutable
CP->VPC path. Server-count fan-out gives every apiserver a tunnel to every
replica (HA-correct).
- Pin k3s node-ip to the CP-VPC primary on overlay re-home; CP-overlay
flannel mesh SG rule authorized at cluster launch with ordered teardown.
- Pin VPC data NIC MTU to 1320 so flannel fits the OVN underlay.
- Pin the CoreDNS DaemonSet to workers (drop CriticalAddonsOnly + CP/master
tolerations, add anti-control-plane nodeAffinity) for AWS-EKS parity.
- Spread nodegroup workers across hosts; addon readiness falls back on
workload rollout when gvks are absent.
- elbv2: allow the proxy_protocol_v2.enabled target-group attribute.
EBS:
- Move volume attachment state to state.json so a live nbdkit VB cannot
clobber it; route mounted-volume state updates through the live VB with a
retry for in-use marks racing the mount responder; keep the boot volume
in-use across stop/crash unmount.
* fix(eks): gate orchestration on the resolved IAM ensurer, not raw deps.IAM
missingOrchestrationDeps gated on s.deps.IAM, but production wires only the
lazy deps.IAMProvider and leaves deps.IAM nil (set directly by tests only).
The gate therefore reported missing=[IAM] on every node forever, so
CreateCluster/CreateNodegroup were rejected with ServiceUnavailable even
though the IAM service was fully up via the provider.
Gate on s.iamEnsurer() instead — the same resolver (deps.IAM, else
deps.IAMProvider()) the nodegroup/role code already uses — so the protective
check still rejects a node with no IAM service but passes when the provider
supplies one. Add a regression test that sets only IAMProvider and asserts the
gate is satisfied; the existing nil-both test still rejects.
* fix(eks): let eks-token-webhook use IMDS creds when static keys absent
The CP VM launches on an instance profile, so buildK3sUserData omits
EKS_ACCESS_KEY/EKS_SECRET_KEY. loadConfig() rejected absent static keys
before eksgw.New could fall back to the IMDS SDK chain, so the webhook
aborted on boot, never wrote token-webhook.kubeconfig, and the apiserver
crashed on the missing --authentication-token-webhook-config-file file,
stalling the cluster at CREATING until it timed out to FAILED.
Drop the static-key precondition (matching the sibling eks-gateway-publish
and eks-gateway-fetch helpers); keep the genuinely-required gateway/account/
cluster gates.
* fix(eks): stop dhcpcd IPv4LL hijacking IMDS on multi-NIC control plane
The K3s control-plane system VM is dual-NIC (data ENI + mgmt NIC on br-mgmt,
which has no DHCP server). dhcpcd's no-lease IPv4LL fallback assigned a
169.254.x.x address and a 169.254.0.0/16 route on the mgmt NIC, capturing
169.254.169.254 and steering IMDS off the data NIC away from the per-tap
br-imds datapath, where the host RST it. eks-gateway-publish then failed to
sign requests and no bootstrap artifacts were ever published, so clusters sat
CREATING until they timed out to FAILED.
Bake noipv4ll into /etc/dhcpcd.conf so dhcpcd never assigns an IPv4LL address
or /16 route on a NIC that fails DHCP; real DHCP leases (workers, data ENI)
are unaffected and IMDS falls through the data NIC's default route to the
br-imds demux, matching the single-NIC path. Defense in depth: mulga-mgmt-net
deletes any default route on a NIC declared NIC<n>_DEFAULT=0.
* fix(eks): grant eks:WebhookTokenReview to the control-plane instance role
The eks-token-webhook relays `aws eks get-token` bearer tokens to the gateway
broker at POST /clusters/{name}/token-review, signing with the control-plane
VM's IMDS instance-role creds (spinifex-eks-server). That route authorizes
against eks:WebhookTokenReview, but the role's inline policy granted only
eks:PublishInternal and eks:ListInternalAddons, so the gateway 403'd every
relay and the apiserver returned 401 to all external kubectl.
Add eks:WebhookTokenReview to eksServerInlinePolicy. EnsureSystemInstanceProfile
re-asserts the inline policy on every control-plane instance-profile ensure, and
the gateway evaluates authorization per request, so existing clusters converge
without a rebuild once the shared role is updated.
311 lines
9.3 KiB
Go
311 lines
9.3 KiB
Go
// Package selectsuites derives the set of e2e suites a diff must exercise.
|
|
//
|
|
// Inputs: the service-interface manifest (docs/service-interfaces.yaml) and a
|
|
// list of repo-root-relative changed paths. Output: the suites to run, plus an
|
|
// optional per-suite -test.run regex (RUN_PATTERN) when AST analysis can prove
|
|
// a strict subtest subset is sufficient.
|
|
//
|
|
// Selection model:
|
|
//
|
|
// changed_services = { s | s.path (or additional_paths) prefixes a changed file }
|
|
// covered_services = changed_services plus the transitive closure of
|
|
// *consumers*: x is covered if x.depends_on overlaps the
|
|
// publishes set of an already-covered service.
|
|
// must_run_suites = { suite | suite.covers ∩ covered_services ≠ ∅ }
|
|
//
|
|
// Several special cases short-circuit to "run all" (infra/harness/workflow
|
|
// changes, ≥N services touched, submodule bumps) or to a single suite (a diff
|
|
// confined to one suite's own test dir). An empty result falls back to the
|
|
// default suites rather than skipping e2e entirely.
|
|
package selectsuites
|
|
|
|
import (
|
|
"maps"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
manifestcheck "github.com/mulgadc/spinifex/tests/e2e/manifest-check"
|
|
)
|
|
|
|
// Manifest is re-exported from manifestcheck so callers import one package.
|
|
type Manifest = manifestcheck.Manifest
|
|
|
|
// Result is the selector's output. AllSuites is true when a special case
|
|
// forced the full matrix; Suites then lists every manifest suite. RunPatterns
|
|
// maps a suite name to a -test.run regex when a strict subtest subset suffices;
|
|
// suites absent from the map run in full.
|
|
type Result struct {
|
|
Suites []string
|
|
RunPatterns map[string]string
|
|
AllSuites bool
|
|
Reason string
|
|
}
|
|
|
|
// Config carries the hard-coded special-case rules. Defaults mirror the
|
|
// commented rules at the foot of service-interfaces.yaml; exposed as a struct
|
|
// so tests can pin them without depending on repo layout.
|
|
type Config struct {
|
|
// InfraGlobs force "run all" when any changed file matches. Globs use
|
|
// path.Match semantics per segment, with a trailing "/**" meaning
|
|
// "this dir and everything under it".
|
|
InfraGlobs []string
|
|
// SubmodulePaths force "run all" when a changed file is at or under one
|
|
// (a submodule pointer bump can hide a contract change).
|
|
SubmodulePaths []string
|
|
// ManyServicesThreshold: ≥ this many distinct changed services → run all.
|
|
ManyServicesThreshold int
|
|
// DefaultSuites run when selection is otherwise empty.
|
|
DefaultSuites []string
|
|
}
|
|
|
|
// DefaultConfig returns the production special-case rules.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
InfraGlobs: []string{
|
|
"spinifex/services/nats/**",
|
|
"tests/e2e/harness/**",
|
|
"tests/e2e/lib/**",
|
|
"tests/e2e/Makefile",
|
|
"Makefile",
|
|
".github/workflows/e2e.yml",
|
|
".github/scripts/**",
|
|
},
|
|
SubmodulePaths: []string{
|
|
"predastore",
|
|
"viperblock",
|
|
},
|
|
ManyServicesThreshold: 3,
|
|
DefaultSuites: []string{"e2e-cert", "e2e-lb"},
|
|
}
|
|
}
|
|
|
|
// Select computes the suites to run for changedFiles. changedFiles are
|
|
// repo-root-relative (as `git diff --name-only` emits inside the spinifex
|
|
// repo). runPatterns is an optional suite→(testName set) map from AST analysis;
|
|
// pass nil to skip subtest narrowing.
|
|
func Select(m *Manifest, cfg Config, changedFiles []string, perTest map[string]SuiteCoverage) Result {
|
|
all := func(reason string) Result {
|
|
names := suiteNames(m)
|
|
return Result{Suites: names, AllSuites: true, Reason: reason}
|
|
}
|
|
|
|
if len(changedFiles) == 0 {
|
|
return Result{Suites: append([]string(nil), cfg.DefaultSuites...), Reason: "empty diff → default suites"}
|
|
}
|
|
|
|
// 1. Force-all special cases take precedence over everything.
|
|
for _, f := range changedFiles {
|
|
if g := matchInfra(f, cfg.InfraGlobs); g != "" {
|
|
return all("infra path changed: " + f + " (" + g + ")")
|
|
}
|
|
if s := matchSubmodule(f, cfg.SubmodulePaths); s != "" {
|
|
return all("submodule pointer changed: " + s)
|
|
}
|
|
}
|
|
|
|
// 2. A diff confined to a single suite's own test dir runs only that suite.
|
|
if name, ok := loneSuiteDir(m, changedFiles); ok {
|
|
return Result{Suites: []string{name}, Reason: "diff confined to suite dir " + name}
|
|
}
|
|
|
|
// 3. Map changed files to changed services, then take the consumer closure.
|
|
changedSvc := changedServices(m, changedFiles)
|
|
if len(changedSvc) >= cfg.ManyServicesThreshold {
|
|
return all("≥3 services touched")
|
|
}
|
|
covered := consumerClosure(m, changedSvc)
|
|
|
|
// 4. Suites whose covers intersect the covered service set.
|
|
suites := suitesForServices(m, covered)
|
|
if len(suites) == 0 {
|
|
return Result{Suites: append([]string(nil), cfg.DefaultSuites...), Reason: "no suite matched → default suites"}
|
|
}
|
|
|
|
res := Result{Suites: suites, Reason: "service-mapped: " + strings.Join(sortedKeys(covered), ",")}
|
|
|
|
// 5. Best-effort subtest narrowing: for each selected suite with per-test
|
|
// coverage data, keep only the tests touching a covered service. Emit a
|
|
// RUN_PATTERN only when that is a strict, non-empty subset.
|
|
if len(perTest) > 0 {
|
|
res.RunPatterns = runPatterns(perTest, suites, covered)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// changedServices returns the set of service names whose path or
|
|
// additional_paths prefix at least one changed file.
|
|
func changedServices(m *Manifest, changed []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for name, svc := range m.Services {
|
|
for _, p := range append([]string{svc.Path}, svc.AdditionalPaths...) {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
for _, f := range changed {
|
|
if underDir(f, p) {
|
|
out[name] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// consumerClosure grows seed with every service that depends_on a subject an
|
|
// already-covered service handles — whether that service *publishes* the
|
|
// subject (produces data the dependant consumes) or *subscribes* to it (handles
|
|
// requests the dependant sends). Both mean "if the covered service changes, the
|
|
// dependant may break". Iterates to a fixpoint.
|
|
func consumerClosure(m *Manifest, seed map[string]bool) map[string]bool {
|
|
covered := map[string]bool{}
|
|
for k := range seed {
|
|
covered[k] = true
|
|
}
|
|
for {
|
|
added := false
|
|
for name, svc := range m.Services {
|
|
if covered[name] {
|
|
continue
|
|
}
|
|
for cname := range covered {
|
|
c := m.Services[cname]
|
|
if subjectsOverlap(svc.DependsOn, c.Publishes) ||
|
|
subjectsOverlap(svc.DependsOn, c.Subscribes) {
|
|
covered[name] = true
|
|
added = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !added {
|
|
return covered
|
|
}
|
|
}
|
|
}
|
|
|
|
// suitesForServices returns the sorted suite names whose covers list intersects
|
|
// the covered service set. A suite with covers [all] always matches.
|
|
func suitesForServices(m *Manifest, covered map[string]bool) []string {
|
|
var out []string
|
|
for name, s := range m.Suites {
|
|
match := false
|
|
for _, c := range s.Covers {
|
|
if c == "all" || covered[c] {
|
|
match = true
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// loneSuiteDir reports the suite when every changed file lives under exactly
|
|
// one suite's path and that path is not a shared dir (harness/lib are infra,
|
|
// handled earlier).
|
|
func loneSuiteDir(m *Manifest, changed []string) (string, bool) {
|
|
hit := ""
|
|
for _, f := range changed {
|
|
owner := ""
|
|
for name, s := range m.Suites {
|
|
if s.Path != "" && underDir(f, s.Path) {
|
|
owner = name
|
|
break
|
|
}
|
|
}
|
|
if owner == "" {
|
|
return "", false // a non-suite file is in the diff
|
|
}
|
|
if hit == "" {
|
|
hit = owner
|
|
} else if hit != owner {
|
|
return "", false // spans ≥2 suites
|
|
}
|
|
}
|
|
return hit, hit != ""
|
|
}
|
|
|
|
func matchInfra(f string, globs []string) string {
|
|
for _, g := range globs {
|
|
if globMatch(g, f) {
|
|
return g
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func matchSubmodule(f string, subs []string) string {
|
|
for _, s := range subs {
|
|
if f == s || underDir(f, s) {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// underDir reports whether file f is dir itself or sits beneath it. Both are
|
|
// slash-separated repo-relative paths.
|
|
func underDir(f, dir string) bool {
|
|
dir = strings.TrimSuffix(dir, "/")
|
|
return f == dir || strings.HasPrefix(f, dir+"/")
|
|
}
|
|
|
|
// globMatch supports a trailing "/**" (this dir and all descendants) plus
|
|
// per-segment path.Match for the rest. Without "/**" it is an exact or
|
|
// path.Match comparison.
|
|
func globMatch(pattern, f string) bool {
|
|
if before, ok := strings.CutSuffix(pattern, "/**"); ok {
|
|
return underDir(f, before)
|
|
}
|
|
if ok, _ := path.Match(pattern, f); ok {
|
|
return true
|
|
}
|
|
return pattern == f
|
|
}
|
|
|
|
// subjectsOverlap reports whether any subject in a matches any in b under
|
|
// trailing-wildcard semantics (either side may end in ".*").
|
|
func subjectsOverlap(a, b []string) bool {
|
|
for _, x := range a {
|
|
for _, y := range b {
|
|
if subjectMatch(x, y) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// subjectMatch compares two subjects, each optionally ending ".*". A wildcard
|
|
// matches anything sharing its prefix; two wildcards match if one prefix
|
|
// contains the other.
|
|
func subjectMatch(x, y string) bool {
|
|
xw := strings.HasSuffix(x, ".*")
|
|
yw := strings.HasSuffix(y, ".*")
|
|
xp := strings.TrimSuffix(x, ".*")
|
|
yp := strings.TrimSuffix(y, ".*")
|
|
switch {
|
|
case xw && yw:
|
|
return xp == yp || strings.HasPrefix(xp, yp+".") || strings.HasPrefix(yp, xp+".")
|
|
case xw:
|
|
return y == xp || strings.HasPrefix(y, xp+".")
|
|
case yw:
|
|
return x == yp || strings.HasPrefix(x, yp+".")
|
|
default:
|
|
return x == y
|
|
}
|
|
}
|
|
|
|
func suiteNames(m *Manifest) []string {
|
|
return slices.Sorted(maps.Keys(m.Suites))
|
|
}
|
|
|
|
func sortedKeys(s map[string]bool) []string {
|
|
return slices.Sorted(maps.Keys(s))
|
|
}
|