mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +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.
208 lines
7.0 KiB
Go
208 lines
7.0 KiB
Go
package main
|
|
|
|
// Stage 2: for each failure surfaced by Stage 1, materialise a per-failure bundle
|
|
// directory containing the failure summary, a time-window slice of the
|
|
// daemon journal, a testcase-window slice of the suite's stdout log, and
|
|
// any per-test artifacts the harness already dumped at run-time.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// bundlePad is the per-failure window padding either side of the
|
|
// [StartAt, StartAt+Duration] testcase span. Five seconds matches the
|
|
// plan doc: long enough to catch the daemon's pre-test setup and
|
|
// post-test cleanup chatter, short enough to keep the slice focused.
|
|
const bundlePad = 5 * time.Second
|
|
|
|
// nameSanitiseRe replaces characters unsafe in directory names with `_`.
|
|
// Tracks tests/e2e/harness/artifacts.go which uses the same convention
|
|
// when the test's own dump dir is created, so we can pick its contents
|
|
// up by path without a second translation table.
|
|
var nameSanitiseRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
|
|
|
func sanitiseTestName(s string) string {
|
|
return nameSanitiseRe.ReplaceAllString(s, "_")
|
|
}
|
|
|
|
// ApplySuiteStartFiles overrides each failure's StartAt by reading
|
|
// <logDir>/test-<suiteLabel>.start (RFC3339 timestamp written by the
|
|
// workflow before the suite runs). Required because go-junit-report's
|
|
// `timestamp` attribute on <testsuite> records the time the XML was
|
|
// produced — i.e. after the suite has finished — so it can't be used
|
|
// as the suite wall-clock origin for journal slicing.
|
|
//
|
|
// Missing or unparseable files are silently ignored: the analyzer
|
|
// falls back to the junit timestamp and the bundle still gets created,
|
|
// it just may have an empty (or off-window) journal slice.
|
|
func ApplySuiteStartFiles(rep *Report, logDir string) {
|
|
if logDir == "" {
|
|
return
|
|
}
|
|
for si := range rep.Suites {
|
|
s := &rep.Suites[si]
|
|
startPath := filepath.Join(logDir, fmt.Sprintf("test-%s.start", s.Label))
|
|
data, err := os.ReadFile(startPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ts, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: parse %s: %v\n", startPath, err)
|
|
continue
|
|
}
|
|
if s.Root != nil {
|
|
s.Root.StartAt = ts.Add(s.Root.OffsetFromSuiteStart)
|
|
}
|
|
for ci := range s.Cascades {
|
|
s.Cascades[ci].StartAt = ts.Add(s.Cascades[ci].OffsetFromSuiteStart)
|
|
}
|
|
for bi := range s.Buckets {
|
|
for fi := range s.Buckets[bi] {
|
|
f := &s.Buckets[bi][fi]
|
|
f.StartAt = ts.Add(f.OffsetFromSuiteStart)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// WriteBundles materialises per-failure bundles under <logDir>/analysis/.
|
|
// Mutates rep in-place so each Failure.BundlePath holds the relative
|
|
// path the renderer will link from the summary.
|
|
//
|
|
// Failures with no parseable wall-clock start still get a bundle dir
|
|
// (and the testcase-window test.log slice), but no journal slice — the
|
|
// time window is unknown.
|
|
//
|
|
// Errors are non-fatal: a bundle that partially fails still gets linked
|
|
// from the report so a reader sees what's available, and the analyzer
|
|
// keeps printing warnings so CI logs surface the gap.
|
|
func WriteBundles(rep *Report, logDir string) {
|
|
if logDir == "" {
|
|
return
|
|
}
|
|
base := filepath.Join(logDir, "analysis")
|
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: mkdir %s: %v\n", base, err)
|
|
return
|
|
}
|
|
|
|
for si := range rep.Suites {
|
|
s := &rep.Suites[si]
|
|
if s.Root != nil {
|
|
writeOneBundle(logDir, base, s.Label, s.Root)
|
|
}
|
|
for ci := range s.Cascades {
|
|
writeOneBundle(logDir, base, s.Label, &s.Cascades[ci])
|
|
}
|
|
// Bucket entries share storage with Cascades but live in a
|
|
// separate slice in SuiteReport; mirror the bundle path so the
|
|
// renderer's bucket loop sees it too.
|
|
for bi := range s.Buckets {
|
|
for fi := range s.Buckets[bi] {
|
|
f := &s.Buckets[bi][fi]
|
|
if bp := findBundlePath(s, f.Name); bp != "" {
|
|
f.BundlePath = bp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// findBundlePath looks up a Failure's already-written BundlePath by test
|
|
// name within a suite. Buckets reference the same logical failure as
|
|
// Cascades but are independent slice values, so we copy the path across
|
|
// rather than re-deriving it.
|
|
func findBundlePath(s *SuiteReport, testName string) string {
|
|
if s.Root != nil && s.Root.Name == testName {
|
|
return s.Root.BundlePath
|
|
}
|
|
for _, c := range s.Cascades {
|
|
if c.Name == testName {
|
|
return c.BundlePath
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func writeOneBundle(logDir, base, suiteLabel string, f *Failure) {
|
|
name := fmt.Sprintf("%02d-%s", f.Order+1, sanitiseTestName(f.Name))
|
|
dir := filepath.Join(base, name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: mkdir %s: %v\n", dir, err)
|
|
return
|
|
}
|
|
f.BundlePath = filepath.Join("analysis", name)
|
|
|
|
writeFailureSummary(dir, suiteLabel, f)
|
|
|
|
if !f.StartAt.IsZero() {
|
|
start := f.StartAt.Add(-bundlePad)
|
|
end := f.StartAt.Add(time.Duration(f.Duration * float64(time.Second))).Add(bundlePad)
|
|
journalSrc := filepath.Join(logDir, "spinifex-journal.log")
|
|
if _, err := SliceJournal(journalSrc, start, end, filepath.Join(dir, "journal.log")); err != nil {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: slice journal for %s: %v\n", f.Name, err)
|
|
}
|
|
}
|
|
|
|
testLogSrc := filepath.Join(logDir, fmt.Sprintf("test-%s.log", suiteLabel))
|
|
if _, err := SliceTestLog(testLogSrc, f.Name, filepath.Join(dir, "test.log")); err != nil {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: slice test log for %s: %v\n", f.Name, err)
|
|
}
|
|
|
|
// Per-test artifact subdir written by harness.ArtifactDir lives at
|
|
// <logDir>/<TestName with / replaced by _>/. Copy any files into
|
|
// the bundle so qemu/ovs/etc. dumps already collected by the test
|
|
// travel alongside the analysis.
|
|
perTestSrc := filepath.Join(logDir, strings.ReplaceAll(f.Name, "/", "_"))
|
|
if entries, err := os.ReadDir(perTestSrc); err == nil {
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
_ = copyFile(filepath.Join(perTestSrc, e.Name()), filepath.Join(dir, e.Name()))
|
|
}
|
|
} else if !os.IsNotExist(err) && !errorsIsNotDir(err) {
|
|
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: read per-test dir %s: %v\n", perTestSrc, err)
|
|
}
|
|
}
|
|
|
|
// errorsIsNotDir handles the case where the harness wrote a file at the
|
|
// per-test name instead of a directory — best-effort skip.
|
|
func errorsIsNotDir(err error) bool {
|
|
var pe *fs.PathError
|
|
if !errors.As(err, &pe) {
|
|
return false
|
|
}
|
|
return pe.Err != nil && strings.Contains(pe.Err.Error(), "not a directory")
|
|
}
|
|
|
|
func writeFailureSummary(dir, suiteLabel string, f *Failure) {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Suite: %s\n", suiteLabel)
|
|
fmt.Fprintf(&b, "Test: %s\n", f.Name)
|
|
if !f.StartAt.IsZero() {
|
|
fmt.Fprintf(&b, "Start: %s\n", f.StartAt.Format(time.RFC3339))
|
|
}
|
|
fmt.Fprintf(&b, "Duration: %.3fs\n", f.Duration)
|
|
if f.FileHint != "" {
|
|
fmt.Fprintf(&b, "Site: %s\n", f.FileHint)
|
|
}
|
|
if f.Cascade {
|
|
b.WriteString("Cascade: yes (downstream of another failure)\n")
|
|
}
|
|
if f.Error != "" {
|
|
b.WriteString("\nError:\n")
|
|
b.WriteString(oneLine(f.Error))
|
|
b.WriteString("\n")
|
|
}
|
|
_ = os.WriteFile(filepath.Join(dir, "failure.txt"), []byte(b.String()), 0o644)
|
|
}
|