Compare commits
305 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 020344ec46 | |||
| c92294e7d5 | |||
| 203b805cf0 | |||
| 8006cc2f17 | |||
| d40747acae | |||
| fbeb2133c3 | |||
| 197ae5ccf1 | |||
| 1394fbf488 | |||
| a7a62addeb | |||
| 0a22e86304 | |||
| 89df817601 | |||
| 588162c136 | |||
| 94db344556 | |||
| 96dc1af568 | |||
| c5995e8c69 | |||
| 57d3db1683 | |||
| 89916e841f | |||
| 3c5840a4b6 | |||
| 6a449d7ddd | |||
| 7dd3961e27 | |||
| 2f0eb734ba | |||
| 7440446bbb | |||
| 5301af4c7b | |||
| e68c965ef8 | |||
| 19024f79a4 | |||
| 835dbbaf1c | |||
| e710fdc1d0 | |||
| 7be9839c83 | |||
| f6ef90a605 | |||
| 9b2334b964 | |||
| 46143be964 | |||
| f243061e17 | |||
| 430c02de88 | |||
| cfaa7e04eb | |||
| 26afcfa77e | |||
| 3601bb4d36 | |||
| 53b31cb76c | |||
| 7b33219617 | |||
| ff4f46dcb5 | |||
| 92dae2e449 | |||
| d178ef5992 | |||
| bc3d59c0c3 | |||
| c0f3e0bee2 | |||
| c7fdd23b7e | |||
| bc1dd3413e | |||
| 35ccb298df | |||
| f7d61f8ce1 | |||
| fa070a5359 | |||
| 2cbf50adf7 | |||
| f5adfb1c79 | |||
| 0a1d837f40 | |||
| 3379b16586 | |||
| 272b4b22e8 | |||
| 1c4d41bffc | |||
| 456a60717b | |||
| e3b5701f15 | |||
| 8c07f80032 | |||
| 688ce5ccfd | |||
| 506a8126b7 | |||
| 5dd3af70eb | |||
| 390afa2490 | |||
| 2a79bc8473 | |||
| f7d5d97e3f | |||
| df34f27cdf | |||
| 68671cdf19 | |||
| f240c33778 | |||
| e5f528f877 | |||
| 7f349a8e3d | |||
| 710169e5a6 | |||
| d2d0b357a9 | |||
| 47e6b824ad | |||
| 54434dc22e | |||
| 58b1731bfd | |||
| fcb211cfd9 | |||
| b37708be09 | |||
| 3193933ce8 | |||
| 4b9039c76f | |||
| b977e752e0 | |||
| f49caedd31 | |||
| 8765077f4f | |||
| d8432e99a2 | |||
| 871513dc6c | |||
| ef8bdce9e9 | |||
| b4701c78de | |||
| 317f714355 | |||
| d9907fbf13 | |||
| 615578a2e7 | |||
| 2e09461080 | |||
| 43975b01e6 | |||
| 49c5a04bed | |||
| 050dbdb060 | |||
| 970bf18ab5 | |||
| 12a1e4aedc | |||
| 5a3f5e60b2 | |||
| 14e24243d8 | |||
| 8cf44f3310 | |||
| faa0519cf1 | |||
| 175b8b93d9 | |||
| 9dfef26e48 | |||
| b76aab8b50 | |||
| f3586ed32c | |||
| 6147ca9541 | |||
| 94ca4dd72c | |||
| 46472082f9 | |||
| b67d8878c1 | |||
| f3dd3c251e | |||
| 8be25c93f9 | |||
| da5e38cf18 | |||
| b5fa0ffb7a | |||
| 6605d049bc | |||
| 53960e0bc4 | |||
| 5e17169661 | |||
| ad8b2e5aad | |||
| e6ac1ad9b5 | |||
| e556e622dd | |||
| 9bbc492e5a | |||
| 0638ff8c47 | |||
| 55b496cf9a | |||
| 25081fa349 | |||
| 576742dfeb | |||
| 42c7b9b663 | |||
| eef5b0784f | |||
| 261d5d505e | |||
| 68a3518bf0 | |||
| f77a7e5976 | |||
| c2b80a9b93 | |||
| 5ef1954dd9 | |||
| a2c451128d | |||
| b9898a7e88 | |||
| 6357ea70af | |||
| bda08e1afd | |||
| 972f5837ea | |||
| 3e1f9b1198 | |||
| 8ae8d2fec4 | |||
| bbf1ca7f0b | |||
| 5de598e85a | |||
| 8ec739d3f1 | |||
| 031f0a4cdd | |||
| 22171c44ab | |||
| 2011037d3d | |||
| ae06234009 | |||
| 6b8e3d870e | |||
| dee7673e27 | |||
| a4274f95b5 | |||
| 9a1fb44065 | |||
| 4175248386 | |||
| 57a93fdee3 | |||
| 34aa6d2230 | |||
| dad5d702e1 | |||
| a3fb4cccec | |||
| 9c9bb9430c | |||
| 7d5ebabecc | |||
| 99323672bc | |||
| 59db7d1e52 | |||
| 9f5f877e50 | |||
| 614ab09866 | |||
| 044d7af85e | |||
| 6873875248 | |||
| 6ce8b9ff18 | |||
| d89ac24af2 | |||
| ccc726e0ba | |||
| ee0bf68d71 | |||
| 135da2371e | |||
| bca63a4ede | |||
| 0628b5f20d | |||
| 82d6b2044c | |||
| d455f3e569 | |||
| a104f73306 | |||
| d38db57912 | |||
| 67a8b4409f | |||
| e4cd9ed527 | |||
| 59b19192c4 | |||
| 70dda56fc3 | |||
| d9fb17155f | |||
| 168b8eb83e | |||
| 4a87f19ca0 | |||
| 034c786916 | |||
| 2983160374 | |||
| df6f797971 | |||
| b6d43d8b51 | |||
| 545ca94a8e | |||
| ff46314872 | |||
| 0cdf591c7c | |||
| 5fc6a137ec | |||
| f973b306c4 | |||
| 628fbbc0c3 | |||
| e7f4ee46b0 | |||
| eff11b611b | |||
| 3e195506d3 | |||
| 2db1034436 | |||
| edf2113f54 | |||
| 2638a792c5 | |||
| d2eb3b11dd | |||
| d4d9db9e0b | |||
| a0659ae7d0 | |||
| 53c0671512 | |||
| 23de2ad85d | |||
| 7d2106bfb5 | |||
| fc6e055ca0 | |||
| 6eb76b439b | |||
| 51ecddbf81 | |||
| 27aa2394b3 | |||
| 2569601e3d | |||
| 32d80b473a | |||
| 6cf9ee9d03 | |||
| 84418a218c | |||
| a1331b3785 | |||
| 341f36a432 | |||
| 5800f39e42 | |||
| 7e356b5b9b | |||
| 331e46951f | |||
| 6ba9e11461 | |||
| 1d1b1f6a51 | |||
| 5b8f20b89f | |||
| 6389b37433 | |||
| 0708ac4816 | |||
| 9cec5a50c6 | |||
| d96bc3c095 | |||
| 04c7efde19 | |||
| 95973eeb3c | |||
| e79ca59d4b | |||
| 61319ba4a4 | |||
| 6e13a9d9f4 | |||
| 12085f029c | |||
| 4803469037 | |||
| eae776b2f8 | |||
| f50f69caff | |||
| f682d09a72 | |||
| 1d7d5e1750 | |||
| e26a480e54 | |||
| b505887b36 | |||
| 1d55404a73 | |||
| 5001a04b6a | |||
| f595129c7a | |||
| a36c6d819e | |||
| 528bb1e483 | |||
| 8989fc9ba8 | |||
| a55544b7d4 | |||
| f9c9f0b38d | |||
| d6b356b658 | |||
| e0259b7638 | |||
| 88b57b1805 | |||
| 318e43c66f | |||
| 7e8cbeef6e | |||
| d2efbee043 | |||
| 344a490a70 | |||
| 62e7f983ee | |||
| 68abc0bfb3 | |||
| cae76ba1b6 | |||
| c054b0213e | |||
| a3885db916 | |||
| 369c237f01 | |||
| 2119981924 | |||
| 5fd74a8212 | |||
| 291e550e8a | |||
| 826a441beb | |||
| 2e7136c893 | |||
| 4793b5ac09 | |||
| 3e0aab174a | |||
| 10ce0fba8f | |||
| 77c0e337a6 | |||
| 4274184ba4 | |||
| 220141b145 | |||
| 4670d09d58 | |||
| 48749576c3 | |||
| 8c8dc173bd | |||
| f02d9a6c02 | |||
| 5dc945a6df | |||
| a01e58d90c | |||
| 08fc5e4c58 | |||
| ffc34b735b | |||
| 055fa26f37 | |||
| 8205f88b04 | |||
| 908bfbfc27 | |||
| 95631bf57b | |||
| 9ccf147fc8 | |||
| 501f787c36 | |||
| 5b03cf72c4 | |||
| 72353e476c | |||
| 3b44d3969e | |||
| 28a876d17d | |||
| 0c6a3c5b3a | |||
| 8d796d74ba | |||
| bc9dc36c6c | |||
| 7a6b8908c0 | |||
| 3b379fea38 | |||
| 73eeb87751 | |||
| 4cbd2a5c88 | |||
| b5198d55e2 | |||
| bf21e92caf | |||
| ac5bd613b4 | |||
| 03d981779b | |||
| d46d257278 | |||
| f94a52eb80 | |||
| 6e043f6453 | |||
| 30d4745b6a | |||
| 3b7b781bdf | |||
| 92b775951b | |||
| 0a8e6bca15 | |||
| d3ac8c0656 | |||
| 1281381096 | |||
| b41a193cbc | |||
| 53f636692d | |||
| b40d538c61 | |||
| b147e2b869 |
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Only enforce preflight on dev and main
|
||||
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run preflight if Go files were staged
|
||||
if git diff --cached --name-only | grep -q '\.go$'; then
|
||||
echo "Go files staged on $branch — running make preflight..."
|
||||
make preflight
|
||||
else
|
||||
echo "No Go files staged, skipping preflight."
|
||||
fi
|
||||
@@ -70,7 +70,7 @@ When the report format changes intentionally, regenerate the snapshots:
|
||||
go test ./.github/actions/e2e-analyze/ -update
|
||||
```
|
||||
|
||||
## Per-failure bundles (Stage 2)
|
||||
## Per-failure bundles
|
||||
|
||||
After parsing, the action materialises a bundle directory per failure
|
||||
under `<log-dir>/analysis/NN-<TestName>/`. The report links to it from
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package main implements the e2e-analyze GitHub Action.
|
||||
//
|
||||
// Stage 1: cluster failures from go-junit-report's junit-*.xml by error signature,
|
||||
// Cluster failures from go-junit-report's junit-*.xml by error signature,
|
||||
// pick the earliest non-cascade failure per suite as the likely root cause,
|
||||
// and render a human-readable report.
|
||||
//
|
||||
@@ -73,8 +73,14 @@ type Failure struct {
|
||||
// `test-<suite>.start` file written by the workflow before the suite
|
||||
// runs) can recompute StartAt without re-parsing the XML.
|
||||
OffsetFromSuiteStart time.Duration
|
||||
// SuiteSpan is the suite's own wall-clock span, set only when the suite
|
||||
// ran its tests in parallel. Overlapping tests make the cumulative
|
||||
// offset above pure fiction — it lands minutes past the real failure —
|
||||
// so consumers widen the journal window to the suite instead of
|
||||
// slicing a plausible-looking window around the wrong minute.
|
||||
SuiteSpan time.Duration
|
||||
// BundlePath is the relative path (from log-dir) to the per-failure
|
||||
// bundle written by Stage 2's WriteBundles. Empty when bundles were
|
||||
// bundle written by WriteBundles. Empty when bundles were
|
||||
// not generated (e.g. unit tests that call Render directly).
|
||||
BundlePath string
|
||||
}
|
||||
@@ -88,6 +94,12 @@ type SuiteReport struct {
|
||||
Root *Failure // nil if no failures
|
||||
Cascades []Failure // everything else that failed in this suite
|
||||
Buckets [][]Failure // failures grouped by signature (root's bucket first)
|
||||
// EndedAt is go-junit-report's <testsuite timestamp>, which records when
|
||||
// the XML was produced — i.e. when the suite finished, not when it began.
|
||||
EndedAt time.Time
|
||||
// TopLevelTotal is the summed duration of the parentless tests. Longer than
|
||||
// the suite's wall clock means they overlapped.
|
||||
TopLevelTotal time.Duration
|
||||
}
|
||||
|
||||
// Report is the full analysis across every junit file.
|
||||
@@ -119,6 +131,28 @@ var cascadeMarkers = []string{
|
||||
"Expected value not to be nil",
|
||||
}
|
||||
|
||||
// teardownPatterns match the content a test logs from t.Cleanup or from the
|
||||
// harness's on-failure diagnostics — i.e. after it has already failed. They are
|
||||
// the LAST file:line lines in the failure body, so extractErrorLine's
|
||||
// keep-the-last rule picks them over the assertion unless they are skipped.
|
||||
// Anchored deliberately: a fragment loose enough to match an assertion would
|
||||
// blank out the one line the report exists to show.
|
||||
var teardownPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^DB instance \S+ is gone$`),
|
||||
regexp.MustCompile(`^db diagnostics \S+:`),
|
||||
}
|
||||
|
||||
// isTeardownLine reports whether a candidate error line was emitted after the
|
||||
// failure it would otherwise be mistaken for.
|
||||
func isTeardownLine(content string) bool {
|
||||
for _, re := range teardownPatterns {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// noiseReplacers normalise dynamic identifiers so that "ten failures, same
|
||||
// bug" bucket together. Pattern ordering matters: longer/more-specific
|
||||
// patterns must precede the catch-alls.
|
||||
@@ -206,11 +240,14 @@ func extractErrorLine(body string) string {
|
||||
if m := goFileLineRe.FindStringSubmatch(l); m != nil {
|
||||
// Keep the LAST occurrence: t.Log / harness traces emit
|
||||
// progress lines from the same file:line pattern before the
|
||||
// failing assertion finally fires.
|
||||
goFileLine = strings.TrimSpace(m[1])
|
||||
// failing assertion finally fires. Cleanup runs after it, though,
|
||||
// so its lines are last of all and have to be skipped.
|
||||
if content := strings.TrimSpace(m[1]); !isTeardownLine(content) {
|
||||
goFileLine = content
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fileHintRe.MatchString(l) {
|
||||
if fileHintRe.MatchString(l) && !isTeardownLine(l) {
|
||||
lastTestLine = l
|
||||
}
|
||||
}
|
||||
@@ -254,11 +291,19 @@ func extractFileHint(body string) string {
|
||||
if m := errorTraceRe.FindStringSubmatch(body); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
all := fileHintRe.FindAllString(body, -1)
|
||||
if len(all) == 0 {
|
||||
return ""
|
||||
// Scanned line by line rather than across the body so a cleanup line's own
|
||||
// file:line does not become the site the report points a reader at.
|
||||
hint := ""
|
||||
for _, raw := range strings.Split(body, "\n") {
|
||||
l := strings.TrimSpace(raw)
|
||||
if m := goFileLineRe.FindStringSubmatch(l); m != nil && isTeardownLine(strings.TrimSpace(m[1])) {
|
||||
continue
|
||||
}
|
||||
if all := fileHintRe.FindAllString(l, -1); len(all) > 0 {
|
||||
hint = all[len(all)-1]
|
||||
}
|
||||
}
|
||||
return all[len(all)-1]
|
||||
return hint
|
||||
}
|
||||
|
||||
// signature normalises an error line so different runs of the same root
|
||||
@@ -313,6 +358,47 @@ func computeLeafSet(cases []junitTC) map[string]bool {
|
||||
return leaf
|
||||
}
|
||||
|
||||
// computeRolledUpSet returns the testcase names whose failure is already
|
||||
// reported by a failing descendant. go-junit-report gives such a parent a
|
||||
// testcase of its own, and its body is the parent's t.Log output rather than an
|
||||
// assertion — a cleanup line like "DB instance … is gone", which passing tests
|
||||
// emit too, reads to extractErrorLine exactly like a failure site.
|
||||
func computeRolledUpSet(cases []junitTC) map[string]bool {
|
||||
rolledUp := make(map[string]bool, len(cases))
|
||||
for _, tc := range cases {
|
||||
if tc.Failure == nil && tc.Error == nil {
|
||||
continue
|
||||
}
|
||||
// Mark every ancestor: the failure belongs to the deepest case that
|
||||
// carries it, not to the tests it is nested under.
|
||||
name := tc.Name
|
||||
for {
|
||||
i := strings.LastIndex(name, "/")
|
||||
if i <= 0 {
|
||||
break
|
||||
}
|
||||
name = name[:i]
|
||||
rolledUp[name] = true
|
||||
}
|
||||
}
|
||||
return rolledUp
|
||||
}
|
||||
|
||||
// topLevelTotal sums the durations of the tests with no parent, which are the
|
||||
// units t.Parallel overlaps. Compared against the suite's wall clock it says
|
||||
// whether the suite ran its tests concurrently — see ApplySuiteStartFiles.
|
||||
// suite.Time cannot answer that: go-junit-report sets it to the sum of every
|
||||
// testcase, parents included, so it is never less than any subset of itself.
|
||||
func topLevelTotal(cases []junitTC) time.Duration {
|
||||
total := 0.0
|
||||
for _, tc := range cases {
|
||||
if !strings.Contains(tc.Name, "/") {
|
||||
total += tc.Time
|
||||
}
|
||||
}
|
||||
return time.Duration(total * float64(time.Second))
|
||||
}
|
||||
|
||||
// parseStartTime turns a junit timestamp attr (RFC3339) into a time.Time;
|
||||
// zero value if unparseable.
|
||||
func parseStartTime(s string) time.Time {
|
||||
@@ -359,6 +445,9 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
|
||||
// summing every testcase double-counts. Pre-compute the leaf set so
|
||||
// only leaves contribute to the suite's running offset.
|
||||
isLeaf := computeLeafSet(suite.Cases)
|
||||
rolledUp := computeRolledUpSet(suite.Cases)
|
||||
rep.EndedAt = start
|
||||
rep.TopLevelTotal += topLevelTotal(suite.Cases)
|
||||
cumul := 0.0
|
||||
for _, tc := range suite.Cases {
|
||||
rep.Total++
|
||||
@@ -376,11 +465,12 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip the synthetic parent-test "Failed" stub that go-junit-report
|
||||
// emits for any Go test whose subtests failed. It has an empty body
|
||||
// and would otherwise outrank real failures by XML order.
|
||||
// Skip the parent-test entry go-junit-report emits for any Go test
|
||||
// whose subtests failed: it is a rollup of failures already counted
|
||||
// below it, and it would outrank them by XML order. An empty body is
|
||||
// the same stub in the case where the parent logged nothing.
|
||||
trimmed := strings.TrimSpace(stripANSI(body))
|
||||
if trimmed == "" {
|
||||
if trimmed == "" || rolledUp[tc.Name] {
|
||||
if leaf {
|
||||
cumul += tc.Time
|
||||
}
|
||||
@@ -462,14 +552,24 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// Render writes the markdown report (Stage 1).
|
||||
// Render writes the markdown report.
|
||||
func Render(r Report) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## %s\n\n", r.Title)
|
||||
|
||||
totalFail := 0
|
||||
totalFail, totalTests := 0, 0
|
||||
for _, s := range r.Suites {
|
||||
totalFail += s.FailCount
|
||||
totalTests += s.Total
|
||||
}
|
||||
|
||||
// A cell that dies before the suites run emits junit with no testcases at
|
||||
// all. Reporting that as clean turns "nothing ran" into "nothing wrong", so
|
||||
// name it and point above the suites.
|
||||
if totalTests == 0 {
|
||||
b.WriteString("⚠️ No tests ran — no testcases in any junit file. " +
|
||||
"The failure is upstream of the suites (provisioning, bootstrap, or reimage); check the job log.\n")
|
||||
return b.String()
|
||||
}
|
||||
if totalFail == 0 {
|
||||
b.WriteString("✅ No failures across any suite.\n")
|
||||
@@ -484,12 +584,22 @@ func Render(r Report) string {
|
||||
fmt.Fprintf(&b, "### Suite `%s`: %d failed, 1 root cause likely\n\n", s.Label, s.FailCount)
|
||||
|
||||
if s.Root != nil {
|
||||
b.WriteString("**Root cause (earliest non-cascade)**\n\n")
|
||||
// XML order is completion order, so "earliest" only means earliest
|
||||
// in time for a suite whose tests ran one after another.
|
||||
if s.Root.SuiteSpan > 0 {
|
||||
b.WriteString("**First non-cascade failure** (the suite ran in parallel, so this is report order, not time order)\n\n")
|
||||
} else {
|
||||
b.WriteString("**Root cause (earliest non-cascade)**\n\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "- Test: `%s`\n", s.Root.Name)
|
||||
if !s.Root.StartAt.IsZero() {
|
||||
switch {
|
||||
case s.Root.SuiteSpan > 0:
|
||||
fmt.Fprintf(&b, "- Start: unknown, the suite ran in parallel (duration %.1fs)\n",
|
||||
s.Root.Duration)
|
||||
case !s.Root.StartAt.IsZero():
|
||||
fmt.Fprintf(&b, "- Start: %s (duration %.1fs)\n",
|
||||
s.Root.StartAt.Format("15:04:05"), s.Root.Duration)
|
||||
} else {
|
||||
default:
|
||||
fmt.Fprintf(&b, "- Duration: %.1fs\n", s.Root.Duration)
|
||||
}
|
||||
if s.Root.Error != "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// updateGolden lets `go test ./.github/actions/e2e-analyze/ -update` rewrite
|
||||
@@ -124,6 +125,22 @@ func TestExtractErrorLine_FallsBackToLastTestLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A test's cleanup logs after it has failed, so its lines are the last in the
|
||||
// body — and the cleanup line here is one passing tests emit too.
|
||||
func TestExtractErrorLine_SkipsCleanupLoggedAfterTheFailure(t *testing.T) {
|
||||
body := ` failure_test.go:42: DB instance rds-e2e-failure-1 reached status available
|
||||
failure_test.go:60: EventuallyErr: condition not met within 4m0s: rds-e2e-failure-1 status=available want=failed
|
||||
rds.go:364: db diagnostics rds-e2e-failure-1: no VM to capture a console from (<nil>)
|
||||
main_test.go:325: DB instance rds-e2e-failure-1 is gone`
|
||||
got := extractErrorLine(body)
|
||||
if !strings.HasPrefix(got, "EventuallyErr:") {
|
||||
t.Errorf("extractErrorLine = %q, want the assertion at failure_test.go:60", got)
|
||||
}
|
||||
if hint := extractFileHint(body); hint != "failure_test.go:60" {
|
||||
t.Errorf("extractFileHint = %q, want failure_test.go:60", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFileHint_PicksLastMatch(t *testing.T) {
|
||||
body := "ec2helpers.go:50: setup\n vpc_test.go:227: Eventually: condition not met"
|
||||
got := extractFileHint(body)
|
||||
@@ -157,6 +174,116 @@ func TestParseFile_SkipsEmptyParentFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_SkipsParentRolledUpBySubtests(t *testing.T) {
|
||||
// A parent that logs during cleanup gets a non-empty body, and a t.Log like
|
||||
// "DB instance … is gone" — which passing tests emit too — reads exactly
|
||||
// like an assertion site. Its subtest holds the real failure.
|
||||
xml := []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="2" failures="2">
|
||||
<testcase name="TestX" time="1.5"><failure message="Failed"><![CDATA[
|
||||
main_test.go:325: DB instance rds-e2e-backup-1 is gone
|
||||
]]></failure></testcase>
|
||||
<testcase name="TestX/RealFailure" time="0.5"><failure message="Failed"><![CDATA[
|
||||
foo_test.go:10:
|
||||
Messages: real assertion message
|
||||
]]></failure></testcase>
|
||||
</testsuite></testsuites>`)
|
||||
sr, err := ParseFile("junit-x.xml", xml)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if sr.FailCount != 1 {
|
||||
t.Fatalf("FailCount = %d, want 1 (the parent rollup should be skipped)", sr.FailCount)
|
||||
}
|
||||
if sr.Root == nil || sr.Root.Name != "TestX/RealFailure" {
|
||||
t.Fatalf("expected the subtest as root, got %+v", sr.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// A parent that fails on its own after its subtests passed is a real failure,
|
||||
// and dropping it would lose the only record of it.
|
||||
func TestParseFile_KeepsParentWithNoFailingSubtest(t *testing.T) {
|
||||
xml := []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="2" failures="1">
|
||||
<testcase name="TestX" time="1.5"><failure message="Failed"><![CDATA[
|
||||
foo_test.go:42:
|
||||
Messages: the parent asserted after its subtests
|
||||
]]></failure></testcase>
|
||||
<testcase name="TestX/Passing" time="0.5"></testcase>
|
||||
</testsuite></testsuites>`)
|
||||
sr, err := ParseFile("junit-x.xml", xml)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if sr.Root == nil || sr.Root.Name != "TestX" {
|
||||
t.Fatalf("expected the parent as root, got %+v", sr.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// suiteFixture writes a junit file plus its .start sidecar into a temp log dir
|
||||
// and returns the parsed, start-file-corrected report for one suite.
|
||||
func suiteFixture(t *testing.T, start string, xml []byte) SuiteReport {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "test-x.start"), []byte(start), 0o644); err != nil {
|
||||
t.Fatalf("write start file: %v", err)
|
||||
}
|
||||
sr, err := ParseFile("junit-x.xml", xml)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
rep := Report{Suites: []SuiteReport{sr}}
|
||||
ApplySuiteStartFiles(&rep, dir)
|
||||
return rep.Suites[0]
|
||||
}
|
||||
|
||||
// Parallel tests overlap, so summing the ones before a failure puts its start
|
||||
// minutes past where it really was — and the journal slice cut from that window
|
||||
// misses the failure entirely. The tell is the top-level tests taking longer in
|
||||
// total than the suite's own wall clock.
|
||||
func TestApplySuiteStartFiles_DropsTheOffsetWhenTheSuiteRanInParallel(t *testing.T) {
|
||||
// timestamp is when go-junit-report wrote the XML, i.e. the suite's end.
|
||||
s := suiteFixture(t, "2026-08-03T02:00:00Z", []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="2" failures="1" timestamp="2026-08-03T02:02:00Z">
|
||||
<testcase name="TestSlow" time="100.0"></testcase>
|
||||
<testcase name="TestX" time="90.0"><failure message="Failed"><![CDATA[
|
||||
foo_test.go:10:
|
||||
Messages: real assertion message
|
||||
]]></failure></testcase>
|
||||
</testsuite></testsuites>`))
|
||||
if s.Root == nil {
|
||||
t.Fatal("expected a root failure")
|
||||
}
|
||||
if s.Root.SuiteSpan != 2*time.Minute {
|
||||
t.Errorf("SuiteSpan = %s, want 2m0s to mark the suite parallel", s.Root.SuiteSpan)
|
||||
}
|
||||
if want := time.Date(2026, 8, 3, 2, 0, 0, 0, time.UTC); !s.Root.StartAt.Equal(want) {
|
||||
t.Errorf("StartAt = %s, want the suite start %s rather than a summed offset", s.Root.StartAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The sequential case must keep its offsets: they are the only thing that makes
|
||||
// a per-failure journal slice narrow enough to read.
|
||||
func TestApplySuiteStartFiles_KeepsTheOffsetWhenTheSuiteRanSequentially(t *testing.T) {
|
||||
s := suiteFixture(t, "2026-08-03T02:00:00Z", []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="2" failures="1" timestamp="2026-08-03T02:02:00Z">
|
||||
<testcase name="TestFirst" time="30.0"></testcase>
|
||||
<testcase name="TestX" time="90.0"><failure message="Failed"><![CDATA[
|
||||
foo_test.go:10:
|
||||
Messages: real assertion message
|
||||
]]></failure></testcase>
|
||||
</testsuite></testsuites>`))
|
||||
if s.Root == nil {
|
||||
t.Fatal("expected a root failure")
|
||||
}
|
||||
if s.Root.SuiteSpan != 0 {
|
||||
t.Errorf("SuiteSpan = %s, want 0 for a sequential suite", s.Root.SuiteSpan)
|
||||
}
|
||||
if want := time.Date(2026, 8, 3, 2, 0, 30, 0, time.UTC); !s.Root.StartAt.Equal(want) {
|
||||
t.Errorf("StartAt = %s, want the suite start plus 30s of preceding tests %s", s.Root.StartAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_CascadeFallbackWhenAllCascades(t *testing.T) {
|
||||
// If every failure is a cascade marker, the earliest one becomes root
|
||||
// so the report isn't blank.
|
||||
@@ -189,3 +316,25 @@ func TestRender_NoFailuresIsClean(t *testing.T) {
|
||||
t.Errorf("expected zero-failure banner, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A cell that fails in provisioning writes junit with no testcases. Rendering
|
||||
// the clean banner there reports "nothing ran" as "nothing wrong".
|
||||
func TestRender_NoTestsRanIsNotClean(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
suites []SuiteReport
|
||||
}{
|
||||
{"empty junit file", []SuiteReport{{File: "junit-baremetal.xml", Label: "baremetal", Total: 0, FailCount: 0}}},
|
||||
{"no junit files at all", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := Render(Report{Title: "E2E failure analysis", Suites: tc.suites})
|
||||
if strings.Contains(out, "No failures across any suite") {
|
||||
t.Errorf("clean banner rendered when no test ran, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "No tests ran") {
|
||||
t.Errorf("expected a no-tests-ran warning, got:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// [StartAt, StartAt+Duration] testcase span. Five seconds catches the
|
||||
// daemon's pre-test setup and post-test cleanup chatter while keeping
|
||||
// the slice focused.
|
||||
const bundlePad = 5 * time.Second
|
||||
|
||||
// nameSanitiseRe replaces characters unsafe in directory names with `_`.
|
||||
@@ -58,21 +58,57 @@ func ApplySuiteStartFiles(rep *Report, logDir string) {
|
||||
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)
|
||||
// The start file is the first wall clock the analyzer has, so it is also
|
||||
// the first chance to tell a parallel suite from a sequential one — and
|
||||
// a parallel one's per-test offsets are fiction, since overlapping tests
|
||||
// never started at the sum of the ones before them.
|
||||
span := suiteSpanIfParallel(s, ts)
|
||||
for _, f := range collectFailures(s) {
|
||||
f.SuiteSpan = span
|
||||
if span > 0 {
|
||||
f.StartAt = ts
|
||||
continue
|
||||
}
|
||||
f.StartAt = ts.Add(f.OffsetFromSuiteStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// suiteSpanIfParallel returns the suite's wall-clock span when its top-level
|
||||
// tests overlapped, and zero when they ran one after another. Consumers read a
|
||||
// non-zero span as "no per-test offset is derivable from this suite".
|
||||
func suiteSpanIfParallel(s *SuiteReport, start time.Time) time.Duration {
|
||||
if s.EndedAt.IsZero() || s.TopLevelTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
wall := s.EndedAt.Sub(start)
|
||||
// The 5% margin keeps per-testcase rounding from calling a sequential
|
||||
// suite parallel; real overlap runs to several times the wall clock.
|
||||
if wall <= 0 || s.TopLevelTotal <= time.Duration(float64(wall)*1.05) {
|
||||
return 0
|
||||
}
|
||||
return wall
|
||||
}
|
||||
|
||||
// collectFailures returns every failure in the report by pointer. Root, cascades
|
||||
// and buckets hold independent copies of the same failures, so a field that has
|
||||
// to reach the bundle writer must be set on all three.
|
||||
func collectFailures(s *SuiteReport) []*Failure {
|
||||
var all []*Failure
|
||||
if s.Root != nil {
|
||||
all = append(all, s.Root)
|
||||
}
|
||||
for ci := range s.Cascades {
|
||||
all = append(all, &s.Cascades[ci])
|
||||
}
|
||||
for bi := range s.Buckets {
|
||||
for fi := range s.Buckets[bi] {
|
||||
all = append(all, &s.Buckets[bi][fi])
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -143,7 +179,11 @@ func writeOneBundle(logDir, base, suiteLabel string, f *Failure) {
|
||||
|
||||
writeFailureSummary(dir, suiteLabel, f)
|
||||
|
||||
if !f.StartAt.IsZero() {
|
||||
// A parallel suite offers no derivable per-test window, and the narrowing is
|
||||
// the whole point of the slice: cutting one from the wrong minute leaves out
|
||||
// the lines that explain the failure, and widening it to the suite just
|
||||
// copies the artifact's own journal into every bundle.
|
||||
if !f.StartAt.IsZero() && f.SuiteSpan == 0 {
|
||||
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")
|
||||
@@ -188,7 +228,11 @@ 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() {
|
||||
switch {
|
||||
case f.SuiteSpan > 0:
|
||||
b.WriteString("Start: unknown, the suite ran in parallel; " +
|
||||
"no journal window is derivable — read ../../spinifex-journal.log\n")
|
||||
case !f.StartAt.IsZero():
|
||||
fmt.Fprintf(&b, "Start: %s\n", f.StartAt.Format(time.RFC3339))
|
||||
}
|
||||
fmt.Fprintf(&b, "Duration: %.3fs\n", f.Duration)
|
||||
|
||||
@@ -45,7 +45,7 @@ func main() {
|
||||
report.Suites = append(report.Suites, sr)
|
||||
}
|
||||
|
||||
// Stage 2: prefer authoritative per-suite start times written by the
|
||||
// Prefer authoritative per-suite start times written by the
|
||||
// workflow over junit-report's conversion-time `timestamp` attribute,
|
||||
// then materialise per-failure bundles so Render can link them.
|
||||
if logDir != "" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
// Stage 2: per-failure log slicers used by bundle.go to materialise time-window /
|
||||
// Per-failure log slicers used by bundle.go to materialise time-window /
|
||||
// testcase-window log excerpts next to each named failure.
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Resolve OTEL job id
|
||||
description: >-
|
||||
Append ci.job_id to SPINIFEX_OTEL_ATTRS so telemetry emitted by this job can
|
||||
be pivoted straight back to its GitHub job log. Must run before the step that
|
||||
bakes SPINIFEX_OTEL_ATTRS into a VM's telemetry.env, and requires
|
||||
`actions: read` on the calling job.
|
||||
|
||||
inputs:
|
||||
job-name:
|
||||
description: >-
|
||||
Rendered name of the calling job, exactly as it appears in the Actions UI.
|
||||
github.job is only the YAML key and no context exposes the numeric id, so
|
||||
the id has to be looked up by name.
|
||||
required: true
|
||||
token:
|
||||
description: Token used for the Actions jobs API lookup.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve CI job id (best-effort)
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.token }}
|
||||
JOB_NAME: ${{ inputs.job-name }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# Never fail the caller: on any lookup error or name mismatch ci.job_id
|
||||
# is simply omitted and the run proceeds with the labels it already has.
|
||||
set +e
|
||||
|
||||
if [ -z "${SPINIFEX_OTEL_ATTRS:-}" ]; then
|
||||
echo "SPINIFEX_OTEL_ATTRS is unset or empty — telemetry is disabled, nothing to label"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# env.JOB_NAME rather than shell interpolation into the jq filter: job
|
||||
# names carry quotes, slashes and non-ASCII separators that would
|
||||
# otherwise break the expression.
|
||||
JOB_ID=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs" --paginate \
|
||||
--jq '.jobs[] | select(.name == env.JOB_NAME) | .id' 2>/dev/null | head -n1)
|
||||
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
echo "SPINIFEX_OTEL_ATTRS=${SPINIFEX_OTEL_ATTRS},ci.job_id=${JOB_ID}" >> "$GITHUB_ENV"
|
||||
echo "resolved ci.job_id=${JOB_ID} for job '${JOB_NAME}'"
|
||||
else
|
||||
echo "::warning::could not resolve numeric job id for '${JOB_NAME}' — continuing without ci.job_id"
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,225 +1,114 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#161b22"/>
|
||||
<stop offset="45%" style="stop-color:#1f2028"/>
|
||||
<stop offset="100%" style="stop-color:#161b22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC262633"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D14"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#F87171"/>
|
||||
<stop offset="50%" style="stop-color:#DC2626"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="620" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<!-- Title bar -->
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ARCHITECTURE</text>
|
||||
<text x="24" y="52" font-family="'Segoe UI', system-ui, sans-serif" font-size="16" fill="#f5f5f5" font-weight="500">AWS SDK → Gateway → NATS → Service daemons</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">stateless · message-driven · horizontally scalable</text>
|
||||
|
||||
<!-- ═══ CLIENT LAYER ════════════════════════════════════ -->
|
||||
<rect x="16" y="72" width="868" height="78" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="32" y="93" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700" letter-spacing="1">CLIENTS</text>
|
||||
<text x="32" y="108" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Standard AWS tooling — unmodified, no shims, no custom SDK</text>
|
||||
|
||||
<g transform="translate(32, 120)" font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<rect x="0" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="43" y="15" fill="#7ee787" text-anchor="middle">aws-cli</text>
|
||||
|
||||
<rect x="94" y="0" width="96" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="142" y="15" fill="#7ee787" text-anchor="middle">Terraform</text>
|
||||
|
||||
<rect x="198" y="0" width="76" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="236" y="15" fill="#7ee787" text-anchor="middle">boto3</text>
|
||||
|
||||
<rect x="282" y="0" width="98" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="331" y="15" fill="#7ee787" text-anchor="middle">aws-sdk-go</text>
|
||||
|
||||
<rect x="388" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="431" y="15" fill="#7ee787" text-anchor="middle">kubectl</text>
|
||||
|
||||
<rect x="482" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="525" y="15" fill="#7ee787" text-anchor="middle">Packer</text>
|
||||
|
||||
<rect x="576" y="0" width="120" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="636" y="15" fill="#7ee787" text-anchor="middle">your application</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ HTTPS + SigV4 connector ═══════════════════════════ -->
|
||||
<line x1="450" y1="150" x2="450" y2="178" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="390" y="154" width="120" height="18" rx="9" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
|
||||
<text x="450" y="166" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#F87171" text-anchor="middle">HTTPS · SigV4 · :9999</text>
|
||||
|
||||
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
|
||||
<rect x="16" y="178" width="868" height="68" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
|
||||
<text x="32" y="200" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY</text>
|
||||
<text x="32" y="215" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
|
||||
|
||||
<g transform="translate(32, 224)" font-family="'SF Mono', ui-monospace, monospace" font-size="9">
|
||||
<rect x="0" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="39" y="11" fill="#FCA5A5" text-anchor="middle">TLS 1.3</text>
|
||||
|
||||
<rect x="86" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="125" y="11" fill="#FCA5A5" text-anchor="middle">SigV4 auth</text>
|
||||
|
||||
<rect x="172" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="221" y="11" fill="#FCA5A5" text-anchor="middle">policy eval</text>
|
||||
|
||||
<rect x="278" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="327" y="11" fill="#FCA5A5" text-anchor="middle">req routing</text>
|
||||
|
||||
<rect x="384" y="0" width="88" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="428" y="11" fill="#FCA5A5" text-anchor="middle">rate-limit</text>
|
||||
|
||||
<rect x="480" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="529" y="11" fill="#FCA5A5" text-anchor="middle">audit log</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ NATS BUS ═══════════════════════════════════════ -->
|
||||
<line x1="450" y1="246" x2="450" y2="272" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="16" y="272" width="868" height="34" rx="6" fill="url(#nats-grad)" opacity="0.92"/>
|
||||
<text x="32" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS BUS</text>
|
||||
<text x="140" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#fee2e2" opacity="0.95">ec2.* ebs.* s3.* vpc.* iam.*</text>
|
||||
<text x="876" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">queue groups · fan-out · request/reply</text>
|
||||
|
||||
<!-- ═══ DAEMON FANOUT CONNECTORS ═══════════════════════ -->
|
||||
<g stroke="#DC2626" stroke-width="0.9" stroke-dasharray="3,3" opacity="0.55" fill="none">
|
||||
<line x1="100" y1="306" x2="100" y2="328"/>
|
||||
<line x1="275" y1="306" x2="275" y2="328"/>
|
||||
<line x1="450" y1="306" x2="450" y2="328"/>
|
||||
<line x1="625" y1="306" x2="625" y2="328"/>
|
||||
<line x1="800" y1="306" x2="800" y2="328"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ SERVICE DAEMONS ROW ═══════════════════════════ -->
|
||||
<!-- EC2 -->
|
||||
<g transform="translate(28, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ec2.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">RunInstances</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">TerminateInstances</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">QMP · cloud-init</text>
|
||||
</g>
|
||||
|
||||
<!-- EBS -->
|
||||
<g transform="translate(204, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ebs.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">CreateVolume</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">AttachVolume</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">snapshots · WAL</text>
|
||||
</g>
|
||||
|
||||
<!-- S3 -->
|
||||
<g transform="translate(380, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">s3.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">PutObject · GetObject</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Multipart · presigned</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">erasure-coded</text>
|
||||
</g>
|
||||
|
||||
<!-- VPC -->
|
||||
<g transform="translate(556, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">vpc.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Subnets · SGs · RTs</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">ALB · NAT · IGW</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">OVN · OVS</text>
|
||||
</g>
|
||||
|
||||
<!-- IAM -->
|
||||
<g transform="translate(732, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">iam.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Users · Roles · Keys</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Policy evaluation</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">audit trail</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ DAEMON → BACKEND CONNECTORS ═══════════════════ -->
|
||||
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
|
||||
<line x1="100" y1="410" x2="100" y2="440"/>
|
||||
<line x1="276" y1="410" x2="276" y2="440"/>
|
||||
<line x1="452" y1="410" x2="452" y2="440"/>
|
||||
<line x1="628" y1="410" x2="628" y2="440"/>
|
||||
<line x1="804" y1="410" x2="804" y2="440"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ BACKEND RESOURCES BAR ═════════════════════════ -->
|
||||
<rect x="16" y="440" width="868" height="26" rx="5" fill="url(#bar-grad)"/>
|
||||
<text x="450" y="458" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">BACKEND RESOURCES · local hardware · no cloud dependency</text>
|
||||
|
||||
<!-- ═══ BACKEND ROW ═══════════════════════════════════ -->
|
||||
<!-- QEMU/KVM -->
|
||||
<g transform="translate(28, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">hypervisor</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">host kernel · virtio</text>
|
||||
</g>
|
||||
|
||||
<!-- Viperblock -->
|
||||
<g transform="translate(204, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Viperblock</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">block storage</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">NVMe WAL · NBD</text>
|
||||
</g>
|
||||
|
||||
<!-- Predastore -->
|
||||
<g transform="translate(380, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Predastore</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">object storage</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">Reed–Solomon</text>
|
||||
</g>
|
||||
|
||||
<!-- OVN/OVS -->
|
||||
<g transform="translate(556, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">OVN / OVS</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SDN fabric</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">overlays · flow rules</text>
|
||||
</g>
|
||||
|
||||
<!-- IAM store -->
|
||||
<g transform="translate(732, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Identity store</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">users · keys · rules</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">local · audit-logged</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ FOOTER NOTES ══════════════════════════════════ -->
|
||||
<g transform="translate(16, 558)">
|
||||
<rect width="868" height="48" rx="6" fill="#0a0a0a" stroke="#27272a" stroke-width="0.8"/>
|
||||
<text x="24" y="20" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
|
||||
<tspan fill="#F87171" font-weight="600">Queue groups</tspan> load-balance ops across daemon replicas.
|
||||
<tspan fill="#F87171" font-weight="600"> Fan-out</tspan> (no queue group) dispatches describe calls to every node.
|
||||
</text>
|
||||
<text x="24" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
|
||||
Daemons are <tspan fill="#F87171" font-weight="600">stateless</tspan> — scale horizontally by starting more.
|
||||
No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="770" viewBox="0 0 1020 770" role="img" aria-label="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus; stateless ec2/ebs/s3/vpc/iam daemons answer and drive local backends (QEMU/KVM, Viperblock, Predastore, OVN/OVS, identity store).">
|
||||
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
|
||||
<rect x="0.0" y="0.0" width="1020.0" height="770.0" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="28.0" y="42.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ARCHITECTURE</text>
|
||||
<text x="992.0" y="42.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">stateless · message-driven · horizontally scalable</text>
|
||||
<text x="28.0" y="72.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">AWS SDK → Gateway → NATS → service daemons</text>
|
||||
<rect x="28.0" y="92.0" width="964.0" height="92.0" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="48.0" y="119.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">CLIENTS</text>
|
||||
<text x="48.0" y="140.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#5B636C" font-weight="400" text-anchor="start">Standard AWS tooling, unmodified. No shims, no custom SDK.</text>
|
||||
<rect x="46.0" y="153.0" width="68.2" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="80.1" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli</text>
|
||||
<rect x="123.2" y="153.0" width="81.4" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="163.9" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Terraform</text>
|
||||
<rect x="213.6" y="153.0" width="55.0" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="241.1" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">boto3</text>
|
||||
<rect x="277.6" y="153.0" width="88.0" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="321.6" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-sdk-go</text>
|
||||
<rect x="374.6" y="153.0" width="68.2" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="408.7" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">kubectl</text>
|
||||
<rect x="451.8" y="153.0" width="61.6" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="482.6" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Packer</text>
|
||||
<rect x="522.4" y="153.0" width="74.8" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="559.8" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">your app</text>
|
||||
<line x1="510.0" y1="186.0" x2="510.0" y2="192.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="429.9" y="191.0" width="160.3" height="22.0" rx="11" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<text x="510.0" y="205.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="400" text-anchor="middle" fill-opacity="0.9">HTTPS · SigV4 · :9999</text>
|
||||
<line x1="510.0" y1="212.0" x2="510.0" y2="220.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28.0" y="224.0" width="964.0" height="94.0" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="48.0" y="251.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="700" text-anchor="start" letter-spacing="1.5">AWS GATEWAY</text>
|
||||
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
|
||||
<rect x="46.0" y="287.0" width="68.2" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="80.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">TLS 1.3</text>
|
||||
<rect x="123.2" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="167.2" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">SigV4 auth</text>
|
||||
<rect x="220.2" y="287.0" width="94.6" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="267.5" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">policy eval</text>
|
||||
<rect x="323.8" y="287.0" width="94.6" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="371.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">req routing</text>
|
||||
<rect x="427.4" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="471.4" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">rate-limit</text>
|
||||
<rect x="524.4" y="287.0" width="81.4" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="565.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">audit log</text>
|
||||
<line x1="510.0" y1="320.0" x2="510.0" y2="332.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28.0" y="334.0" width="964.0" height="52.0" rx="12" fill="url(#nats)" fill-opacity="1.0" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<rect x="28.0" y="334.0" width="964.0" height="52.0" rx="12" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55"/>
|
||||
<text x="50.0" y="365.6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">NATS BUS</text>
|
||||
<text x="178.0" y="364.6" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#F5F3EE" font-weight="400" text-anchor="start" fill-opacity="0.92">ec2.* ebs.* s3.* vpc.* iam.*</text>
|
||||
<text x="972.0" y="363.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F5F3EE" font-weight="400" text-anchor="end" fill-opacity="0.9">queue groups · fan-out · request/reply</text>
|
||||
<line x1="119.6" y1="388.0" x2="119.6" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<line x1="314.8" y1="388.0" x2="314.8" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<line x1="510.0" y1="388.0" x2="510.0" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<line x1="705.2" y1="388.0" x2="705.2" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<line x1="900.4" y1="388.0" x2="900.4" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<rect x="28.0" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="44.0" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">ec2.* daemon</text>
|
||||
<line x1="44.0" y1="448.0" x2="195.2" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
|
||||
<text x="44.0" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">RunInstances</text>
|
||||
<text x="44.0" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">TerminateInstances</text>
|
||||
<text x="44.0" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">QMP · cloud-init</text>
|
||||
<rect x="223.2" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="239.2" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">ebs.* daemon</text>
|
||||
<line x1="239.2" y1="448.0" x2="390.4" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
|
||||
<text x="239.2" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">CreateVolume</text>
|
||||
<text x="239.2" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">AttachVolume</text>
|
||||
<text x="239.2" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">snapshots · WAL</text>
|
||||
<rect x="418.4" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="434.4" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">s3.* daemon</text>
|
||||
<line x1="434.4" y1="448.0" x2="585.6" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
|
||||
<text x="434.4" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">PutObject · GetObject</text>
|
||||
<text x="434.4" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Multipart · presigned</text>
|
||||
<text x="434.4" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">erasure-coded</text>
|
||||
<rect x="613.6" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="629.6" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">vpc.* daemon</text>
|
||||
<line x1="629.6" y1="448.0" x2="780.8" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
|
||||
<text x="629.6" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Subnets · SGs · RTs</text>
|
||||
<text x="629.6" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">ALB · NAT · IGW</text>
|
||||
<text x="629.6" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">OVN · OVS</text>
|
||||
<rect x="808.8" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="824.8" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">iam.* daemon</text>
|
||||
<line x1="824.8" y1="448.0" x2="976.0" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
|
||||
<text x="824.8" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Users · Roles · Keys</text>
|
||||
<text x="824.8" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Policy evaluation</text>
|
||||
<text x="824.8" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">audit trail</text>
|
||||
<line x1="119.6" y1="516.0" x2="119.6" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
|
||||
<line x1="314.8" y1="516.0" x2="314.8" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
|
||||
<line x1="510.0" y1="516.0" x2="510.0" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
|
||||
<line x1="705.2" y1="516.0" x2="705.2" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
|
||||
<line x1="900.4" y1="516.0" x2="900.4" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
|
||||
<rect x="28.0" y="530.0" width="964.0" height="30.0" rx="9" fill="#1E242B" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="510.0" y="548.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle" letter-spacing="1">BACKEND RESOURCES · local hardware · no cloud dependency</text>
|
||||
<rect x="28.0" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="119.6" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">QEMU / KVM</text>
|
||||
<text x="119.6" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">hypervisor</text>
|
||||
<text x="119.6" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">host kernel · virtio</text>
|
||||
<rect x="223.2" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="314.8" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Viperblock</text>
|
||||
<text x="314.8" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">block storage</text>
|
||||
<text x="314.8" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">NVMe WAL · NBD</text>
|
||||
<rect x="418.4" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="510.0" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Predastore</text>
|
||||
<text x="510.0" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">object storage</text>
|
||||
<text x="510.0" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Reed-Solomon</text>
|
||||
<rect x="613.6" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="705.2" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">OVN / OVS</text>
|
||||
<text x="705.2" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">SDN fabric</text>
|
||||
<text x="705.2" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">overlays · flow rules</text>
|
||||
<rect x="808.8" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="900.4" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Identity store</text>
|
||||
<text x="900.4" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">users · keys · rules</text>
|
||||
<text x="900.4" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">local · audit-logged</text>
|
||||
<rect x="28.0" y="670.0" width="964.0" height="72.0" rx="11" fill="#181D23" fill-opacity="0.6" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="48.0" y="697.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400"><tspan fill="#EE4B12" font-weight="700">Queue groups</tspan><tspan fill="#5B636C"> load-balance ops across daemon replicas. </tspan><tspan fill="#EE4B12" font-weight="700">Fan-out</tspan><tspan fill="#5B636C"> (no queue group) dispatches describe calls to every node.</tspan></text>
|
||||
<text x="48.0" y="720.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400"><tspan fill="#5B636C">Daemons are </tspan><tspan fill="#EE4B12" font-weight="700">stateless</tspan><tspan fill="#5B636C">: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.</tspan></text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,144 +1,84 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
|
||||
<defs>
|
||||
<linearGradient id="pf-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hw-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#52525b;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#52525b;stop-opacity:0.06"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="os-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="spx-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.28"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="app-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3fb950;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#3fb950;stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="560" fill="url(#pf-bg)" rx="12"/>
|
||||
|
||||
<!-- Subtle mesh dots background -->
|
||||
<g opacity="0.12" fill="#7F1D1D">
|
||||
<circle cx="40" cy="40" r="1"/><circle cx="120" cy="30" r="1"/><circle cx="220" cy="50" r="1"/>
|
||||
<circle cx="320" cy="25" r="1"/><circle cx="440" cy="45" r="1"/><circle cx="560" cy="30" r="1"/>
|
||||
<circle cx="680" cy="50" r="1"/><circle cx="780" cy="30" r="1"/><circle cx="860" cy="45" r="1"/>
|
||||
<circle cx="60" cy="530" r="1"/><circle cx="160" cy="520" r="1"/><circle cx="260" cy="540" r="1"/>
|
||||
<circle cx="380" cy="525" r="1"/><circle cx="500" cy="540" r="1"/><circle cx="620" cy="520" r="1"/>
|
||||
<circle cx="740" cy="540" r="1"/><circle cx="840" cy="525" r="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="450" y="32" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" text-anchor="middle" letter-spacing="2.8">THE MULGA PLATFORM</text>
|
||||
<text x="450" y="58" font-family="'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="600" fill="#f5f5f5" text-anchor="middle">AWS-style workflows, on your own stack</text>
|
||||
|
||||
<!-- Layer 4: Applications (top) -->
|
||||
<g transform="translate(60, 95)">
|
||||
<rect width="780" height="88" rx="10" fill="url(#app-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" letter-spacing="1.5" font-weight="600">LAYER 4 · APPLICATIONS & TOOLING</text>
|
||||
<text x="20" y="48" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">Your workloads — unmodified AWS tooling</text>
|
||||
|
||||
<g transform="translate(20, 60)" font-family="'Segoe UI', sans-serif" font-size="10.5">
|
||||
<rect x="0" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="35" y="14" fill="#7ee787" text-anchor="middle">aws-cli</text>
|
||||
|
||||
<rect x="78" y="0" width="78" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="117" y="14" fill="#7ee787" text-anchor="middle">Terraform</text>
|
||||
|
||||
<rect x="164" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="208" y="14" fill="#7ee787" text-anchor="middle">AWS SDKs</text>
|
||||
|
||||
<rect x="260" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="304" y="14" fill="#7ee787" text-anchor="middle">Kubernetes</text>
|
||||
|
||||
<rect x="356" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="391" y="14" fill="#7ee787" text-anchor="middle">Packer</text>
|
||||
|
||||
<rect x="434" y="0" width="98" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="483" y="14" fill="#7ee787" text-anchor="middle">Your Services</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 188)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 3: Spinifex (highlighted) -->
|
||||
<g transform="translate(60, 215)">
|
||||
<rect width="780" height="140" rx="10" fill="url(#spx-grad)" stroke="#DC2626" stroke-width="1.5" stroke-opacity="0.75"/>
|
||||
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" letter-spacing="1.5" font-weight="600">LAYER 3 · SPINIFEX · AWS ORCHESTRATION</text>
|
||||
<text x="20" y="50" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">AWS Gateway → NATS → Service Daemons · SigV4, IAM, VPC-aware</text>
|
||||
|
||||
<g transform="translate(20, 66)" font-family="'Segoe UI', sans-serif">
|
||||
<!-- EC2 -->
|
||||
<rect x="0" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="59" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EC2</text>
|
||||
<text x="59" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">QEMU/KVM VMs</text>
|
||||
<text x="59" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">cloud-init · AMIs</text>
|
||||
|
||||
<!-- EBS -->
|
||||
<rect x="126" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="185" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EBS</text>
|
||||
<text x="185" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Viperblock</text>
|
||||
<text x="185" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">WAL · NBD · snapshots</text>
|
||||
|
||||
<!-- S3 -->
|
||||
<rect x="252" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="311" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">S3</text>
|
||||
<text x="311" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Predastore</text>
|
||||
<text x="311" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Reed–Solomon EC</text>
|
||||
|
||||
<!-- VPC -->
|
||||
<rect x="378" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
|
||||
<text x="437" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">VPC</text>
|
||||
<text x="437" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">OVN / OVS</text>
|
||||
<text x="437" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">subnets · SG · ALB</text>
|
||||
|
||||
<!-- IAM -->
|
||||
<rect x="504" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
|
||||
<text x="563" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">IAM</text>
|
||||
<text x="563" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">SigV4 · policies</text>
|
||||
<text x="563" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">users · roles · keys</text>
|
||||
|
||||
<!-- Control -->
|
||||
<rect x="630" y="0" width="110" height="58" rx="6" fill="#27272a" fill-opacity="0.8" stroke="#71717a" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="685" y="18" font-size="13" fill="#e4e4e7" text-anchor="middle" font-weight="600">Control</text>
|
||||
<text x="685" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">NATS bus</text>
|
||||
<text x="685" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">spx · daemons</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 360)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 2: OS -->
|
||||
<g transform="translate(60, 387)">
|
||||
<rect width="780" height="62" rx="10" fill="url(#os-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.5"/>
|
||||
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" letter-spacing="1.5" font-weight="600">LAYER 2 · OPERATING SYSTEM</text>
|
||||
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · headless autoinstall</text>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 454)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 1: Hardware -->
|
||||
<g transform="translate(60, 481)">
|
||||
<rect width="780" height="62" rx="10" fill="url(#hw-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" letter-spacing="1.5" font-weight="600">LAYER 1 · HARDWARE</text>
|
||||
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Single node · multi-node cluster · edge appliance · air-gapped rack · commodity x86 / ARM</text>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="664" viewBox="0 0 1000 664" role="img" aria-label="The Spinifex stack: applications and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU/GPU hardware, deployable to Neocloud, on-prem or edge.">
|
||||
<rect x="0.0" y="0.0" width="1000.0" height="664.0" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<rect x="28.0" y="204.0" width="950.0" height="438.0" rx="16" fill="none" fill-opacity="1.0" stroke="#9AA1A8" stroke-width="1" stroke-opacity="0.12" stroke-dasharray="2 4"/>
|
||||
<rect x="25.0" y="201.0" width="950.0" height="438.0" rx="16" fill="none" fill-opacity="1.0" stroke="#9AA1A8" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="2 4"/>
|
||||
<rect x="22.0" y="198.0" width="950.0" height="438.0" rx="16" fill="#181D23" fill-opacity="0.25" stroke="#9AA1A8" stroke-width="1.3" stroke-opacity="0.45" stroke-dasharray="2 4"/>
|
||||
<text x="36.0" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Apps</text>
|
||||
<rect x="136.0" y="34.0" width="836.0" height="54.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="156.0" y="66.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">Your infrastructure & apps</text>
|
||||
<rect x="516.7" y="51.0" width="89.3" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.16" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="561.4" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="middle">AI training</text>
|
||||
<rect x="613.0" y="51.0" width="76.7" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.16" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="651.4" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="middle">Inference</text>
|
||||
<rect x="696.7" y="51.0" width="79.0" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
|
||||
<text x="736.2" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Web & APIs</text>
|
||||
<rect x="782.7" y="51.0" width="72.7" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
|
||||
<text x="819.1" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Databases</text>
|
||||
<rect x="862.4" y="51.0" width="91.6" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
|
||||
<text x="908.2" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Batch & data</text>
|
||||
<text x="36.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Tooling</text>
|
||||
<rect x="136.0" y="102.0" width="836.0" height="54.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="156.0" y="134.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">AWS tooling</text>
|
||||
<rect x="600.9" y="119.0" width="64.1" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="633.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">aws-cli</text>
|
||||
<rect x="672.0" y="119.0" width="76.7" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="710.4" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">Terraform</text>
|
||||
<rect x="755.7" y="119.0" width="64.1" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="787.8" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">kubectl</text>
|
||||
<rect x="826.8" y="119.0" width="66.4" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
|
||||
<text x="860.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">AWS SDKs</text>
|
||||
<rect x="900.2" y="119.0" width="53.8" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
|
||||
<text x="927.1" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Packer</text>
|
||||
<text x="484.0" y="184.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">↓ AWS API · SigV4</text>
|
||||
<text x="624.0" y="184.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">RESPONSE ↑</text>
|
||||
<text x="136.0" y="231.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">One portable stack.</text>
|
||||
<text x="136.0" y="249.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="start">Runs the same on a single node, cluster, edge, or air-gapped rack.</text>
|
||||
<rect x="752.3" y="222.0" width="72.4" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="788.5" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">NEOCLOUD</text>
|
||||
<rect x="832.7" y="222.0" width="66.1" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="865.7" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">ON-PREM</text>
|
||||
<rect x="906.8" y="222.0" width="47.2" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="930.4" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">EDGE</text>
|
||||
<text x="152.0" y="295.6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">AWS orchestration</text>
|
||||
<text x="956.0" y="293.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.9">Gateway → NATS → daemons</text>
|
||||
<rect x="150.0" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="164.0" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EC2</text>
|
||||
<text x="164.0" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Compute</text>
|
||||
<text x="164.0" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">QEMU / KVM</text>
|
||||
<rect x="422.7" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="436.7" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EBS</text>
|
||||
<text x="436.7" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Block storage</text>
|
||||
<text x="436.7" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">Viperblock</text>
|
||||
<rect x="695.3" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="709.3" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">S3</text>
|
||||
<text x="709.3" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Object storage</text>
|
||||
<text x="709.3" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">Predastore</text>
|
||||
<rect x="150.0" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="164.0" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">VPC</text>
|
||||
<text x="164.0" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Networking</text>
|
||||
<text x="164.0" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">OVN / OVS</text>
|
||||
<rect x="422.7" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="436.7" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">IAM</text>
|
||||
<text x="436.7" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Identity</text>
|
||||
<text x="436.7" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">SigV4 · policies</text>
|
||||
<rect x="695.3" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="709.3" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EKS</text>
|
||||
<text x="709.3" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Kubernetes</text>
|
||||
<text x="709.3" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">K8s-conformant</text>
|
||||
<rect x="150.0" y="452.0" width="808.0" height="32.0" rx="8" fill="#14181D" fill-opacity="0.6" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0" stroke-dasharray="3 3"/>
|
||||
<text x="164.0" y="472.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">+ ALB · ECS · ECR · RDS and more of the core AWS services</text>
|
||||
<text x="944.0" y="471.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end">See all →</text>
|
||||
<text x="36.0" y="386.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="start">Spinifex</text>
|
||||
<rect x="136.0" y="268.0" width="836.0" height="230.0" rx="12" fill="#EE4B12" fill-opacity="0.07" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="36.0" y="536.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">OS</text>
|
||||
<rect x="136.0" y="510.0" width="836.0" height="46.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="156.0" y="537.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">Operating system</text>
|
||||
<text x="952.0" y="536.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="end">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · Open vSwitch</text>
|
||||
<text x="36.0" y="596.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Hardware</text>
|
||||
<rect x="136.0" y="568.0" width="413.0" height="50.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="156.0" y="597.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">CPU</text>
|
||||
<text x="533.0" y="596.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="end">Intel · AMD · ARM</text>
|
||||
<rect x="559.0" y="568.0" width="413.0" height="50.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="579.0" y="597.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">GPU</text>
|
||||
<text x="956.0" y="596.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end">NVIDIA · AMD</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 15 KiB |
@@ -5,7 +5,7 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
day: sunday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 3
|
||||
@@ -19,7 +19,7 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
day: sunday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 3
|
||||
@@ -38,7 +38,7 @@ updates:
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
day: sunday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 3
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve which branch of each sibling repo (viperblock, predastore) the CI
|
||||
# workspace should build against. A cross-repo change is developed on a branch
|
||||
# of the SAME name in each repo; build against that branch when it exists so the
|
||||
# set can be tested together, and fall back to dev otherwise. This mirrors the
|
||||
# resolution the e2e path already does in
|
||||
# mulga/scripts/tofu-cluster/build-install-artifacts.sh, so unit CI and e2e CI
|
||||
# agree on how sibling branches are selected.
|
||||
#
|
||||
# Reads $BRANCH (the triggering head/ref name) and writes viperblock=<ref> and
|
||||
# predastore=<ref> to $GITHUB_OUTPUT for the checkout steps to consume.
|
||||
set -euo pipefail
|
||||
|
||||
: "${BRANCH:?BRANCH must be set to the triggering branch name}"
|
||||
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||
|
||||
for repo in viperblock predastore; do
|
||||
if git ls-remote --exit-code --heads \
|
||||
"https://github.com/mulgadc/${repo}.git" "$BRANCH" >/dev/null 2>&1; then
|
||||
ref="$BRANCH"
|
||||
else
|
||||
ref="dev"
|
||||
fi
|
||||
echo "${repo}: building against ${ref}"
|
||||
echo "${repo}=${ref}" >>"$GITHUB_OUTPUT"
|
||||
done
|
||||
@@ -0,0 +1,62 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # every variable here is consumed by the sourcing workflow
|
||||
# Source from a workflow `run:` block:
|
||||
# . "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
#
|
||||
# Which e2e suites each topology can run. Sourced by every workflow that drives
|
||||
# suites (e2e.yml, e2e-suites.yml, e2e-nightly.yml) so the lists cannot drift
|
||||
# apart — they previously disagreed three ways.
|
||||
#
|
||||
# Eligibility is a property of the suite, not of the workflow: a suite belongs
|
||||
# to a topology when all of its assertions are reachable there. lb is
|
||||
# multi-only for exactly that reason — on a single node every internet-facing
|
||||
# subtest skips for want of a peer, so a single-node lb run reports green
|
||||
# having exercised a fraction of the suite.
|
||||
|
||||
# Suites runnable against a one-node environment.
|
||||
#
|
||||
# partialblock gates merges because it earned it, not because it is new: on one
|
||||
# node, same binary and workload, it fails against the pre-fix engine (1758 of
|
||||
# 8192 halves reverted a generation) and passes against the fixed one (0 of
|
||||
# 8192). A suite that has never been shown to go red for the reason it claims
|
||||
# does not belong here.
|
||||
# rds is single-only for the opposite reason to lb: v1 has no multi-AZ
|
||||
# behaviour, so a second node adds no assertion. Its DB VMs are capped at four
|
||||
# concurrent by the suite's own semaphore, which is what keeps it inside the
|
||||
# job's 50-minute budget alongside the suites above.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds"
|
||||
|
||||
# Suites runnable against a multi-node environment.
|
||||
E2E_SUITES_MULTI="multinode lb cert"
|
||||
|
||||
# grep -xE alternation form, for narrowing a requested set to the eligible one.
|
||||
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
|
||||
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
|
||||
|
||||
# e2e-nightly runs a narrower set than the two sets above, deliberately: its
|
||||
# ~19 cells each have a 35-minute budget and exist to prove that every
|
||||
# install / network / host-OS permutation boots and serves. Suite breadth is
|
||||
# the PR gate's job, and it already runs on every push. These are subsets, and
|
||||
# e2e_suites_assert_subset below enforces that.
|
||||
E2E_SUITES_NIGHTLY_SINGLE="single cert"
|
||||
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
|
||||
|
||||
# Fail loudly when a narrowed list names a suite its topology cannot run. This
|
||||
# is the guard that stops the lists drifting again: a suite added to a nightly
|
||||
# subset without also being declared eligible above stops the job here rather
|
||||
# than silently skipping half its assertions in CI.
|
||||
# e2e_suites_assert_subset <label> <subset> <eligible-set>
|
||||
e2e_suites_assert_subset() {
|
||||
local label="$1" subset="$2" eligible="$3" suite found eligible_suite
|
||||
for suite in ${subset}; do
|
||||
found=0
|
||||
for eligible_suite in ${eligible}; do
|
||||
if [ "${suite}" = "${eligible_suite}" ]; then found=1; break; fi
|
||||
done
|
||||
if [ "${found}" -ne 1 ]; then
|
||||
echo "::error::${label} names suite '${suite}', which is not eligible for that topology (eligible: ${eligible}). Fix the list in .github/scripts/suite-sets.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Apt Packages
|
||||
|
||||
# Path-filtered like image-scripts.yml: this resolves setup.sh's runtime package
|
||||
# list against each supported suite's real indices, so a renamed, dropped, or
|
||||
# ambiguously virtual package fails here rather than on a user's machine.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- scripts/setup.sh
|
||||
- scripts/lint-apt-packages.sh
|
||||
- .github/workflows/apt-packages.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- scripts/setup.sh
|
||||
- scripts/lint-apt-packages.sh
|
||||
- .github/workflows/apt-packages.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
apt_packages:
|
||||
name: Resolve apt list (${{ matrix.image }})
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The suites the nightly E2E matrix actually installs on. Ubuntu 22.04 is
|
||||
# accepted by detect_os but has never been exercised and does not resolve.
|
||||
image:
|
||||
- ubuntu:24.04
|
||||
- ubuntu:26.04
|
||||
- debian:13
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve runtime package list
|
||||
run: ./scripts/lint-apt-packages.sh ${{ matrix.image }}
|
||||
@@ -9,6 +9,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
otel-attrs:
|
||||
description: "OTEL_RESOURCE_ATTRIBUTES value for this run, e.g. ci.run_id=123,ci.cell=20 (caller-specific, so it's an input rather than a workflow-level default)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,6 +21,10 @@ permissions:
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
# Same central ES/APM sink as the other four e2e workflows. Bare-metal had
|
||||
# no default here before — this cell's telemetry never reached the sink at
|
||||
# all (not just missing ci.cell), see bm-bootstrap.sh's telemetry.env step.
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
|
||||
jobs:
|
||||
e2e_baremetal:
|
||||
@@ -28,6 +37,9 @@ jobs:
|
||||
env:
|
||||
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-20-${{ github.run_id }}
|
||||
# Caller-supplied (workflow_call input); empty for a manual
|
||||
# workflow_dispatch run, which bm-bootstrap.sh treats as "no attrs".
|
||||
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: E2E DDIL
|
||||
|
||||
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
|
||||
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
|
||||
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
|
||||
# This workflow is manually triggered while the DDIL scenarios are being
|
||||
# validated.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -13,6 +12,9 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the otel-job-id action look up this job's numeric id via
|
||||
# the Actions API — github.job is only the YAML key, no context exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -28,6 +30,10 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
|
||||
# Job-level env wins over the workflow-level default. ci.cell=ddil marks
|
||||
# this workflow's telemetry so a DDIL run's partition-induced errors are
|
||||
# never mistaken for a healthy multi-node run's in the sink.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=ddil
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
@@ -72,6 +78,13 @@ jobs:
|
||||
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
|
||||
"${BIN_DIR}/gotestsum" --version
|
||||
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VMs, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: DDIL Real Multi-Node
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
|
||||
@@ -11,12 +11,16 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the "Resolve CI job id" step below look up this job's
|
||||
# numeric id via the Actions API — github.job is only the YAML key, no
|
||||
# context exposes the id itself.
|
||||
actions: read
|
||||
|
||||
# Scheduled runs are loaded from the default branch (main), so the schedule
|
||||
# trigger must stay in sync on main for cron to fire.
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-24.04.qcow2
|
||||
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
|
||||
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
|
||||
# empty string disables telemetry entirely.
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
@@ -32,20 +36,25 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-26.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-26.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-26.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-26.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
|
||||
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
|
||||
- { cell: 19, install: binary, topo: single, net: nat, host: debian-13, arch: x86_64, runner: ci-single, extra: "nat-single" }
|
||||
|
||||
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: ${{ matrix.extra == 'tofu-examples' && 45 || 35 }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
# Job-level env wins over the workflow-level default above, so telemetry
|
||||
# from this cell carries ci.cell/ci.matrix. Nine cells run under one
|
||||
# run id and every cell names its hosts node1/node2/node3, so without
|
||||
# this every cell's telemetry collapses into one unattributable bucket.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.cell }},ci.matrix=${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}
|
||||
|
||||
steps:
|
||||
# blank or scheduled run = full matrix.
|
||||
@@ -92,6 +101,16 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
# Nine cells share one run id and every cell names its hosts
|
||||
# node1/node2/node3, so ci.cell above separates them and ci.job_id here
|
||||
# links each cell's telemetry to the job log that produced it. Runs
|
||||
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
|
||||
- name: Resolve CI job id
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
|
||||
- name: Build Install Artifacts
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -109,10 +128,13 @@ jobs:
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
|
||||
# Match the family, not a pinned version: an exact match silently stops
|
||||
# overriding the image when the matrix bumps release, dropping the cell
|
||||
# back onto the debian gold.
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
case "$MATRIX_HOST" in
|
||||
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
|
||||
esac
|
||||
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
@@ -147,13 +169,20 @@ jobs:
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Nightly runs a documented subset of the single-node set; the assert
|
||||
# fails the cell if that subset ever names a suite this topology
|
||||
# cannot fully exercise.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly single" "${E2E_SUITES_NIGHTLY_SINGLE}" "${E2E_SUITES_SINGLE}"
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
|
||||
for SUITE in single cert lb; do \
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_SINGLE}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
@@ -190,6 +219,11 @@ jobs:
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# See the single-node step: same subset guard, multi-node set.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly multi" "${E2E_SUITES_NIGHTLY_MULTI}" "${E2E_SUITES_MULTI}"
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
@@ -204,7 +238,7 @@ jobs:
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
|
||||
for SUITE in multinode cert lb; do \
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_MULTI}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
|
||||
@@ -217,6 +251,18 @@ jobs:
|
||||
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
|
||||
done; exit \$FAIL"
|
||||
|
||||
- name: Import RDS PostgreSQL AMI
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"spx admin images import --name spinifex-rds-postgres"
|
||||
|
||||
- name: Run Tofu Workbooks
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -339,10 +385,12 @@ jobs:
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
run: |
|
||||
# Must mirror the Provision step's override, or destroy runs against a
|
||||
# different image path than apply did.
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
case "$MATRIX_HOST" in
|
||||
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
|
||||
esac
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
@@ -373,4 +421,8 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
# A reusable-workflow call gets none of this workflow's context (env:
|
||||
# above, matrix.cell) for free — cell-20 needs its own attrs string.
|
||||
# There's no matrix leg here, just the one fixed baremetal cell.
|
||||
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=20,ci.matrix=baremetal/pxe/real-hw
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# E2E (per-suite blocks) — EXPERIMENTAL, workflow_dispatch only.
|
||||
# E2E per-suite blocks. Each suite appears as its own node in the Actions
|
||||
# graph:
|
||||
#
|
||||
# Restructures the looped e2e.yml into env-grouped job blocks so each suite
|
||||
# surfaces as its own node in the Actions graph (mulga-siv-283):
|
||||
#
|
||||
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks, iam)
|
||||
# single_provision ─┬─ single_suite (matrix: the single-node-eligible suites)
|
||||
# └─ single_teardown (if: always)
|
||||
# multi_provision ─┬─ multi_suite (matrix: multinode)
|
||||
# multi_provision ─┬─ multi_suite (matrix: the multi-node-eligible suites)
|
||||
# └─ multi_teardown (if: always)
|
||||
#
|
||||
# Both matrices come from .github/scripts/suite-sets.sh, the one definition of
|
||||
# which suites a topology can run, shared with e2e.yml and e2e-nightly.yml.
|
||||
#
|
||||
# Constraint: tofu state is runner-filesystem-local, so *_provision and
|
||||
# *_teardown must execute on the same self-hosted runner (one runner per label
|
||||
# today). Teardown reuses the persisted mulga/ tofu-cluster workspace and does
|
||||
@@ -25,9 +26,13 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
single_suites:
|
||||
description: "Single-node suites to run as matrix legs"
|
||||
description: "Single-node suites to run as matrix legs (empty = every single-node-eligible suite, per .github/scripts/suite-sets.sh)"
|
||||
required: false
|
||||
default: "single cert lb eks iam"
|
||||
default: ""
|
||||
multi_suites:
|
||||
description: "Multi-node suites to run as matrix legs (empty = every multi-node-eligible suite, per .github/scripts/suite-sets.sh)"
|
||||
required: false
|
||||
default: ""
|
||||
run_multinode:
|
||||
description: "Also run the multinode env block"
|
||||
type: boolean
|
||||
@@ -40,6 +45,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the otel-job-id action look up a provision job's numeric
|
||||
# id via the Actions API — github.job is only the YAML key, no context
|
||||
# exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -64,6 +73,13 @@ jobs:
|
||||
matrix: ${{ steps.suites.outputs.matrix }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
# This job (not single_suite) is where "Bootstrap via Binary Install"
|
||||
# bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env, before the
|
||||
# suite matrix even exists — single_suite's legs share this one
|
||||
# already-booted VM, so daemon telemetry can't be scoped any finer
|
||||
# than "single env" per suite leg. ci.cell=single distinguishes it
|
||||
# from multi_provision's VMs within the same run id.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -107,7 +123,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -115,12 +131,22 @@ jobs:
|
||||
- name: Re-enable Go toolchain auto-download
|
||||
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
|
||||
|
||||
# Default and allow-list both come from the shared definition, so this
|
||||
# workflow cannot drift from e2e.yml about what a single node can run.
|
||||
- name: Compute suite matrix
|
||||
id: suites
|
||||
env:
|
||||
REQUESTED: ${{ inputs.single_suites }}
|
||||
run: |
|
||||
LIST="${{ inputs.single_suites || 'single cert lb eks iam' }}"
|
||||
set -euo pipefail
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
LIST="${REQUESTED:-${E2E_SUITES_SINGLE}}"
|
||||
e2e_suites_assert_subset "single_suites" "${LIST}" "${E2E_SUITES_SINGLE}"
|
||||
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
|
||||
JSON=$(printf '%s\n' $LIST | jq -R . | jq -cs .)
|
||||
# Unquoted on purpose: the space-separated list must word-split into
|
||||
# one JSON element per suite.
|
||||
# shellcheck disable=SC2086
|
||||
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reclaim docker build cache
|
||||
@@ -130,6 +156,14 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# ci.job_id names the provision job, not the suite job that later drives
|
||||
# the env: the suite legs share this env and its baked telemetry.env, so
|
||||
# the provisioner is the only job the daemon telemetry can belong to.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single env · provision
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -294,7 +328,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -497,8 +531,13 @@ jobs:
|
||||
node1_ip: ${{ steps.cluster.outputs.node1_ip }}
|
||||
ssh_key: ${{ steps.cluster.outputs.ssh_key }}
|
||||
nodes_csv: ${{ steps.cluster.outputs.nodes_csv }}
|
||||
suites: ${{ steps.suites.outputs.list }}
|
||||
matrix: ${{ steps.suites.outputs.matrix }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
# See single_provision above — daemon telemetry is baked in here, at
|
||||
# provision time, before the multi_suite matrix exists.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -539,7 +578,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -554,6 +593,12 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# See the matching note on single_provision.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi env · provision
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -601,14 +646,36 @@ jobs:
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build multinode binary on runner
|
||||
# Mirrors single_provision's step: the multi leg previously ran multinode
|
||||
# alone, silently dropping the lb and cert coverage that only a
|
||||
# multi-node env can complete.
|
||||
- name: Compute suite matrix
|
||||
id: suites
|
||||
env:
|
||||
REQUESTED: ${{ inputs.multi_suites }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
LIST="${REQUESTED:-${E2E_SUITES_MULTI}}"
|
||||
e2e_suites_assert_subset "multi_suites" "${LIST}" "${E2E_SUITES_MULTI}"
|
||||
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
|
||||
# Unquoted on purpose: the space-separated list must word-split into
|
||||
# one JSON element per suite.
|
||||
# shellcheck disable=SC2086
|
||||
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build multinode binaries on runner
|
||||
working-directory: spinifex/tests/e2e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
banner "build multinode, teardown"
|
||||
# teardown always builds: the run-scoped sweep binary runs in the
|
||||
# teardown job, it is never a selectable suite leg.
|
||||
SUITES_BUILD="${{ steps.suites.outputs.list }} teardown"
|
||||
banner "build ${SUITES_BUILD// /, }"
|
||||
group_open "go test -c"
|
||||
make e2e-build SUITES_BUILD="multinode teardown"
|
||||
make e2e-build SUITES_BUILD="${SUITES_BUILD}"
|
||||
group_close
|
||||
ok "$(basename _bin/multinode.test) $(stat -c '%s' _bin/multinode.test | numfmt --to=iec)"
|
||||
|
||||
@@ -652,7 +719,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [multinode]
|
||||
suite: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
|
||||
env:
|
||||
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
|
||||
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
|
||||
@@ -663,7 +730,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suites:
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks e2e-iam')"
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks e2e-iam e2e-rds')"
|
||||
required: false
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam"
|
||||
test_run:
|
||||
@@ -26,10 +26,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
# actions: read lets the otel-job-id action look up a job's numeric id via
|
||||
# the Actions API — github.job is only the YAML key, no context exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam' }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
|
||||
# empty string disables telemetry entirely.
|
||||
@@ -37,54 +40,28 @@ env:
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
# Shadow-mode suite selector. Computes which suites the diff *would* run and logs it to the job summary.
|
||||
# Does NOT gate or override the real SUITES env yet — it runs alongside the full matrix so we
|
||||
# can compare predicted vs actual before flipping it on.
|
||||
select_suites:
|
||||
name: Select Suites (shadow)
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Compute targeted suite selection
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.before }}"
|
||||
HEAD="${{ github.sha }}"
|
||||
# First push to a branch reports an all-zero before-SHA; fall back to
|
||||
# the first parent so the diff is still meaningful.
|
||||
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
BASE="${HEAD}~1"
|
||||
fi
|
||||
echo "## Targeted suite selection (shadow)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "base=$BASE head=$HEAD" >> "$GITHUB_STEP_SUMMARY"
|
||||
go run ./tests/e2e/select-suites/cmd/select-suites \
|
||||
--manifest docs/service-interfaces.yaml \
|
||||
--base-sha "$BASE" --head-sha "$HEAD" --repo-root . \
|
||||
2>>"$GITHUB_STEP_SUMMARY" \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Shadow only — actual run uses SUITES='${SUITES}'." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
# Skip when the dispatch input requests no in-VM suites (e.g.
|
||||
# suites='e2e-multinode' only). Push triggers and the default SUITES list
|
||||
# both include single/cert/lb so the inputs.suites null-check keeps the
|
||||
# job running on plain PR pushes.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-iam')
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists, i.e. E2E_SUITES_SINGLE in .github/scripts/suite-sets.sh —
|
||||
# an `if:` expression cannot source that file, so this one list is
|
||||
# hand-mirrored and must be updated with it. Gating on a suite the job
|
||||
# won't run — lb, which only executes in the multinode job — would
|
||||
# provision a tofu VM and pay the Spinifex install only to run nothing and
|
||||
# tear down. Push triggers keep running via the null-check on
|
||||
# inputs.suites.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock') || contains(github.event.inputs.suites, 'e2e-rds')
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
|
||||
# Job-level env wins over the workflow-level default. This job and
|
||||
# e2e_multi share one run id and both name their hosts node1/node2/node3,
|
||||
# so without ci.cell their telemetry collapses into one bucket. This job
|
||||
# is where "Bootstrap via Binary Install" bakes SPINIFEX_OTEL_ATTRS into
|
||||
# the VM's telemetry.env, so the label has to be set here.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -134,7 +111,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -149,13 +126,21 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VM, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single-Node E2E
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
--skip-e2e-build
|
||||
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -265,6 +250,54 @@ jobs:
|
||||
EOF
|
||||
ok "kubectl installed on VM"
|
||||
|
||||
# CreateDBInstance boots the DB VM from the rds-postgres AMI, so that image
|
||||
# must be in the VM's image store before the suite runs — same split as the
|
||||
# eks-node pair above: build on the runner (needs source + network), import
|
||||
# on the VM (needs predastore/NATS). The suite's psql client needs no step
|
||||
# here: it runs inside a client VM that installs postgresql-client from its
|
||||
# own user-data, because a private-only endpoint is unreachable from the
|
||||
# runner and from the cluster host alike.
|
||||
- name: Build rds-postgres AMI (libguestfs, runner)
|
||||
if: contains(env.SUITES, 'e2e-rds')
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
banner "build rds-postgres AMI (libguestfs — no host devices)"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
sudo chmod 0644 /boot/vmlinuz-* || true
|
||||
group_open "make build-rds-postgres-image"
|
||||
make build-rds-postgres-image
|
||||
group_close
|
||||
ok "raw: $(du -h /tmp/rds-postgres-image-build/rds-postgres-alpine.raw | cut -f1)"
|
||||
|
||||
- name: Import rds-postgres AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-rds')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/rds-postgres/manifest.conf)"
|
||||
# SYSTEM_TAG carries three tags, not one: managed-by marks it a system
|
||||
# image and engine/engine-version are what an EngineVersion request
|
||||
# resolves against. --tag is repeatable, so expand rather than pass the
|
||||
# whole string as a single value.
|
||||
TAG_ARGS=""
|
||||
for tag in ${SYSTEM_TAG}; do TAG_ARGS="${TAG_ARGS} --tag ${tag}"; done
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import rds-postgres AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
/tmp/rds-postgres-image-build/rds-postgres-alpine.raw \
|
||||
"$VM":/tmp/rds-postgres-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/rds-postgres-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}" >/dev/null 2>&1
|
||||
ok "rds-postgres AMI imported"
|
||||
|
||||
- name: Build E2E test binaries on runner
|
||||
working-directory: spinifex/tests/e2e
|
||||
run: |
|
||||
@@ -312,14 +345,23 @@ jobs:
|
||||
id: e2e
|
||||
run: |
|
||||
set +e
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(multinode)$' | tr '\n' ' ')
|
||||
# Allow-list, not a deny-list: a suite runs here only if named. Deny-lists
|
||||
# silently fan a newly added suite out to every job, which is how lb ended up
|
||||
# running twice per e2e run. The allow-list itself is shared with the other
|
||||
# e2e workflows so the three cannot disagree about what a topology can run;
|
||||
# suite-sets.sh also carries the per-suite rationale.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_SINGLE_RE}" | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "${TEST_RUN}" ]; then
|
||||
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
|
||||
# The value is expanded into a command that a remote shell re-parses over
|
||||
# ssh, so single-quote it — otherwise regex metacharacters like ( | ) $ are
|
||||
# taken as shell syntax on the VM and bash fails before the test binary runs.
|
||||
TEST_RUN_FLAG="-test.run='${TEST_RUN}'"
|
||||
else
|
||||
TEST_RUN_FLAG=""
|
||||
fi
|
||||
@@ -486,12 +528,19 @@ jobs:
|
||||
|
||||
e2e_multi:
|
||||
name: Multi-Node E2E
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode')
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists (multinode|lb|cert) so lb — which only runs here — still
|
||||
# provisions the 3-node env when requested on its own, and the env stays
|
||||
# unprovisioned when none of them are asked for.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
|
||||
# See the matching note on e2e_single: ci.cell is what separates this
|
||||
# job's 3-node telemetry from the single-node job's under one run id.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -541,7 +590,7 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -556,13 +605,20 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# See the matching note on e2e_single.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi-Node E2E
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
--skip-e2e-build
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -697,14 +753,18 @@ jobs:
|
||||
id: e2e
|
||||
run: |
|
||||
set +e
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks|iam)$' | tr '\n' ' ')
|
||||
# Allow-list, not a deny-list — see the single-node job for why.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_MULTI_RE}" | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "${TEST_RUN}" ]; then
|
||||
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
|
||||
# Single-quote for the remote shell — see the single-node job for the
|
||||
# metacharacter-injection reason.
|
||||
TEST_RUN_FLAG="-test.run='${TEST_RUN}'"
|
||||
else
|
||||
TEST_RUN_FLAG=""
|
||||
fi
|
||||
@@ -935,3 +995,136 @@ jobs:
|
||||
-f context=e2e \
|
||||
-f description="$DESC" \
|
||||
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
# Emits one summary doc per run to the central Elastic sink, keyed by
|
||||
# labels.ci_run_id, so Kibana can list "did run X pass, and where are its
|
||||
# artifacts" without hand-aggregating traces-apm*/logs-apm.error*. This is
|
||||
# a separate self-hosted job (not a step on e2e_status) because e2e_status
|
||||
# runs on GitHub-hosted ubuntu-26.04, which has no route to the private
|
||||
# 192.168.1.13 sink, and moving e2e_status off GH-hosted would risk the PR
|
||||
# status-report check.
|
||||
e2e_es_summary:
|
||||
name: ES CI Run Summary
|
||||
needs: [e2e_single, e2e_multi]
|
||||
if: always()
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
env:
|
||||
# Repo var overrides; defaults to the LAN Elastic sink reachable from
|
||||
# ci-single/ci-multi runners. Anonymous ES requests are superuser on
|
||||
# this host, so no credentials are needed for the POST.
|
||||
MULGA_ES_SINK: ${{ vars.MULGA_ES_SINK || '192.168.1.13:9200' }}
|
||||
steps:
|
||||
# Either leg may not have run (e.g. a workflow_dispatch that only
|
||||
# requested single-node suites), so a missing artifact is tolerated —
|
||||
# the aggregation script below treats an absent suite-status.txt as
|
||||
# "no failures from that leg".
|
||||
- name: Download single-node artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
path: ${{ github.workspace }}/es-summary-single
|
||||
|
||||
- name: Download multi-node artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-multi-artifacts-${{ github.run_id }}
|
||||
path: ${{ github.workspace }}/es-summary-multi
|
||||
|
||||
- name: Build and post CI run summary
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
WORKFLOW: ${{ github.workflow }}
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
SINGLE_RESULT: ${{ needs.e2e_single.result }}
|
||||
MULTI_RESULT: ${{ needs.e2e_multi.result }}
|
||||
SINGLE_DIR: ${{ github.workspace }}/es-summary-single
|
||||
MULTI_DIR: ${{ github.workspace }}/es-summary-multi
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SINGLE_STATUS="${SINGLE_DIR}/suite-status.txt"
|
||||
MULTI_STATUS="${MULTI_DIR}/suite-status.txt"
|
||||
|
||||
# Collect "<suite>: FAILED" lines from both legs' suite-status.txt
|
||||
# (either may be absent — a leg that didn't run, or a passing leg
|
||||
# that never wrote the file). Accumulate as newline-separated text
|
||||
# rather than a bash array so this stays correct under `set -u`
|
||||
# even when nothing failed.
|
||||
FAILED_SUITES=""
|
||||
for f in "$SINGLE_STATUS" "$MULTI_STATUS"; do
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*": FAILED") FAILED_SUITES="${FAILED_SUITES}${line%: FAILED}"$'\n' ;;
|
||||
esac
|
||||
done < "$f"
|
||||
done
|
||||
|
||||
FAILED_JSON=$(printf '%s' "$FAILED_SUITES" | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
FAILED_COUNT=$(printf '%s' "$FAILED_JSON" | jq 'length')
|
||||
|
||||
# Mirror e2e_status's single_ok/multi_ok logic exactly so the PR
|
||||
# status and this ES doc can never disagree on overall pass/fail.
|
||||
single_ok=true
|
||||
case "$SINGLE_RESULT" in success | skipped) ;; *) single_ok=false ;; esac
|
||||
multi_ok=true
|
||||
case "$MULTI_RESULT" in success | skipped) ;; *) multi_ok=false ;; esac
|
||||
|
||||
STATE=success
|
||||
if [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
STATE=failure
|
||||
fi
|
||||
|
||||
RUN_STARTED_AT=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq .run_started_at)
|
||||
START_EPOCH=$(date -u -d "${RUN_STARTED_AT}" +%s)
|
||||
NOW_EPOCH=$(date -u +%s)
|
||||
DURATION=$((NOW_EPOCH - START_EPOCH))
|
||||
|
||||
DOC=$(jq -n \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg run_id "${RUN_ID}" \
|
||||
--arg workflow "${WORKFLOW}" \
|
||||
--arg branch "${BRANCH}" \
|
||||
--arg sha "${SHA}" \
|
||||
--arg run_attempt "${RUN_ATTEMPT}" \
|
||||
--arg state "${STATE}" \
|
||||
--arg single_result "${SINGLE_RESULT}" \
|
||||
--arg multi_result "${MULTI_RESULT}" \
|
||||
--argjson duration "${DURATION}" \
|
||||
--arg artifact_url "${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \
|
||||
--argjson failed_suites "${FAILED_JSON}" \
|
||||
'{
|
||||
"@timestamp": $ts,
|
||||
labels: { ci_run_id: $run_id },
|
||||
ci: { workflow: $workflow, branch: $branch, sha: $sha, run_attempt: $run_attempt },
|
||||
state: $state,
|
||||
single_result: $single_result,
|
||||
multi_result: $multi_result,
|
||||
failed_suites: $failed_suites,
|
||||
duration_seconds: $duration,
|
||||
artifact_url: $artifact_url
|
||||
}')
|
||||
|
||||
echo "$DOC" | jq .
|
||||
|
||||
HTTP_CODE=$(curl -sS -o /tmp/es-summary-resp.json -w '%{http_code}' \
|
||||
-X POST "http://${MULGA_ES_SINK}/mulga-ci-runs/_doc" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "${DOC}")
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "::error::ES POST to ${MULGA_ES_SINK}/mulga-ci-runs failed (HTTP ${HTTP_CODE})"
|
||||
cat /tmp/es-summary-resp.json
|
||||
exit 1
|
||||
fi
|
||||
echo "CI run summary doc posted to ${MULGA_ES_SINK}/mulga-ci-runs (HTTP ${HTTP_CODE})"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GPU E2E — drives wattle's persistent, shared-host Spinifex stack over SSH to
|
||||
# exercise GPU passthrough (raw EC2, ECS task GPU exposure, EKS pod GPU
|
||||
# exposure). See docs/development/feature/wattle-deploy-and-ci.md (Part B).
|
||||
# exposure).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# ONE-TIME HOST-SIDE SETUP — NOT part of this repo, a human must do this:
|
||||
@@ -32,7 +32,6 @@
|
||||
# to skip cleanly (job succeeds, all work steps skipped) instead of
|
||||
# queuing behind manual work indefinitely.
|
||||
#
|
||||
# Runbook: docs/development/feature/wattle-deploy-and-ci.md (Part B).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
name: GPU E2E
|
||||
@@ -44,32 +43,6 @@ on:
|
||||
description: "Optional -test.run regex scoped to tests/e2e/gpu (empty = run all GPU tests)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths: &gpu_paths
|
||||
- "spinifex/gpu/**"
|
||||
- "spinifex/instancetypes/**"
|
||||
- "spinifex/daemon/gpu_adapter.go"
|
||||
- "spinifex/daemon/gpu_pool_reconciler.go"
|
||||
- "spinifex/handlers/ecs/gpu_report.go"
|
||||
- "spinifex/handlers/eks/nodegroup.go"
|
||||
- "spinifex/handlers/eks/nodegroup_userdata.go"
|
||||
- "spinifex/vm/vm.go"
|
||||
- "spinifex/vm/lifecycle.go"
|
||||
- "cmd/ecs-agent/gpu_discovery.go"
|
||||
- "cmd/ecs-agent/gpu_ledger.go"
|
||||
- "cmd/spinifex/cmd/admin_gpu.go"
|
||||
- "tests/e2e/gpu/**"
|
||||
- "scripts/images/eks-node-gpu/**"
|
||||
- "scripts/images/ecs-agent-gpu/**"
|
||||
- ".github/workflows/gpu-e2e.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths: *gpu_paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -88,7 +61,11 @@ env:
|
||||
# the GPU suite's own daemon-side spans are emitted by wattle's already-
|
||||
# configured obs-agent, not by this job.
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
# ci.cell=gpu keeps this workflow's telemetry separable from the tofu-env
|
||||
# workflows'. Nothing in this job bakes these attrs into a daemon (see the
|
||||
# note above), so the label only takes effect once wattle's obs-agent is
|
||||
# given them; there is deliberately no ci.job_id lookup for the same reason.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=gpu
|
||||
WATTLE_SSH_HOST: ${{ vars.WATTLE_SSH_HOST || '192.168.1.13' }}
|
||||
WATTLE_SSH_USER: ${{ vars.WATTLE_SSH_USER || 'tf-user' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
@@ -131,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -258,9 +235,8 @@ jobs:
|
||||
# (SPINIFEX_E2E=1 SPINIFEX_MODE=single, ./<suite>.test -test.v
|
||||
# -test.timeout=NNm), plus SPINIFEX_WAN_IP: wattle's ~24 bridges make
|
||||
# net.InterfaceAddrs() enumeration pick a co-tenant bridge instead of
|
||||
# the advertised gateway (mulga-7suds), so the override is required
|
||||
# here (proper fix tracked separately: tests/e2e/harness/env.go should
|
||||
# prefer spinifex.toml's advertised bridge).
|
||||
# the advertised gateway, so the override is required here. The harness
|
||||
# should prefer spinifex.toml's advertised bridge.
|
||||
- name: Run GPU e2e suite on wattle
|
||||
id: e2e
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Image Scripts
|
||||
|
||||
# Separate from spinifex.yml on purpose: scripts/images/ and images/ are shell
|
||||
# and mkosi assets baked into system images, not Go, so they get their own
|
||||
# path-filtered gate instead of running on every Go contributor's push (see
|
||||
# ui.yml for the same pattern applied to the frontend).
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- scripts/images/**
|
||||
- images/**
|
||||
- .github/workflows/image-scripts.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- scripts/images/**
|
||||
- images/**
|
||||
- .github/workflows/image-scripts.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
image_scripts:
|
||||
name: Image Scripts
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run image script tests + shellcheck
|
||||
run: make test-images
|
||||
@@ -1,17 +1,19 @@
|
||||
name: Publish EKS + ECS node AMIs
|
||||
name: Publish System AMIs
|
||||
|
||||
# Builds the system AMIs (libguestfs, on the runner) and publishes each
|
||||
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
|
||||
# iso.mulgadc.com/system-ami/. Four images ship:
|
||||
# iso.mulgadc.com/system-ami/. Five images ship:
|
||||
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
|
||||
# spinifex-ecs-node Alpine ECS container instance
|
||||
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (NVIDIA driver + toolkit)
|
||||
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance
|
||||
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
|
||||
# Operators then install them on any cluster with:
|
||||
# spx admin images import --name spinifex-eks-node
|
||||
# spx admin images import --name spinifex-ecs-node
|
||||
# spx admin images import --name spinifex-eks-node-gpu
|
||||
# spx admin images import --name spinifex-ecs-node-gpu
|
||||
# spx admin images import --name spinifex-rds-postgres
|
||||
#
|
||||
# Manual-only: publishing overwrites the mirror artifact, so it is a deliberate
|
||||
# action, not something every push should trigger. The `variant` input scopes
|
||||
@@ -29,6 +31,7 @@ on:
|
||||
- all
|
||||
- cpu
|
||||
- gpu
|
||||
- rds
|
||||
dry_run:
|
||||
description: "Build + convert only; skip the R2 upload"
|
||||
type: boolean
|
||||
@@ -56,10 +59,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
cpu='{"name":"spinifex-eks-node","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","manifest":"scripts/images/ecs-agent/manifest.conf"}'
|
||||
gpu='{"name":"spinifex-eks-node-gpu","manifest":"scripts/images/eks-node-gpu/manifest.conf"},{"name":"spinifex-ecs-node-gpu","manifest":"scripts/images/ecs-agent-gpu/manifest.conf"}'
|
||||
rds='{"name":"spinifex-rds-postgres","manifest":"scripts/images/rds-postgres/manifest.conf"}'
|
||||
case "${VARIANT}" in
|
||||
cpu) items="${cpu}" ;;
|
||||
gpu) items="${gpu}" ;;
|
||||
*) items="${cpu},${gpu}" ;;
|
||||
rds) items="${rds}" ;;
|
||||
*) items="${cpu},${gpu},${rds}" ;;
|
||||
esac
|
||||
echo "images={\"include\":[${items}]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -95,7 +100,7 @@ jobs:
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
# Self-hosted runner: the Go module + build cache already persists on
|
||||
@@ -105,7 +110,13 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore
|
||||
# Idempotent: the ci-single runner reuses one workspace across matrix
|
||||
# legs and runs, and checkout never scrubs the root go.work. Remove any
|
||||
# stale file so each leg re-inits cleanly instead of failing on
|
||||
# "go.work already exists".
|
||||
run: |
|
||||
rm -f go.work go.work.sum
|
||||
go work init ./spinifex ./viperblock ./predastore
|
||||
|
||||
- name: Install build + upload tooling
|
||||
run: |
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
run: sudo make -C mulga/scripts/iso-builder clean
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Segscan storage oracle — push-triggered (main/dev) + manual dispatch only,
|
||||
# deliberately NOT wired into spinifex.yml's pull_request path.
|
||||
#
|
||||
# tests/integration/segscan_oracle_test.go needs the mulga umbrella repo's
|
||||
# scripts/segscan checked out alongside spinifex (it is a separate Go module,
|
||||
# `package main`, and cannot be imported — see spinifex/testutil/segscanoracle).
|
||||
# spinifex.yml's jobs check out only spinifex/viperblock/predastore and run on
|
||||
# every PR; adding a second-repo checkout there would make every spinifex PR
|
||||
# depend on mulga main being healthy and pay that checkout cost on every run,
|
||||
# for one storage-accounting oracle test. Mirrors e2e.yml's push-only trigger
|
||||
# This never gates a PR before merge, matching how e2e.yml's own
|
||||
# "front gate" job works.
|
||||
name: Segscan Storage Oracle
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
segscan_oracle:
|
||||
name: Segscan Oracle
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- name: Checkout Mulga (scripts/segscan only)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/segscan
|
||||
path: mulga
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore
|
||||
|
||||
- name: Run Segscan Oracle Test
|
||||
env:
|
||||
SEGSCAN_DIR: ${{ github.workspace }}/mulga/scripts/segscan
|
||||
run: make -C spinifex test-segscan-oracle
|
||||
@@ -32,22 +32,29 @@ jobs:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
@@ -70,6 +77,55 @@ jobs:
|
||||
- name: Check Diff Coverage
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
# In-process integration tier (gateway.SetupRoutes over embedded NATS
|
||||
# JetStream). Its own job so it runs in parallel with the unit tiers rather
|
||||
# than adding to their wall time, and it still blocks a PR — unlike e2e.yml's
|
||||
# "Integration (front gate)" job, which is push-triggered against main/dev on
|
||||
# a self-hosted runner and so never gates a PR before merge.
|
||||
integration_tests:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
race_detection:
|
||||
name: Race Detection
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -80,22 +136,29 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
@@ -116,22 +179,29 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
package_json_file: spinifex/services/spinifexui/frontend/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: spinifex/services/spinifexui/frontend/pnpm-lock.yaml
|
||||
@@ -57,3 +57,6 @@ jobs:
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Check for generated route tree drift
|
||||
run: git diff --exit-code -- src/routeTree.gen.ts
|
||||
|
||||
@@ -37,3 +37,6 @@ Thumbs.db
|
||||
|
||||
# Test reports
|
||||
*-report.txt
|
||||
|
||||
# Per-worktree golangci-lint cache
|
||||
/.cache/
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
version: "2"
|
||||
|
||||
issues:
|
||||
max-same-issues: 10
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
@@ -14,24 +17,29 @@ linters:
|
||||
- bidichk # checks for dangerous unicode bidi character sequences
|
||||
- bodyclose # checks whether HTTP response body is closed successfully
|
||||
- canonicalheader # checks whether net/http.Header uses canonical header keys
|
||||
- decorder # checks declaration order of types, consts, vars, and funcs
|
||||
- depguard # blocks imports of packages with a preferred replacement
|
||||
- durationcheck # checks for two durations multiplied together
|
||||
- embeddedstructfieldcheck # checks embedded sync.Mutex/RWMutex in structs
|
||||
- errcheck # checks for unchecked errors in type assertions and I/O
|
||||
- errchkjson # checks types passed to json encoding functions, flags omittable error checks
|
||||
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
|
||||
- errorlint # catches ==/!= on errors, type assertions on errors, and %v instead of %w — all break on wrapped errors
|
||||
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- forcetypeassert # flags unchecked type assertions that can panic
|
||||
- forbidigo # blocks the legacy JetStream/KV symbols
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- godot # checks that declaration comments end in a period
|
||||
- goprintffuncname # checks that printf-like functions are named with f at the end
|
||||
- gosec # inspects source code for security problems
|
||||
- govet # reports suspicious constructs (Printf alignment, shadow, etc.)
|
||||
- iface # detects interface pollution — duplicate interface declarations
|
||||
- ineffassign # detects assignments to existing variables that are not used
|
||||
- iotamixing # flags const blocks that mix iota with non-iota declarations
|
||||
- makezero # finds slice declarations with non-zero initial length followed by append
|
||||
- mirror # catches wrong mirror patterns in bytes/strings usage
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- misspell # finds commonly misspelled English words
|
||||
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- nakedret # finds naked returns in functions
|
||||
- nilerr # catches code that returns nil even after checking err != nil
|
||||
- nilnesserr # catches returning a different nil error variable after err != nil check
|
||||
@@ -43,6 +51,8 @@ linters:
|
||||
- recvcheck # checks for receiver type consistency (pointer vs value)
|
||||
- sloglint # enforces consistent log/slog usage (key-value pairs, no globals)
|
||||
- staticcheck # extensive static analysis checks
|
||||
- testifylint # checks common testify mistakes (assert.Len over assert.Equal on len, etc.)
|
||||
- tparallel # detects inappropriate usage of t.Parallel()
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- usestdlibvars # detects hardcoded values that should use stdlib constants
|
||||
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
|
||||
@@ -50,14 +60,41 @@ linters:
|
||||
- whitespace # detects leading and trailing whitespace in functions
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: log$
|
||||
desc: Use log/slog instead, see https://go.dev/blog/slog
|
||||
- pkg: math/rand$
|
||||
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
|
||||
|
||||
embeddedstructfieldcheck:
|
||||
forbid-mutex: true
|
||||
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-type-assertions: true # replaces forcetypeassert
|
||||
exclude-functions:
|
||||
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
|
||||
|
||||
forbidigo:
|
||||
analyze-types: true
|
||||
forbid:
|
||||
- pattern: '\.JetStream$'
|
||||
pkg: '^github\.com/nats-io/nats\.go$'
|
||||
msg: legacy nc.JetStream() is superseded; construct with jetstream.New(nc).
|
||||
- pattern: '^nats\.(JetStreamContext|KeyValue.*|KeyWatcher|Err(KeyNotFound|KeyExists|BucketNotFound|NoKeysFound|StreamNotFound))$'
|
||||
pkg: '^github\.com/nats-io/nats\.go$'
|
||||
msg: legacy nats KV types/sentinels are superseded; use github.com/nats-io/nats.go/jetstream.
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unchecked errors — errcheck is stricter
|
||||
- G116 # bidi chars — bidichk is more thorough
|
||||
- G117 # use of net/http serve functions (we handle TLS separately)
|
||||
- G703 # path traversal taint analysis — false positives on config file loading
|
||||
- G705 # XSS taint analysis — false positives on internal error responses
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
@@ -65,6 +102,28 @@ linters:
|
||||
- shadow # too noisy to retrofit — all 50 findings are err re-declarations
|
||||
- unusedwrite # false positives on struct field assignments before marshal/send
|
||||
|
||||
modernize:
|
||||
disable:
|
||||
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
|
||||
- stringsbuilder # don't replace small string concatenations with strings.Builder
|
||||
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
|
||||
perfsprint:
|
||||
concat-loop: false
|
||||
err-error: false # don't flag err.Error()
|
||||
errorf: false # don't convert fmt.Errorf to errors.New
|
||||
strconcat: false
|
||||
|
||||
reassign:
|
||||
patterns:
|
||||
- ".*"
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
@@ -76,40 +135,14 @@ linters:
|
||||
- -ST1020 # godoc on exported types — not enforcing yet
|
||||
- -ST1021 # godoc on exported type groups — not enforcing yet
|
||||
- -ST1022 # godoc on exported vars/consts — not enforcing yet
|
||||
- -SA1019 # deprecated usage (handle separately)
|
||||
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
|
||||
- -SA9005 # ineffective break (staticcheck false positives)
|
||||
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unchecked errors — errcheck is stricter
|
||||
- G116 # bidi chars — bidichk is more thorough
|
||||
- G117 # use of net/http serve functions (we handle TLS separately)
|
||||
- G703 # path traversal taint analysis — false positives on config file loading
|
||||
- G705 # XSS taint analysis — false positives on internal error responses
|
||||
- G706 # log injection taint analysis — false positives on structured slog usage
|
||||
|
||||
modernize:
|
||||
testifylint:
|
||||
disable:
|
||||
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
|
||||
- stringsbuilder # don't replace small string concatenations with strings.Builder
|
||||
|
||||
perfsprint:
|
||||
concat-loop: false
|
||||
err-error: false # don't flag err.Error()
|
||||
errorf: false # don't convert fmt.Errorf to errors.New
|
||||
strconcat: false
|
||||
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
|
||||
reassign:
|
||||
patterns:
|
||||
- ".*"
|
||||
- encoded-compare # misidentifies non-JSON strings when identifiers contain "JSON"
|
||||
- float-compare # false positives
|
||||
- require-error # assert-on-error is deliberate where a test continues to collect further failures
|
||||
|
||||
exclusions:
|
||||
presets:
|
||||
@@ -133,3 +166,8 @@ linters:
|
||||
linters: [gosec] # subprocess execution — legitimate in VM orchestration
|
||||
- text: "G304"
|
||||
linters: [gosec] # file path from variable — necessary for config loading
|
||||
|
||||
# aws-sdk-go v1 is deprecated wholesale (EOL 2025-07-31); the v1->v2
|
||||
# migration is tracked separately, so suppress its SA1019 noise for now.
|
||||
- text: "aws-sdk-go is deprecated"
|
||||
linters: [staticcheck]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
GO_PROJECT_NAME := spx
|
||||
SHELL := /bin/bash
|
||||
|
||||
# golangci-lint's default cache (~/.cache/golangci-lint) is keyed by module
|
||||
# path, not worktree path, so parallel worktrees of this module collide and
|
||||
# surface each other's issues. Scope it per worktree.
|
||||
export GOLANGCI_LINT_CACHE := $(CURDIR)/.cache/golangci-lint
|
||||
|
||||
# Detect architecture for cross-platform support
|
||||
ARCH := $(shell uname -m)
|
||||
ifeq ($(ARCH),x86_64)
|
||||
@@ -44,7 +49,7 @@ else
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
|
||||
build: go_build build-installer build-lb-agent
|
||||
build: go_build build-installer build-lb-agent generate-aws-model-coverage
|
||||
|
||||
# Build spinifex-ui frontend (requires pnpm)
|
||||
build-ui:
|
||||
@@ -100,6 +105,12 @@ build-ecs-node-image: ## Build the spinifex-ecs-node AMI (Alpine + containerd +
|
||||
import-ecs-node-image: ## Build + register the ecs-node AMI (requires a running cluster)
|
||||
$(MAKE) build-system-image IMAGE=ecs-agent IMPORT=1
|
||||
|
||||
build-rds-postgres-image: ## Build the spinifex-rds-postgres AMI (Alpine + PostgreSQL 18 + rds-init; IMPORT=1 to register)
|
||||
$(MAKE) build-system-image IMAGE=rds-postgres
|
||||
|
||||
import-rds-postgres-image: ## Build + register the rds-postgres AMI (requires a running cluster)
|
||||
$(MAKE) build-system-image IMAGE=rds-postgres IMPORT=1
|
||||
|
||||
MICROVM_OUT_DIR := build/microvm
|
||||
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
|
||||
@@ -120,10 +131,10 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
|
||||
|
||||
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
|
||||
# Use this before committing to catch CI failures locally.
|
||||
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
|
||||
# integration and race tests skipped to keep quick, they run in CI.
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness
|
||||
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-harness
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
@@ -133,14 +144,41 @@ test-harness:
|
||||
@echo -e "\n....Running e2e harness unit tests...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
|
||||
|
||||
# In-process integration tier: the real gateway router against embedded NATS
|
||||
# JetStream, with only the daemon-side NATS subjects stubbed.
|
||||
AWS_MODEL_CONFORMANCE_REPORT ?= $(CURDIR)/.cache/aws-model-conformance-report.txt
|
||||
AWS_MODEL_CONFORMANCE_MODE ?= fail
|
||||
AWS_MODEL_OPERATION_COVERAGE_REPORT ?= $(CURDIR)/docs/aws-model-operation-coverage.md
|
||||
test-integration:
|
||||
@echo -e "\n....Running in-process integration tests...."
|
||||
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
|
||||
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
|
||||
@$(MAKE) --no-print-directory generate-aws-model-coverage
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_REPORT)"
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@mkdir -p $(dir $(AWS_MODEL_OPERATION_COVERAGE_REPORT))
|
||||
@go run ./cmd/aws-model-coverage > $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
|
||||
aws-model-coverage: generate-aws-model-coverage
|
||||
@cat $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
|
||||
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
|
||||
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
|
||||
# is not the default local or CI layout, so this is a separate target from
|
||||
# test-integration rather than folded into it. Skips itself when segscan's
|
||||
# source isn't found.
|
||||
test-segscan-oracle:
|
||||
@echo -e "\n....Running segscan storage oracle test...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=integration,segscanoracle -timeout 120s ./tests/integration/... -run TestSegscanOracle $(_RACEQ)
|
||||
|
||||
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
|
||||
# of services/suites/fixtures + on-disk path existence. Subject content
|
||||
# vs source is enforced separately in Bead 5 drift lint.
|
||||
# of services/suites/fixtures + on-disk path existence.
|
||||
manifest-check:
|
||||
@echo -e "\n....Checking service-interfaces.yaml...."
|
||||
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
|
||||
|
||||
# Drift guards (Bead 5): direct-create fixture lint + NATS subject lint,
|
||||
# Drift guards: direct-create fixture lint + NATS subject lint,
|
||||
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
|
||||
# drift beyond the baseline.
|
||||
manifest-lint:
|
||||
@@ -154,19 +192,19 @@ manifest-lint-update:
|
||||
# Run unit tests
|
||||
test:
|
||||
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
|
||||
LOG_IGNORE=1 go test -timeout 120s ./spinifex/...
|
||||
GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/...
|
||||
|
||||
# Run unit tests with coverage profile
|
||||
COVERPROFILE ?= coverage.out
|
||||
test-cover:
|
||||
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
|
||||
$(_Q)LOG_IGNORE=1 go test -timeout 120s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... $(_COVQ)
|
||||
$(_Q)GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
|
||||
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
|
||||
|
||||
# Run unit tests with race detector
|
||||
test-race:
|
||||
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
|
||||
$(_Q)LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... $(_RACEQ)
|
||||
$(_Q)GOFIPS140=v1.0.0 LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... $(_RACEQ)
|
||||
|
||||
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
|
||||
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
|
||||
@@ -174,6 +212,39 @@ test-actions:
|
||||
@echo -e "\n....Running action tests...."
|
||||
LOG_IGNORE=1 go test -timeout 60s ./.github/actions/...
|
||||
|
||||
# Shell suites + shellcheck for scripts/images/ and images/mkosi.profiles/
|
||||
# helpers baked into system images. Kept out of `preflight` (a dedicated CI
|
||||
# job gates it on scripts/images/**+images/mkosi.profiles/** changes instead)
|
||||
# so image-asset churn doesn't run on every Go contributor's commit.
|
||||
#
|
||||
# images/mkosi.profiles/ ships scripts with no .sh suffix (mkosi's own
|
||||
# mkosi.*.chroot lifecycle hooks, and wrapper binaries like vllm-serve under
|
||||
# mkosi.extra/) so a plain `-name '*.sh'` glob silently skips exactly the
|
||||
# files most worth linting. Sources there are found by shebang instead of
|
||||
# name; this also means a profile that ships no scripts at all yields an
|
||||
# empty match rather than an error.
|
||||
test-images:
|
||||
@echo -e "\n....Running scripts/images/**/*_test.sh...."
|
||||
@for t in $$(find scripts/images -name '*_test.sh' | sort); do \
|
||||
echo "-- $$t"; \
|
||||
bash "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running images/mkosi.profiles/**/*_test.sh...."
|
||||
@for t in $$(find images/mkosi.profiles -name '*_test.sh' | sort); do \
|
||||
echo "-- $$t"; \
|
||||
bash "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running shellcheck over scripts/images/**/*.sh...."
|
||||
shellcheck -S warning $$(find scripts/images -name '*.sh' | sort)
|
||||
@echo -e "\n....Running shellcheck over images/mkosi.profiles/** shell sources...."
|
||||
@srcs="$$(grep -rlIE '^#!/bin/(bash|sh)' images/mkosi.profiles 2>/dev/null | sort)"; \
|
||||
if [ -n "$$srcs" ]; then \
|
||||
shellcheck -S warning $$srcs; \
|
||||
else \
|
||||
echo " (no shell sources found under images/mkosi.profiles)"; \
|
||||
fi
|
||||
@echo " test-images ok"
|
||||
|
||||
# Check that new/changed code meets coverage threshold (runs tests first)
|
||||
diff-coverage: test-cover
|
||||
@QUIET=$(QUIET) scripts/diff-coverage.sh $(COVERPROFILE)
|
||||
@@ -189,6 +260,20 @@ bench:
|
||||
# `make install-microvm` explicitly the first time.
|
||||
deploy: build
|
||||
sudo install -m 755 bin/spx /usr/local/bin/spx
|
||||
@if [ "$${SKIP_ASSET_PREFLIGHT:-}" != "1" ]; then \
|
||||
if ! sudo /usr/local/bin/spx admin preflight; then \
|
||||
echo ""; \
|
||||
echo "[deploy] host-asset preflight failed: this node's helper scripts or sudoers"; \
|
||||
echo " grants don't match the binary just installed. Remediate with:"; \
|
||||
echo " scripts/update-nodes.sh (from the mulga root)"; \
|
||||
echo " or:"; \
|
||||
echo " make reinstall"; \
|
||||
echo " Set SKIP_ASSET_PREFLIGHT=1 to bypass for a deliberate binary-only push."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
else \
|
||||
echo "[deploy] SKIP_ASSET_PREFLIGHT=1 — skipping host-asset preflight"; \
|
||||
fi
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
|
||||
$(MAKE) install-microvm; \
|
||||
else \
|
||||
@@ -211,7 +296,7 @@ install-system:
|
||||
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
-o Dpkg::Options::="--force-confdef" \
|
||||
-o Dpkg::Options::="--force-confold" \
|
||||
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils qemu-kvm \
|
||||
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils \
|
||||
ovmf qemu-efi-aarch64 \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
|
||||
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
|
||||
@@ -243,7 +328,7 @@ quickinstall: install-system install-go install-aws
|
||||
|
||||
lint:
|
||||
@echo "Running golangci-lint..."
|
||||
$(_Q)golangci-lint run ./...
|
||||
$(_Q)scripts/run-gate.sh golangci-lint golangci-lint run ./...
|
||||
@echo " golangci-lint ok"
|
||||
|
||||
fix:
|
||||
@@ -251,9 +336,15 @@ fix:
|
||||
|
||||
govulncheck:
|
||||
@echo "Running govulncheck..."
|
||||
$(_Q)go tool govulncheck ./...
|
||||
$(_Q)scripts/run-gate.sh govulncheck go tool govulncheck ./...
|
||||
@echo " govulncheck ok"
|
||||
|
||||
# NilAway — advisory nil-panic analysis. Not in preflight due to false positives
|
||||
nilaway:
|
||||
@echo "Running nilaway..."
|
||||
$(_Q)scripts/run-gate.sh nilaway go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./...
|
||||
@echo " nilaway ok"
|
||||
|
||||
# Build release tarballs — use distro-ARCH for single arch, distro for both
|
||||
distro: distro-amd64 distro-arm64
|
||||
@echo ""
|
||||
@@ -297,8 +388,8 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness manifest-check manifest-lint manifest-lint-update \
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-images test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
lint fix govulncheck nilaway \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/banner.svg" alt="Spinifex — AWS-Compatible Infrastructure Stack" width="900">
|
||||
<img src=".github/assets/banner.svg" alt="Spinifex: the open, AWS-compatible cloud you run yourself. EC2, EBS, S3, VPC, and IAM on your infrastructure, at the edge, or on a Neocloud." width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-docs.mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
|
||||
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/home-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-is-spinifex">What is Spinifex?</a> ·
|
||||
<a href="#the-platform">Platform</a> ·
|
||||
<a href="#three-ways-to-deploy">Deploy</a> ·
|
||||
<a href="#aws-compatibility">AWS Compatibility</a> ·
|
||||
<a href="#core-components">Components</a> ·
|
||||
<a href="#architecture-at-a-glance">Architecture</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
@@ -19,31 +21,69 @@
|
||||
|
||||
---
|
||||
|
||||
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
|
||||
# Spinifex: the open, AWS-compatible cloud you run yourself
|
||||
|
||||
Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
|
||||
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line. The exact build you ship to AWS, Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
|
||||
|
||||
## What is Spinifex?
|
||||
|
||||
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
|
||||
Spinifex is an open-source infrastructure platform that recreates the AWS service surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises environments. Run cloud-native software without a hyperscaler.
|
||||
|
||||
It's designed for developers and operators who need:
|
||||
Most AWS alternatives wrap an API and call it a cloud. We rebuilt the engineering underneath: distributed object storage with erasure coding, block storage built to survive failure, and compute on bare metal. That depth is the reason your software runs unchanged, instead of rewritten.
|
||||
|
||||
- Cloud-like infrastructure without the cloud
|
||||
- Minimal dependencies, full control
|
||||
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
|
||||
- A secure cloud environment you control and own the entire hardware, network and software stack
|
||||
It's built for teams that need:
|
||||
|
||||
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren't feasible.
|
||||
- The AWS tooling they already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
|
||||
- Full control of the stack: your hardware, your network, your data, your keys
|
||||
- A cloud that keeps running offline, through disconnection, with no external control plane
|
||||
- An open core (AGPL-3.0), auditable and yours to keep, with no lock-in
|
||||
|
||||
You change *where* your software runs, not *what* it is.
|
||||
|
||||
## The Platform
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/platform.svg" alt="The Mulga Platform — Hardware to Applications" width="900">
|
||||
<img src=".github/assets/platform.svg" alt="The Spinifex stack: your apps and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU and GPU hardware, deployable to Neocloud, on-premise, or the edge." width="900">
|
||||
</p>
|
||||
|
||||
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
|
||||
|
||||
## Three Ways To Deploy
|
||||
|
||||
One AWS-compatible surface, three deployment shapes. Pick the one that matches your reality; the platform on top is the same.
|
||||
|
||||
### Neocloud
|
||||
|
||||
Lift and shift onto a partner Neocloud. Move workloads off the hyperscalers and onto our Neocloud partner ecosystem without rewriting them. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
|
||||
|
||||
### On-premise
|
||||
|
||||
Bring your own hardware and host Spinifex in your own data centre. A real multi-node HA cluster integrated with your storage and networking, with full control of stack, data, and jurisdiction. A predictable bill, no egress surprises, an auditable open-source core.
|
||||
|
||||
### Edge
|
||||
|
||||
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels, factories, and clinics. Compute next to the data, running through disconnection, on hardware you own. The same AWS APIs your software already uses.
|
||||
|
||||
## AWS Compatibility
|
||||
|
||||
Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform: everything you deploy on AWS deploys on Spinifex unchanged. At the edge, on-premise, or on a partner Neocloud.
|
||||
|
||||
| Service | What it is | Status |
|
||||
| --- | --- | --- |
|
||||
| **EC2** | Compute | Available |
|
||||
| **EBS** | Block Storage | Available |
|
||||
| **S3** | Object Storage | Available |
|
||||
| **VPC** | Networking | Available |
|
||||
| **IAM** | Identity & Auth | Available |
|
||||
| **ALB / NLB** | Load Balancers | Available |
|
||||
| **EKS** | Kubernetes | Available |
|
||||
| **ECR** | Container Registry | Available |
|
||||
| **ECS** | Container Service | Available |
|
||||
| **RDS** | Databases | Available |
|
||||
| **Bedrock** | AI Deployment | Q3 2026 |
|
||||
|
||||
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
|
||||
|
||||
## Core Components
|
||||
|
||||
### Spinifex (Compute Service – EC2 Alternative)
|
||||
@@ -72,26 +112,34 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
|
||||
- Multipart uploads, streaming reads/writes
|
||||
- Data redundancy with Reed-Solomon encoding
|
||||
|
||||
### Northstar (Authoritative DNS)
|
||||
|
||||
[Northstar](https://github.com/mulgadc/northstar) is a lightweight authoritative DNS server that handles both internal service discovery and public-facing DNS for a Spinifex cluster. Zones are human-readable TOML files, served from the local filesystem or synced from Predastore, keeping DNS configuration in the same object storage as the rest of your infrastructure.
|
||||
|
||||
- Authoritative DNS over UDP, TCP, and DNS-over-TLS
|
||||
- Service discovery for cluster components via SRV records
|
||||
- Zone files stored locally or in S3-compatible storage
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture — AWS Gateway, NATS bus, service daemons" width="900">
|
||||
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
|
||||
</p>
|
||||
|
||||
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless — scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
|
||||
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Just systemd units, a NATS cluster, and your hardware. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
|
||||
|
||||
## Key Features
|
||||
|
||||
AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, or SDKs you already use.
|
||||
|
||||
- Designed for Control – Run on your own terms, whether that's in a datacenter, remote site, or sensitive environment.
|
||||
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
|
||||
- Works Offline – No reliance on centralized cloud services or external networks.
|
||||
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
|
||||
- **AWS-compatible APIs.** Use the AWS CLI, SDKs, and Terraform you already know. Repoint your endpoint and ship; nothing to rewrite.
|
||||
- **Zero cloud dependency.** Runs entirely on your hardware. No phone-home, no control plane, no external authority. Works fully offline.
|
||||
- **Bare-metal compute.** QEMU-based with direct hardware access. No hypervisor tax, no abstraction overhead.
|
||||
- **Built-in storage.** Block and object storage included: NVMe caching, Reed-Solomon erasure coding, and replication out of the box.
|
||||
- **Edge-first architecture.** Designed for disconnected, contested, and resource-constrained environments from day one.
|
||||
- **Open core, no lock-in.** AGPL-3.0 with a commercial option. Inspect it, modify it, deploy it. The platform is yours to keep.
|
||||
|
||||
## Installation
|
||||
|
||||
Installation requires an Ubuntu / Debian system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
|
||||
Installation requires an Ubuntu 26.04 or Debian 13 system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for installing Spinifex.
|
||||
|
||||
### Bare Metal ISO
|
||||
|
||||
@@ -101,16 +149,11 @@ The recommended installation is a [bootable x86 installer](https://iso.mulgadc.c
|
||||
curl -fLO https://iso.mulgadc.com/spinifex.iso
|
||||
```
|
||||
|
||||
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware. The install guide walks through the full process.
|
||||
|
||||
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware.
|
||||
|
||||
### Single Node Install
|
||||
|
||||
The installation is straightforward to set up and running on a single node for testing purposes. Debian 13 is currently supported, additional Linux distributions are on the immediate roadmap.
|
||||
|
||||
>*Prerequisite:* Linux bridge for networking.
|
||||
|
||||
Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) prerequisites for setup details.
|
||||
>*Prerequisite:* Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) for setup details.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
@@ -128,7 +171,7 @@ aws ec2 describe-instance-types
|
||||
|
||||
### Development Setup
|
||||
|
||||
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation
|
||||
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation.
|
||||
|
||||
### Component Repositories
|
||||
|
||||
@@ -136,6 +179,7 @@ Spinifex coordinates these independent components:
|
||||
|
||||
- **[Predastore](https://github.com/mulgadc/predastore)** - S3-compatible object storage
|
||||
- **[Viperblock](https://github.com/mulgadc/viperblock)** - EBS-compatible block storage
|
||||
- **[Northstar](https://github.com/mulgadc/northstar)** - Authoritative DNS server
|
||||
|
||||
Each component can be developed independently. See component-specific documentation for focused development guides.
|
||||
|
||||
@@ -149,10 +193,6 @@ Spinifex ships with a built-in web console — an optional alternative to the AW
|
||||
|
||||
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
|
||||
|
||||
```bash
|
||||
open https://YOUR_NODE_IP:3000
|
||||
```
|
||||
|
||||
- **Same API, different surface.** Every action in the UI is the same AWS SigV4 call the CLI makes — so RBAC, audit trails, and IAM policies apply uniformly.
|
||||
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed — no separate user database.
|
||||
- **Self-hosted, works offline.** The UI is embedded in the Spinifex binary and served from the node itself. No external CDN, no analytics calls, no cloud dependency.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/var/log/spinifex/*.log {
|
||||
# The directory is 0775 root:spinifex so each service user can write its own
|
||||
# log. logrotate refuses to rotate inside a group-writable directory unless
|
||||
# it is told which user to rotate as.
|
||||
su root spinifex
|
||||
daily
|
||||
rotate 14
|
||||
compress
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
#!/bin/sh
|
||||
# JetStream liveness watchdog for spinifex-nats.
|
||||
#
|
||||
# NATS can disable JetStream at runtime (e.g. a filesystem permission error while
|
||||
# flushing stream state) and keep the process alive serving KV as read-only. That
|
||||
# is not a process failure, so Restart=on-failure never fires and KV-backed
|
||||
# services (awsgw IAM bucket init, etc.) stall until NATS is restarted by hand.
|
||||
# NATS can stop accepting JetStream writes at runtime while the process stays
|
||||
# alive and keeps serving reads. That is not a process failure, so
|
||||
# Restart=on-failure never fires and KV-backed services (awsgw IAM bucket init,
|
||||
# etc.) stall until NATS is restarted by hand.
|
||||
#
|
||||
# This probes the NATS monitoring endpoint's JS health and restarts the service
|
||||
# only when JetStream is sustainedly unhealthy while the service is running. The
|
||||
# multi-sample window avoids restarting during transient startup/quorum 503s.
|
||||
# Two distinct latches produce that symptom, and they need different probes:
|
||||
#
|
||||
# 1. Server-wide. handleOutOfSpace calls ShutdownJetStream and sets an
|
||||
# internal disabled flag that only a restart clears. healthz reports it.
|
||||
# 2. Per message block. filestore records a write error on the block and
|
||||
# returns it on every later write; nothing clears it short of a restart.
|
||||
# healthz does NOT report it, because the server-wide state is untouched —
|
||||
# only an actual write observes it.
|
||||
#
|
||||
# So a health check alone misses case 2 entirely. Both probes run here, and a
|
||||
# sustained failure of either restarts the service. The multi-sample window
|
||||
# avoids restarting during transient startup/quorum 503s.
|
||||
|
||||
set -eu
|
||||
|
||||
HEALTH_URL="http://127.0.0.1:8222/healthz?js-enabled-only=true"
|
||||
SERVICE="spinifex-nats.service"
|
||||
SPX="${SPX_BIN:-/usr/local/bin/spx}"
|
||||
SAMPLES=3 # consecutive failing samples required before acting
|
||||
SAMPLE_GAP=2 # seconds between samples
|
||||
PROBE_TIMEOUT=5 # seconds for one canary round-trip
|
||||
|
||||
# Only act on a running service: a stopped/activating unit is systemd's to manage,
|
||||
# and try-restart on it would either no-op or fight an in-flight (re)start.
|
||||
@@ -30,15 +41,29 @@ if ! command -v curl >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A single 200 means JetStream is healthy — exit fast on the common case.
|
||||
js_healthy() {
|
||||
curl -fsS -o /dev/null --max-time 5 "$HEALTH_URL" 2>/dev/null
|
||||
}
|
||||
|
||||
# Round-trips a canary key through the cluster-state bucket. Absent spx this
|
||||
# degrades to the health check alone rather than restarting on a missing tool.
|
||||
js_writable() {
|
||||
if [ ! -x "$SPX" ]; then
|
||||
return 0
|
||||
fi
|
||||
"$SPX" admin node js-probe --timeout "${PROBE_TIMEOUT}s" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
failure=""
|
||||
i=1
|
||||
while [ "$i" -le "$SAMPLES" ]; do
|
||||
if js_healthy; then
|
||||
exit 0
|
||||
if js_writable; then
|
||||
exit 0
|
||||
fi
|
||||
failure="JetStream accepts no writes (canary round-trip failed) while $HEALTH_URL reports healthy"
|
||||
else
|
||||
failure="JetStream unhealthy at $HEALTH_URL"
|
||||
fi
|
||||
if [ "$i" -lt "$SAMPLES" ]; then
|
||||
sleep "$SAMPLE_GAP"
|
||||
@@ -46,5 +71,5 @@ while [ "$i" -le "$SAMPLES" ]; do
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "nats-js-watchdog: JetStream unhealthy on $SAMPLES consecutive probes ($HEALTH_URL); restarting $SERVICE" >&2
|
||||
echo "nats-js-watchdog: $failure on $SAMPLES consecutive probes; restarting $SERVICE" >&2
|
||||
systemctl try-restart "$SERVICE"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Helper script for spinifex-predastore.service
|
||||
# Detects host, port, backend, and node-id from spinifex.toml
|
||||
# then execs spx. Falls back to single-node defaults if no config found.
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
BIND="0.0.0.0:8443"
|
||||
# -1 = co-located mode (all [[db]] peers in one process). Single-node spinifex
|
||||
# templates list 3 DB peers on 127.0.0.1; only -1 can serve that topology.
|
||||
# Multi-node spinifex.toml emits node_id = N (N >= 1) which overrides below.
|
||||
# predastore rejects NODE_ID=0 since d3a02f0 (strconv.Atoi-of-garbage guard).
|
||||
NODE_ID="-1"
|
||||
|
||||
if [ -f "$CONF" ]; then
|
||||
H=$(awk -F'"' '/\[nodes\..*\.predastore\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$H" ]; then
|
||||
BIND="$H"
|
||||
fi
|
||||
|
||||
N=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2);print $2;exit}' "$CONF")
|
||||
if [ -n "$N" ]; then
|
||||
NODE_ID="$N"
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
|
||||
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
|
||||
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
|
||||
|
||||
exec /usr/local/bin/spx service predastore start
|
||||
@@ -7,6 +7,9 @@ Before=ssh.service
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/ssh-keygen -A
|
||||
RemainAfterExit=yes
|
||||
# No ExecStop: the keygen process has already exited by the time this unit
|
||||
# is stopped, so there is nothing to signal.
|
||||
TimeoutStopSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -14,8 +14,15 @@ ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service awsgw start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# No SIGTERM handler in this process: it exits immediately on signal, so this
|
||||
# only bounds an unexpectedly wedged process before SIGKILL.
|
||||
TimeoutStopSec=10
|
||||
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
|
||||
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
|
||||
|
||||
@@ -7,6 +7,8 @@ Wants=openvswitch-ipsec.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
# ZFS root: installer raises SPINIFEX_RESERVED_MEM_GB by the ARC ceiling the admission gate cannot see
|
||||
EnvironmentFile=-/etc/spinifex/host.env
|
||||
Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-daemon
|
||||
@@ -19,13 +21,17 @@ KillMode=process
|
||||
TimeoutStopSec=15
|
||||
# RG-4: infra tier — reaped only after every guest
|
||||
OOMScoreAdjust=-500
|
||||
# Guest QEMU shares this cgroup: the ceiling tracks the slice budget, not daemon overhead
|
||||
MemoryHigh=75%
|
||||
MemoryMax=85%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
|
||||
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
|
||||
# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)
|
||||
Environment=XDG_RUNTIME_DIR=/run/spinifex
|
||||
|
||||
# RG-9: daemon tier — privileged by necessity (GPU vfio). NoNewPrivileges omitted (RG-10: sudo ip/ovs-vsctl/dhcpcd)
|
||||
# RG-9: daemon tier — privileged by necessity (GPU vfio). No CAP_NET_ADMIN here,
|
||||
# so unlike vpcd it still escalates ip/sysctl and NoNewPrivileges stays omitted.
|
||||
AmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE
|
||||
ProtectSystem=full
|
||||
PrivateDevices=no
|
||||
|
||||
@@ -5,10 +5,16 @@ After=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Runs as root: the probe is a local curl to the NATS monitoring port, and the
|
||||
# remedy is `systemctl try-restart spinifex-nats` when JetStream is disabled while
|
||||
# the process stays alive (which Restart=on-failure cannot catch).
|
||||
# Runs as root: the probes are a local curl to the NATS monitoring port and a
|
||||
# canary KV round-trip via spx, and the remedy is `systemctl try-restart
|
||||
# spinifex-nats` when JetStream stops accepting writes while the process stays
|
||||
# alive (which Restart=on-failure cannot catch).
|
||||
ExecStart=/var/lib/spinifex/nats-js-watchdog.sh
|
||||
# Sized for the probe loop's own worst case (3 samples x (5s curl + 5s canary)
|
||||
# + 2 gaps x 2s = 34s): a stop mid-cycle lets the script finish rather than
|
||||
# cutting it off. Only the failing path runs that long; a healthy node exits on
|
||||
# the first sample.
|
||||
TimeoutStopSec=45
|
||||
|
||||
# Hardening (kept compatible with talking to PID1 over /run/systemd).
|
||||
NoNewPrivileges=yes
|
||||
|
||||
@@ -12,7 +12,15 @@ Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service nats start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# nats-server installs its own SIGTERM handler (JetStream store shutdown, raft
|
||||
# stepdown, client drain) with no internal deadline; every dependent service
|
||||
# stops before nats does, so size this for nats's own graceful-stop needs.
|
||||
TimeoutStopSec=20
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
|
||||
|
||||
# Filesystem access
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
[Unit]
|
||||
Description=Spinifex Northstar (Authoritative DNS)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-predastore.service
|
||||
Wants=spinifex-predastore.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-northstar
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service northstar start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# server.Shutdown() is called with an internal 5s context; margin covers the
|
||||
# NATS unsubscribe/close and process exit that follow it.
|
||||
TimeoutStopSec=10
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/northstar/
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/northstar /etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/northstar /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
# RG-9: exactly CAP_NET_BIND_SERVICE so the unprivileged user binds :53 (no root).
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
@@ -14,10 +14,20 @@ Slice=spinifex-system.slice
|
||||
User=spinifex-storage
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/var/lib/spinifex/predastore-start.sh
|
||||
ExecStart=/usr/local/bin/spx service predastore start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Matches viperblock's drain budget: viperblock's WAL flush writes into
|
||||
# predastore, and After= already stops viperblock first, but this is the
|
||||
# backstop if that ordering is ever bypassed (direct stop, future edit).
|
||||
TimeoutStopSec=30
|
||||
OOMScoreAdjust=-500
|
||||
# Legitimately the most memory-hungry infra service (S3 buffering/caching);
|
||||
# High/Max stay well above idle so normal load isn't throttled, while still
|
||||
# bounding a leak short of the slice-wide cap.
|
||||
MemoryHigh=40%
|
||||
MemoryMax=55%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.toml
|
||||
Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
|
||||
|
||||
@@ -16,6 +16,13 @@ ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service qmp-collector start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Poll loop selects on ctx.Done() directly and closes NATS locally; shutdown
|
||||
# is near-instant, so this only bounds an unexpectedly wedged process.
|
||||
TimeoutStopSec=10
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
|
||||
# Same runtime dir as the daemon: telemetry sockets + metadata live there
|
||||
|
||||
@@ -9,9 +9,13 @@ After=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.serv
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/true
|
||||
# ExecStop only runs when the target/host goes down, not on a single-service
|
||||
# restart — so a daemon restart still leaves guests running (DDIL).
|
||||
ExecStop=/usr/local/bin/spx admin node drain --local --timeout=120s
|
||||
# ExecStop fires on ANY target stop, including the stop half of `systemctl
|
||||
# restart spinifex.target` (a rolling deploy) -- PartOf propagates regardless
|
||||
# of whether the host is actually going down. --only-if-host-stopping drains
|
||||
# only when systemd itself is unwinding into shutdown.target; a plain restart
|
||||
# or `systemctl stop spinifex.target` falls through so QEMU/nbdkit survive
|
||||
# (KillMode=process) and the daemon reconnects to them on the way back up.
|
||||
ExecStop=/usr/local/bin/spx admin node drain --local --timeout=120s --only-if-host-stopping
|
||||
TimeoutStopSec=180
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -12,16 +12,24 @@ Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service spinifex-ui start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# server.Shutdown() uses an internal 30s context (WriteTimeout=120s covers
|
||||
# long streaming proxy responses, e.g. console passthrough); add margin above it.
|
||||
TimeoutStopSec=40
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
|
||||
Environment=SPINIFEX_UI_BASE_DIR=/var/lib/spinifex/spinifex-ui/
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/log/spinifex
|
||||
ReadWritePaths=/var/lib/spinifex/spinifex-ui /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
|
||||
@@ -15,6 +15,16 @@ ExecStart=/usr/local/bin/spx service viperblock start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling bounds a leak in this service alone rather than
|
||||
# tripping the slice-wide cap, which would pick an arbitrary victim.
|
||||
MemoryHigh=15%
|
||||
MemoryMax=25%
|
||||
# KillMode=process: nbdkit still serving a guest survives a restart here, the
|
||||
# way guest QEMU survives a daemon restart. Tearing one out mid-write corrupts
|
||||
# that guest's filesystem; idle nbdkit is reaped explicitly on shutdown.
|
||||
KillMode=process
|
||||
# Bounds this process's own WAL drain, which the plugin caps at 20s.
|
||||
TimeoutStopSec=30
|
||||
EnvironmentFile=/etc/spinifex/systemd.env
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
|
||||
|
||||
@@ -14,13 +14,23 @@ ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service vpcd start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Waits for an in-flight OVN drift-reconcile pass to return before exiting;
|
||||
# no sub-timeout bounds that call today, so this covers a normal pass.
|
||||
TimeoutStopSec=20
|
||||
OOMScoreAdjust=-500
|
||||
# Per-service ceiling: bounds a leak here instead of the slice-wide cap.
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
|
||||
|
||||
# RG-9: network tier. Per-tap IMDS removed the in-process setns, so vpcd no
|
||||
# longer enters a per-VPC netns: CAP_SYS_ADMIN is dropped and the filesystem
|
||||
# sandbox is restored. NoNewPrivileges stays off (RG-10: sudo ip/ovs-vsctl/dhcpcd).
|
||||
# sandbox is restored.
|
||||
#
|
||||
# RG-10: these are ambient so ip/iptables/sysctl/arping inherit them on exec and
|
||||
# vpcd holds no sudoers rules at all. NoNewPrivileges stays off pending the ADR
|
||||
# update that retires the last escalation path.
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
NoNewPrivileges=no
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=Spinifex Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dispatch := gateway.AWSOperationInventory()
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := dispatch[string(service)]
|
||||
var modelInventory awsmodel.DispatchInventory
|
||||
switch {
|
||||
case ok:
|
||||
modelInventory = awsmodel.DispatchInventory{
|
||||
Registered: inventory.Registered,
|
||||
Stubbed: inventory.Stubbed,
|
||||
Unsupported: inventory.Unsupported,
|
||||
}
|
||||
case service == awsmodel.S3:
|
||||
modelInventory = awsmodel.DispatchInventory{
|
||||
Opaque: true,
|
||||
Note: "Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.",
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: no gateway inventory for %s\n", service)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
coverage, err := awsmodel.CompareOperations(service, modelInventory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
coverages = append(coverages, coverage)
|
||||
}
|
||||
|
||||
fmt.Print(awsmodel.RenderCoverageMarkdown(coverages))
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
// Usage (must run as root — needs AF_PACKET):
|
||||
//
|
||||
// sudo dhcptest --iface=br-wan
|
||||
// sudo dhcptest --iface=br-wan --mac=02:00:00:7c:b7:b9 --promisc
|
||||
// sudo dhcptest --iface=br-wan --mac=02:54:df:71:22:5c --promisc
|
||||
// sudo dhcptest --iface=br-wan --spinifex-id=gateway-wan1
|
||||
package main
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -36,6 +37,7 @@ func main() {
|
||||
timeout := flag.Duration("timeout", 10*time.Second, "Per-attempt DHCP timeout")
|
||||
retries := flag.Int("retries", 3, "Number of DORA attempts before giving up")
|
||||
verbose := flag.Bool("verbose", false, "Print full DHCP packet contents")
|
||||
broadcast := flag.Bool("broadcast", false, "Set the BOOTP broadcast flag, as vpcd's Acquire path does, so replies come back to ff:ff:ff:ff:ff:ff rather than unicast to the bridge MAC")
|
||||
flag.Parse()
|
||||
|
||||
// Resolve the hardware address to use.
|
||||
@@ -44,20 +46,25 @@ func main() {
|
||||
|
||||
switch {
|
||||
case *spinifexID != "":
|
||||
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
|
||||
// vpcd's own derivation, so the probe leases under the MAC the daemon
|
||||
// would have used for this id.
|
||||
hwAddr, err = dhcp.DeriveMAC(*spinifexID)
|
||||
if err != nil {
|
||||
log.Fatalf("generateMAC(%q): %v", *spinifexID, err)
|
||||
slog.Error("Could not derive MAC", "spinifex-id", *spinifexID, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
|
||||
case *macStr != "":
|
||||
hwAddr, err = net.ParseMAC(*macStr)
|
||||
if err != nil {
|
||||
log.Fatalf("parse --mac %q: %v", *macStr, err)
|
||||
slog.Error("Could not parse --mac", "mac", *macStr, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
hwAddr, err = ifaceMAC(*iface)
|
||||
if err != nil {
|
||||
log.Fatalf("get MAC for %s: %v", *iface, err)
|
||||
slog.Error("Could not get MAC for interface", "iface", *iface, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("using real interface MAC: %s\n", hwAddr)
|
||||
}
|
||||
@@ -83,7 +90,7 @@ func main() {
|
||||
|
||||
if *promisc {
|
||||
if err := setPromisc(*iface, true); err != nil {
|
||||
log.Printf("WARNING: failed to set promisc on %s: %v", *iface, err)
|
||||
slog.Warn("Failed to set promisc on interface", "iface", *iface, "error", err)
|
||||
} else {
|
||||
fmt.Printf("set %s promisc ON\n\n", *iface)
|
||||
}
|
||||
@@ -96,25 +103,21 @@ func main() {
|
||||
nclient4.WithRetry(*retries),
|
||||
}
|
||||
if *verbose {
|
||||
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: log.New(os.Stderr, "[nclient4] ", 0)}))
|
||||
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: slogPrintfer{}}))
|
||||
}
|
||||
|
||||
client, err := nclient4.New(*iface, opts...)
|
||||
if err != nil {
|
||||
log.Fatalf("nclient4.New(%s): %v", *iface, err)
|
||||
slog.Error("Could not create nclient4 client", "iface", *iface, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Identity modifiers — client-id / hostname / vendor-class for upstream lease tagging.
|
||||
var mods []dhcpv4.Modifier
|
||||
if cid != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
|
||||
}
|
||||
if *hostname != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(*hostname)))
|
||||
}
|
||||
if *vendorClass != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(*vendorClass)))
|
||||
// Identity modifiers — client-id / hostname / vendor-class for upstream lease
|
||||
// tagging. Built by the vpcd code path so the probe cannot drift from it.
|
||||
mods := dhcp.IdentityModifiers(cid, *hostname, *vendorClass, hwAddr)
|
||||
if *broadcast {
|
||||
mods = append(mods, dhcpv4.WithBroadcast(true))
|
||||
}
|
||||
|
||||
fmt.Println("Sending DHCP Discover...")
|
||||
@@ -141,14 +144,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// generateMAC is the same deterministic hash used by vpcd's topology.go.
|
||||
// It produces a locally-administered MAC (02:00:00:xx:xx:xx) from a resource ID.
|
||||
func generateMAC(resourceID string) string {
|
||||
h := uint32(0)
|
||||
for _, c := range resourceID {
|
||||
h = h*31 + uint32(c)
|
||||
}
|
||||
return fmt.Sprintf("02:00:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
|
||||
// slogPrintfer adapts nclient4's Printfer to slog. nclient4 only emits through
|
||||
// it under --verbose, so the packet summaries log at info level to stay visible.
|
||||
type slogPrintfer struct{}
|
||||
|
||||
func (slogPrintfer) Printf(format string, v ...any) {
|
||||
slog.Info(fmt.Sprintf(format, v...), "component", "nclient4")
|
||||
}
|
||||
|
||||
func ifaceMAC(name string) (net.HardwareAddr, error) {
|
||||
|
||||
@@ -12,17 +12,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/ecrauth"
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
@@ -144,13 +143,7 @@ func cacheDurationFrom(expiresAt time.Time) string {
|
||||
// loadConfig reads the cloud-init env file then lets real env vars override. The
|
||||
// IMDS base is fixed (not in the env file) but overridable for tests.
|
||||
func loadConfig(envFile string) config {
|
||||
env := parseEnvFile(envFile)
|
||||
get := func(key string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return env[key]
|
||||
}
|
||||
get := guestenv.Load(envFile).Get
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: get("EKS_GATEWAY_URL"),
|
||||
@@ -167,29 +160,6 @@ func loadConfig(envFile string) config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
|
||||
func parseEnvFile(path string) map[string]string {
|
||||
out := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emitEmpty writes a valid CredentialProviderResponse with no auth entries.
|
||||
func emitEmpty(out io.Writer) {
|
||||
resp := CredentialProviderResponse{
|
||||
|
||||
@@ -18,12 +18,14 @@ func fakeBackend(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// IMDSv2 token.
|
||||
// IMDSv2 token. Real IMDS echoes the requested TTL back as a response
|
||||
// header; the SDK client parses it from there, not the body.
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("imds-v2-token"))
|
||||
})
|
||||
// IMDS role name.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,13 +39,7 @@ type config struct {
|
||||
|
||||
// loadConfig reads the cloud-init env file then lets real env vars override.
|
||||
func loadConfig(envFile string) config {
|
||||
env := parseEnvFile(envFile)
|
||||
get := func(key string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return env[key]
|
||||
}
|
||||
get := guestenv.Load(envFile).Get
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: get("ECS_GATEWAY_URL"),
|
||||
@@ -94,26 +87,3 @@ func loadConfig(envFile string) config {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
|
||||
func parseEnvFile(path string) map[string]string {
|
||||
out := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -71,18 +71,3 @@ func TestLoadConfig_HeartbeatOverride(t *testing.T) {
|
||||
t.Errorf("Heartbeat = %v, want default on bad value", cfg.Heartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFile_SkipsBlankAndComments(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.env")
|
||||
if err := os.WriteFile(p, []byte("\n# c\nA=1\nnokeyval\nB = 2 \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := parseEnvFile(p)
|
||||
if m["A"] != "1" || m["B"] != "2" {
|
||||
t.Errorf("parse mismatch: %#v", m)
|
||||
}
|
||||
if _, ok := m["nokeyval"]; ok {
|
||||
t.Errorf("line without = should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -143,7 +144,7 @@ func (c *credEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
creds, roleARN, err := c.fetch(r.Context(), credID)
|
||||
if err != nil {
|
||||
if err == errUnknownCredID {
|
||||
if errors.Is(err, errUnknownCredID) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,57 +3,39 @@ package credentials
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
)
|
||||
|
||||
// refreshMargin is how long before expiry the cache is considered stale.
|
||||
const refreshMargin = 5 * time.Minute
|
||||
|
||||
// IMDSProvider fetches instance-role credentials from IMDS and caches them until
|
||||
// they near expiry. Safe for concurrent use.
|
||||
// IMDSProvider fetches instance-role credentials from IMDS via the AWS SDK's
|
||||
// EC2 IMDS client and ec2rolecreds provider, cached with aws.CredentialsCache
|
||||
// (which refetches once the real expiry passes; ec2rolecreds also caps
|
||||
// Expires to at most an hour out). Safe for concurrent use.
|
||||
type IMDSProvider struct {
|
||||
client *http.Client
|
||||
base string
|
||||
|
||||
mu sync.Mutex
|
||||
cached Credentials
|
||||
cache *aws.CredentialsCache
|
||||
}
|
||||
|
||||
var _ CredentialsProvider = (*IMDSProvider)(nil)
|
||||
|
||||
// NewIMDSProvider builds a provider against the given IMDS base URL (e.g.
|
||||
// "http://169.254.169.254/latest"). A nil client uses http.DefaultClient.
|
||||
// "http://169.254.169.254/latest"). A nil client uses the SDK IMDS client's
|
||||
// default HTTP client.
|
||||
func NewIMDSProvider(client *http.Client, base string) *IMDSProvider {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &IMDSProvider{client: client, base: base}
|
||||
provider := imdscreds.NewProvider(client, base)
|
||||
return &IMDSProvider{cache: aws.NewCredentialsCache(provider)}
|
||||
}
|
||||
|
||||
// Retrieve returns cached credentials when still valid, else fetches fresh ones.
|
||||
func (p *IMDSProvider) Retrieve(ctx context.Context) (Credentials, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.cached.Valid(refreshMargin) {
|
||||
return p.cached, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
|
||||
raw, err := imdscreds.Fetch(p.client, p.base)
|
||||
creds, err := p.cache.Retrieve(ctx)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
p.cached = Credentials{
|
||||
AccessKeyID: raw.AccessKeyID,
|
||||
SecretAccessKey: raw.SecretAccessKey,
|
||||
SessionToken: raw.SessionToken,
|
||||
Expiration: raw.Expiration,
|
||||
}
|
||||
return p.cached, nil
|
||||
return Credentials{
|
||||
AccessKeyID: creds.AccessKeyID,
|
||||
SecretAccessKey: creds.SecretAccessKey,
|
||||
SessionToken: creds.SessionToken,
|
||||
Expiration: creds.Expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -10,14 +10,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// imdsStub serves the IMDSv2 token + role + credentials endpoints under the
|
||||
// real /latest prefix the SDK IMDS client always requests.
|
||||
func imdsStub(t *testing.T, hits *int32, creds map[string]string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Real IMDS echoes the requested TTL back as a response header; the SDK
|
||||
// client parses it from there, not the body.
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("v2-token"))
|
||||
})
|
||||
mux.HandleFunc("/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/meta-data/iam/security-credentials/" {
|
||||
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
|
||||
_, _ = w.Write([]byte("node-role"))
|
||||
return
|
||||
}
|
||||
@@ -33,15 +38,17 @@ func imdsStub(t *testing.T, hits *int32, creds map[string]string) *httptest.Serv
|
||||
|
||||
func TestRetrieve_FetchesAndCaches(t *testing.T) {
|
||||
var hits int32
|
||||
exp := time.Now().Add(6 * time.Hour).UTC().Round(time.Second)
|
||||
// Kept under an hour: ec2rolecreds caps Expires to now+1h.
|
||||
exp := time.Now().Add(30 * time.Minute).UTC().Round(time.Second)
|
||||
srv := imdsStub(t, &hits, map[string]string{
|
||||
"Code": "Success",
|
||||
"AccessKeyId": "AKIA",
|
||||
"SecretAccessKey": "secret",
|
||||
"Token": "session",
|
||||
"Expiration": exp.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL)
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
got, err := p.Retrieve(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
@@ -50,7 +57,7 @@ func TestRetrieve_FetchesAndCaches(t *testing.T) {
|
||||
t.Errorf("creds mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// Second call within validity window must not re-hit IMDS.
|
||||
// Second call well within validity must not re-hit IMDS.
|
||||
if _, err := p.Retrieve(context.Background()); err != nil {
|
||||
t.Fatalf("Retrieve 2: %v", err)
|
||||
}
|
||||
@@ -59,15 +66,17 @@ func TestRetrieve_FetchesAndCaches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieve_RefetchesWhenStale(t *testing.T) {
|
||||
func TestRetrieve_RefetchesWhenExpired(t *testing.T) {
|
||||
var hits int32
|
||||
// Expiry inside the refresh margin → never cached, always refetched.
|
||||
// Already expired: aws.CredentialsCache refetches once the stored
|
||||
// credentials' real expiry has passed, regardless of ExpiryWindow.
|
||||
srv := imdsStub(t, &hits, map[string]string{
|
||||
"Code": "Success",
|
||||
"AccessKeyId": "AKIA",
|
||||
"SecretAccessKey": "secret",
|
||||
"Expiration": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
|
||||
"Expiration": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
|
||||
})
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL)
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
if _, err := p.Retrieve(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,13 +84,13 @@ func TestRetrieve_RefetchesWhenStale(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 2 {
|
||||
t.Errorf("hits = %d, want 2 (stale refetch)", hits)
|
||||
t.Errorf("hits = %d, want 2 (expired refetch)", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieve_CancelledContext(t *testing.T) {
|
||||
srv := imdsStub(t, nil, map[string]string{"AccessKeyId": "A", "SecretAccessKey": "B"})
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL)
|
||||
srv := imdsStub(t, nil, map[string]string{"Code": "Success", "AccessKeyId": "A", "SecretAccessKey": "B"})
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := p.Retrieve(ctx); err == nil {
|
||||
@@ -89,16 +98,10 @@ func TestRetrieve_CancelledContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_Valid(t *testing.T) {
|
||||
if (Credentials{}).Valid(0) {
|
||||
t.Error("empty creds should be invalid")
|
||||
}
|
||||
noExp := Credentials{AccessKeyID: "a", SecretAccessKey: "b"}
|
||||
if !noExp.Valid(time.Hour) {
|
||||
t.Error("zero expiry should be treated valid")
|
||||
}
|
||||
soon := Credentials{AccessKeyID: "a", SecretAccessKey: "b", Expiration: time.Now().Add(time.Minute)}
|
||||
if soon.Valid(5 * time.Minute) {
|
||||
t.Error("expiry inside margin should be invalid")
|
||||
func TestRetrieve_CodeNotSuccessErrors(t *testing.T) {
|
||||
srv := imdsStub(t, nil, map[string]string{"Code": "Failure", "AccessKeyId": "A", "SecretAccessKey": "B"})
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
if _, err := p.Retrieve(context.Background()); err == nil {
|
||||
t.Fatal("expected error when Code != Success")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,6 @@ type Credentials struct {
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// Valid reports whether the credentials are usable now, leaving a margin so a
|
||||
// caller does not start a request with a token about to expire mid-flight.
|
||||
func (c Credentials) Valid(margin time.Duration) bool {
|
||||
if c.AccessKeyID == "" || c.SecretAccessKey == "" {
|
||||
return false
|
||||
}
|
||||
if c.Expiration.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Now().Add(margin).Before(c.Expiration)
|
||||
}
|
||||
|
||||
// CredentialsProvider yields current credentials, refreshing as needed. Retrieve
|
||||
// must be safe for concurrent use.
|
||||
type CredentialsProvider interface {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
)
|
||||
|
||||
// instanceMetadata is the IMDS-derived identity of the host the agent runs on.
|
||||
@@ -15,83 +18,44 @@ type instanceMetadata struct {
|
||||
AZ string
|
||||
}
|
||||
|
||||
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2.
|
||||
// base is the IMDS root, e.g. "http://169.254.169.254/latest".
|
||||
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2
|
||||
// via the SDK IMDS client. base is the IMDS root, e.g.
|
||||
// "http://169.254.169.254/latest".
|
||||
func fetchInstanceMetadata(client *http.Client, base string) (instanceMetadata, error) {
|
||||
token, err := imdsToken(client, base)
|
||||
if err != nil {
|
||||
return instanceMetadata{}, err
|
||||
}
|
||||
imdsClient := imdscreds.NewClient(client, base)
|
||||
ctx := context.Background()
|
||||
|
||||
instanceID, err := imdsGet(client, base+"/meta-data/instance-id", token)
|
||||
instanceID, err := getMetadata(ctx, imdsClient, "instance-id")
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("instance-id: %w", err)
|
||||
}
|
||||
az, err := imdsGet(client, base+"/meta-data/placement/availability-zone", token)
|
||||
az, err := getMetadata(ctx, imdsClient, "placement/availability-zone")
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("availability-zone: %w", err)
|
||||
}
|
||||
|
||||
doc, err := imdsGet(client, base+"/dynamic/instance-identity/document", token)
|
||||
doc, err := imdsClient.GetInstanceIdentityDocument(ctx, &imds.GetInstanceIdentityDocumentInput{})
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("identity document: %w", err)
|
||||
}
|
||||
var ident struct {
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(doc), &ident); err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("parse identity document: %w", err)
|
||||
}
|
||||
|
||||
return instanceMetadata{
|
||||
AccountID: ident.AccountID,
|
||||
AccountID: doc.AccountID,
|
||||
InstanceID: strings.TrimSpace(instanceID),
|
||||
AZ: strings.TrimSpace(az),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// imdsToken fetches an IMDSv2 session token.
|
||||
func imdsToken(client *http.Client, base string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodPut, base+"/api/token", nil)
|
||||
// getMetadata reads a single meta-data path and returns its body as a string.
|
||||
func getMetadata(ctx context.Context, imdsClient *imds.Client, path string) (string, error) {
|
||||
out, err := imdsClient.GetMetadata(ctx, &imds.GetMetadataInput{Path: path})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("imds token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
defer out.Content.Close()
|
||||
body, err := io.ReadAll(out.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("imds token: status %d", resp.StatusCode)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// imdsGet performs a token-authenticated IMDS GET.
|
||||
func imdsGet(client *http.Client, url, token string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token", token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
func metadataStub(t *testing.T, enforceV2 bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Real IMDS echoes the requested TTL back as a response header; the SDK
|
||||
// client parses it from there, not the body.
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("v2-token"))
|
||||
})
|
||||
guard := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -23,17 +26,17 @@ func metadataStub(t *testing.T, enforceV2 bool) *httptest.Server {
|
||||
}
|
||||
return true
|
||||
}
|
||||
mux.HandleFunc("/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte("i-0abc123"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte("us-east-1a"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte(`{"accountId":"123456789012","region":"us-east-1"}`))
|
||||
}
|
||||
@@ -45,7 +48,7 @@ func metadataStub(t *testing.T, enforceV2 bool) *httptest.Server {
|
||||
|
||||
func TestFetchInstanceMetadata_OK(t *testing.T) {
|
||||
srv := metadataStub(t, true)
|
||||
got, err := fetchInstanceMetadata(srv.Client(), srv.URL)
|
||||
got, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
@@ -57,20 +60,23 @@ func TestFetchInstanceMetadata_OK(t *testing.T) {
|
||||
func TestFetchInstanceMetadata_TokenRequired(t *testing.T) {
|
||||
// Server returns 405 for non-PUT token; a GET-only client would 401 downstream.
|
||||
srv := metadataStub(t, true)
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL); err != nil {
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err != nil {
|
||||
t.Fatalf("v2 path should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceMetadata_BadDocument(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/token", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("t")) })
|
||||
mux.HandleFunc("/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("i-1")) })
|
||||
mux.HandleFunc("/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("z")) })
|
||||
mux.HandleFunc("/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("t"))
|
||||
})
|
||||
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("i-1")) })
|
||||
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("z")) })
|
||||
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL); err == nil {
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err == nil {
|
||||
t.Fatal("expected parse error on bad identity document")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +22,12 @@ const (
|
||||
credEndpointCIDR = defaultCredEndpointIP + "/32"
|
||||
)
|
||||
|
||||
// udhcpcScript is the config script busybox udhcpc execs to apply a lease. It
|
||||
// is the reason presence of the udhcpc binary is not enough to use it: Debian
|
||||
// and Ubuntu's busybox ship the applet without this script, and udhcpc then
|
||||
// takes the lease and exits nonzero having configured nothing.
|
||||
const udhcpcScript = "/usr/share/udhcpc/default.script"
|
||||
|
||||
// netCmdRunner executes a host networking command. Abstracted so unit tests can
|
||||
// assert the ip/netns sequence without touching the kernel.
|
||||
type netCmdRunner interface {
|
||||
@@ -43,10 +50,47 @@ type taskNetns struct {
|
||||
run netCmdRunner
|
||||
nicWait time.Duration
|
||||
poll time.Duration
|
||||
// Injected so the DHCP-client choice below is unit-testable on a host that
|
||||
// has neither client installed.
|
||||
lookPath func(string) (string, error)
|
||||
fileIsExec func(string) bool
|
||||
}
|
||||
|
||||
func newTaskNetns(run netCmdRunner) *taskNetns {
|
||||
return &taskNetns{run: run, nicWait: 30 * time.Second, poll: time.Second}
|
||||
return &taskNetns{
|
||||
run: run,
|
||||
nicWait: 30 * time.Second,
|
||||
poll: time.Second,
|
||||
lookPath: exec.LookPath,
|
||||
fileIsExec: func(p string) bool { fi, err := os.Stat(p); return err == nil && fi.Mode()&0o111 != 0 },
|
||||
}
|
||||
}
|
||||
|
||||
// dhcpStep returns the in-netns DHCP command that leases the task ENI's
|
||||
// OVN-assigned address.
|
||||
//
|
||||
// This image ships on two distributions and they do not agree on a DHCP client.
|
||||
// Alpine has busybox udhcpc; Ubuntu has no udhcpc at all, and `ip netns exec`
|
||||
// exits 1 — not 127 — when it cannot exec the binary, so a hardcoded udhcpc
|
||||
// fails as an opaque "network setup failed" with the task stuck in STOPPED.
|
||||
// Resolve the client at setup time instead, preferring udhcpc so Alpine keeps
|
||||
// its existing behaviour exactly, then falling back to dhcpcd. Same order and
|
||||
// same reasoning as dhcp_oneshot() in mulga-mgmt-net.sh.
|
||||
//
|
||||
// Both invocations are one-shot: udhcpc -q exits once it has the lease and -n
|
||||
// gives up rather than backgrounding, and dhcpcd -1 exits after configuring the
|
||||
// interface. Neither leaves a daemon behind in the netns.
|
||||
func (n *taskNetns) dhcpStep(name, iface string) ([]string, error) {
|
||||
if _, err := n.lookPath("udhcpc"); err == nil && n.fileIsExec(udhcpcScript) {
|
||||
return []string{"ip", "netns", "exec", name, "udhcpc", "-i", iface, "-q", "-n"}, nil
|
||||
}
|
||||
// Lease state is keyed by interface name, which is unique per task here:
|
||||
// each task ENI is a separate hot-plug, so it lands on its own PCI slot and
|
||||
// gets its own predictable name before being moved into the netns.
|
||||
if _, err := n.lookPath("dhcpcd"); err == nil {
|
||||
return []string{"ip", "netns", "exec", name, "dhcpcd", "-q", "-1", "-t", "20", iface}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no DHCP client for %s: need udhcpc (with %s) or dhcpcd", iface, udhcpcScript)
|
||||
}
|
||||
|
||||
func netnsName(taskID string) string { return "ecs-" + taskID }
|
||||
@@ -68,11 +112,16 @@ func (n *taskNetns) Setup(taskID, mac string) (string, error) {
|
||||
}
|
||||
hostVeth := credVethName(taskID)
|
||||
hostIP, taskIP := credSubnet(taskID)
|
||||
dhcp, err := n.dhcpStep(name, iface)
|
||||
if err != nil {
|
||||
_ = n.Teardown(taskID)
|
||||
return "", err
|
||||
}
|
||||
steps := [][]string{
|
||||
{"ip", "link", "set", iface, "netns", name},
|
||||
{"ip", "-n", name, "link", "set", "lo", "up"},
|
||||
{"ip", "-n", name, "link", "set", iface, "up"},
|
||||
{"ip", "netns", "exec", name, "udhcpc", "-i", iface, "-q", "-n"},
|
||||
dhcp,
|
||||
// Credentials path: a veth into the host netns plus a host-routed /32 to the
|
||||
// credential endpoint, so the container reaches 169.254.170.2 without it
|
||||
// leaking onto the task ENI's VPC route.
|
||||
|
||||
@@ -55,10 +55,27 @@ const linkWithENI = `1: lo: <LOOPBACK,UP> mtu 65536 ... link/loopback 00:00:00:0
|
||||
2: eth0: <BROADCAST,UP> mtu 1500 ... link/ether 52:54:00:aa:bb:cc ...
|
||||
3: eth1: <BROADCAST,UP> mtu 1500 ... link/ether 52:54:00:de:ad:01 ...`
|
||||
|
||||
// withDHCPClients pins which DHCP clients the netns sees, so a test asserts the
|
||||
// branch it names rather than whatever happens to be installed on the machine
|
||||
// running it. Without this the DHCP step is host-dependent: an Alpine CI box and
|
||||
// an Ubuntu dev box would exercise different code from the same test.
|
||||
func withDHCPClients(n *taskNetns, present map[string]bool, scriptOK bool) {
|
||||
n.lookPath = func(f string) (string, error) {
|
||||
if present[f] {
|
||||
return "/sbin/" + f, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
n.fileIsExec = func(string) bool { return scriptOK }
|
||||
}
|
||||
|
||||
// newTestNetns defaults to the Alpine arrangement — udhcpc plus its config
|
||||
// script — so the sequence assertions below keep describing that platform.
|
||||
func newTestNetns(f *fakeNetRunner) *taskNetns {
|
||||
n := newTaskNetns(f)
|
||||
n.nicWait = 50 * time.Millisecond
|
||||
n.poll = 5 * time.Millisecond
|
||||
withDHCPClients(n, map[string]bool{"udhcpc": true}, true)
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -142,6 +159,79 @@ func TestNetns_SetupTearsDownOnStepFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The image runs on two distributions with different DHCP clients, and picking
|
||||
// the wrong one fails as an opaque "network setup failed" with the task stuck
|
||||
// in STOPPED — `ip netns exec` exits 1, not 127, when it cannot exec the
|
||||
// binary. Each branch is asserted so that misresolution is a test failure.
|
||||
func TestNetns_DHCPStepPerPlatform(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
present map[string]bool
|
||||
scriptOK bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "alpine: udhcpc with its config script",
|
||||
present: map[string]bool{"udhcpc": true, "dhcpcd": true},
|
||||
scriptOK: true,
|
||||
want: "ip netns exec ecs-t-001 udhcpc -i eth1 -q -n",
|
||||
},
|
||||
{
|
||||
// Ubuntu ships no udhcpc at all — the case that broke awsvpc tasks.
|
||||
name: "ubuntu: no udhcpc, falls back to dhcpcd",
|
||||
present: map[string]bool{"dhcpcd": true},
|
||||
want: "ip netns exec ecs-t-001 dhcpcd -q -1 -t 20 eth1",
|
||||
},
|
||||
{
|
||||
// Debian/Ubuntu busybox ships the applet without the script, so
|
||||
// presence of the binary alone must not select it.
|
||||
name: "udhcpc present but config script missing: still dhcpcd",
|
||||
present: map[string]bool{"udhcpc": true, "dhcpcd": true},
|
||||
scriptOK: false,
|
||||
want: "ip netns exec ecs-t-001 dhcpcd -q -1 -t 20 eth1",
|
||||
},
|
||||
{
|
||||
name: "neither client: explicit error, not a silent bad command",
|
||||
present: map[string]bool{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := newTestNetns(&fakeNetRunner{linkOut: linkWithENI})
|
||||
withDHCPClients(n, tc.present, tc.scriptOK)
|
||||
got, err := n.dhcpStep("ecs-t-001", "eth1")
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error with no DHCP client, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("dhcpStep: %v", err)
|
||||
}
|
||||
if joined := strings.Join(got, " "); joined != tc.want {
|
||||
t.Errorf("want %q, got %q", tc.want, joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing DHCP client must not leave the half-built netns behind for the next
|
||||
// attempt to trip over, same as any other failed setup step.
|
||||
func TestNetns_SetupTearsDownWhenNoDHCPClient(t *testing.T) {
|
||||
f := &fakeNetRunner{linkOut: linkWithENI}
|
||||
n := newTestNetns(f)
|
||||
withDHCPClients(n, map[string]bool{}, false)
|
||||
if _, err := n.Setup("t-001", "52:54:00:de:ad:01"); err == nil {
|
||||
t.Fatal("want error when no DHCP client is available")
|
||||
}
|
||||
if !f.sawAny("ip netns del ecs-t-001") {
|
||||
t.Errorf("expected teardown, got %v", f.joined())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetns_Teardown(t *testing.T) {
|
||||
f := &fakeNetRunner{}
|
||||
n := newTestNetns(f)
|
||||
|
||||
@@ -8,12 +8,18 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// reconcile re-adopts containers that survived an agent restart. It lists the
|
||||
// runtime's containers and, for each task still running in this cluster,
|
||||
// re-registers its IAM credentials, re-attaches the exit-wait, and refreshes its
|
||||
// RUNNING state on the gateway. The returned set seeds pollAssignments so the
|
||||
// reconcile re-syncs the gateway with what this instance is actually running
|
||||
// after an agent restart. It lists the runtime's containers and, for each task
|
||||
// in this cluster, either re-adopts it (still running: re-register IAM
|
||||
// credentials, re-attach the exit-wait, refresh RUNNING) or reports it STOPPED
|
||||
// and clears its leftovers. The returned set seeds pollAssignments so the
|
||||
// gateway's re-delivered assignments for these tasks are acked but not re-run.
|
||||
//
|
||||
// Reporting the dead ones is the load-bearing half. The exit-wait is cancelled
|
||||
// when the agent shuts down, so a container that dies while the agent is down
|
||||
// has nobody to report its exit; the gateway holds the task RUNNING forever,
|
||||
// its service never replaces it, and the target never recovers.
|
||||
//
|
||||
// It is best-effort: a nil runner or a List error returns an empty set and the
|
||||
// agent falls back to its pre-reconcile behaviour (poll + run).
|
||||
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
@@ -27,15 +33,17 @@ func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
return adopted
|
||||
}
|
||||
|
||||
// Group this cluster's running containers back into their tasks. The Assign is
|
||||
// reconstructed from labels — enough to re-register creds and tear down netns.
|
||||
// Group this cluster's containers back into their tasks, running or not. The
|
||||
// Assign is reconstructed from labels — enough to re-register creds and tear
|
||||
// down netns.
|
||||
type task struct {
|
||||
as *bus.Assign
|
||||
containers []ctrruntime.Container
|
||||
as *bus.Assign
|
||||
running []ctrruntime.Container
|
||||
dead []ctrruntime.Container
|
||||
}
|
||||
tasks := map[string]*task{}
|
||||
for _, c := range containers {
|
||||
if !c.Running || c.Labels[labelClusterName] != a.id.ClusterName {
|
||||
if c.Labels[labelClusterName] != a.id.ClusterName {
|
||||
continue
|
||||
}
|
||||
taskID := c.Labels[labelTaskID]
|
||||
@@ -53,20 +61,50 @@ func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
}}
|
||||
tasks[taskID] = t
|
||||
}
|
||||
t.containers = append(t.containers, c)
|
||||
if c.Running {
|
||||
t.running = append(t.running, c)
|
||||
} else {
|
||||
t.dead = append(t.dead, c)
|
||||
}
|
||||
}
|
||||
|
||||
for taskID, t := range tasks {
|
||||
// A task with nothing running is over, whatever the gateway still believes.
|
||||
// Mixed running/dead is left to the running path: the exit-wait re-attached
|
||||
// below reports the individual container when it is reaped.
|
||||
if len(t.running) == 0 {
|
||||
a.reportStoppedTask(ctx, t.as, t.dead)
|
||||
adopted[taskID] = true
|
||||
continue
|
||||
}
|
||||
|
||||
a.registerTaskCreds(t.as)
|
||||
statuses := make([]bus.ContainerStatus, 0, len(t.containers))
|
||||
for _, c := range t.containers {
|
||||
statuses := make([]bus.ContainerStatus, 0, len(t.running))
|
||||
for _, c := range t.running {
|
||||
name := c.Labels[labelContainerName]
|
||||
go a.waitContainer(ctx, t.as, name, c.ID)
|
||||
statuses = append(statuses, bus.ContainerStatus{Name: name, Status: bus.TaskStatusRunning, ContainerID: c.ID})
|
||||
}
|
||||
a.reportTaskState(t.as, bus.TaskStatusRunning, "", statuses)
|
||||
adopted[taskID] = true
|
||||
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.containers))
|
||||
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.running))
|
||||
}
|
||||
return adopted
|
||||
}
|
||||
|
||||
// reportStoppedTask clears a task's leftover containers and tells the gateway it
|
||||
// is STOPPED, so the scheduler stops counting a task this instance is not
|
||||
// running and its service can place a replacement.
|
||||
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
|
||||
statuses := make([]bus.ContainerStatus, 0, len(dead))
|
||||
for _, c := range dead {
|
||||
if err := a.runner.Remove(ctx, c.ID); err != nil {
|
||||
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
|
||||
}
|
||||
statuses = append(statuses, bus.ContainerStatus{
|
||||
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
|
||||
})
|
||||
}
|
||||
a.reportTaskState(as, bus.TaskStatusStopped, "Container exited while the agent was not running", statuses)
|
||||
slog.Info("ecs-agent: reported stopped task", "task", as.TaskID, "containers", len(dead))
|
||||
}
|
||||
|
||||
@@ -72,6 +72,63 @@ func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
|
||||
waitedFor(t, rt, containerID("t-001", "web"))
|
||||
}
|
||||
|
||||
// A task whose container died while the agent was down must be reported STOPPED,
|
||||
// not silently skipped. The exit-wait is cancelled on agent shutdown, so nothing
|
||||
// else ever reports that exit: skipping it leaves the gateway holding the task
|
||||
// RUNNING forever, its service never places a replacement, and the load
|
||||
// balancer target stays unhealthy with no path back.
|
||||
func TestReconcile_ReportsTaskWhoseContainerDied(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-dead", "web", "default", "t-dead", testTaskRole, "52:54:00:de:ad:02", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusStopped); n != 1 {
|
||||
t.Errorf("STOPPED reports for t-dead = %d, want 1", n)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusRunning); n != 0 {
|
||||
t.Errorf("t-dead must not be reported RUNNING, got %d such reports", n)
|
||||
}
|
||||
// Seeded so a re-delivered assignment for the dead task is acked, not re-run.
|
||||
if !adopted["t-dead"] {
|
||||
t.Errorf("t-dead not seeded into the adopted set; got %v", adopted)
|
||||
}
|
||||
if want := containerID("t-dead", "web"); !slices.Contains(rt.Removed, want) {
|
||||
t.Errorf("leftover container %s not removed; removed=%v", want, rt.Removed)
|
||||
}
|
||||
}
|
||||
|
||||
// A task with one live and one dead container is still running: the live half is
|
||||
// re-adopted and the re-attached exit-wait reports the dead one when it is
|
||||
// reaped, so reconcile must not declare the whole task STOPPED.
|
||||
func TestReconcile_PartiallyDeadTaskStaysRunning(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
WaitErr: errors.New("blocked"),
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-mixed", "web", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", true),
|
||||
adoptedContainer("t-mixed", "sidecar", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
a.reconcile(context.Background())
|
||||
|
||||
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusRunning); n != 1 {
|
||||
t.Errorf("RUNNING reports for t-mixed = %d, want 1", n)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusStopped); n != 0 {
|
||||
t.Errorf("t-mixed must not be reported STOPPED while a container still runs, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// pollAssignments seeded with an adopted task acks its re-delivered assignment
|
||||
// but does not re-run it, while a genuinely new task still dispatches.
|
||||
func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
|
||||
@@ -123,23 +180,35 @@ func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Containers for another cluster or with a stopped task are not adopted.
|
||||
func TestReconcile_FiltersOtherClusterAndStopped(t *testing.T) {
|
||||
// Containers belonging to another cluster are ignored entirely: this agent
|
||||
// speaks for its own cluster only, and must neither adopt nor report them.
|
||||
//
|
||||
// A stopped container of this cluster IS handled — see
|
||||
// TestReconcile_ReportsTaskWhoseContainerDied. It lands in the adopted set so
|
||||
// its re-delivered assignment is acked rather than re-run, after reconcile has
|
||||
// reported the task STOPPED.
|
||||
func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
WaitErr: errors.New("blocked"),
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-other", "web", "other", "", "", "", true),
|
||||
adoptedContainer("t-stop", "web", "default", "", "", "", false),
|
||||
adoptedContainer("t-other-dead", "web", "other", "", "", "", false),
|
||||
adoptedContainer("t-ok", "web", "default", "", "", "", true),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), &fakeCP{}, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
if len(adopted) != 1 || !adopted["t-ok"] {
|
||||
t.Fatalf("adopted = %v, want only t-ok", adopted)
|
||||
}
|
||||
for _, id := range []string{"t-other", "t-other-dead"} {
|
||||
if n := countStatus(cp.taskStates(), id, bus.TaskStatusStopped); n != 0 {
|
||||
t.Errorf("reported %d STOPPED states for another cluster's task %s, want 0", n, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil runner or a List error degrades to an empty set without panicking.
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestRunTask_PullRunReportsRunning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTask_GPUCarriedToRunSpec covers mulga-11opz (Epic C task C1): the
|
||||
// AssignContainer's GPU count reaches the runtime RunSpec unchanged; a
|
||||
// TestRunTask_GPUCarriedToRunSpec verifies that the AssignContainer's GPU count
|
||||
// reaches the runtime RunSpec unchanged; a
|
||||
// container with no GPU request keeps RunSpec.GPU at zero (regression).
|
||||
func TestRunTask_GPUCarriedToRunSpec(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
@@ -534,7 +534,7 @@ func (sentinelResolver) Authorize(context.Context, string) (string, string, stri
|
||||
return "", "", "", nil
|
||||
}
|
||||
|
||||
// TestPullResolver covers siv-459: an assign with no execution role (or no
|
||||
// TestPullResolver verifies that an assign with no execution role (or no
|
||||
// credential endpoint) uses the instance resolver; with both, a distinct
|
||||
// exec-role resolver is built.
|
||||
func TestPullResolver(t *testing.T) {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -18,6 +20,10 @@ import (
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// listDisks is the block-device scan, indirected so tests can drive selection
|
||||
// without real hardware.
|
||||
var listDisks = install.ListDisks
|
||||
|
||||
// Load returns an install.Config built from SPINIFEX_* environment variables,
|
||||
// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode).
|
||||
func Load() (*install.Config, error) {
|
||||
@@ -65,9 +71,9 @@ func buildConfig() (*install.Config, error) {
|
||||
hostname = "spinifex-node"
|
||||
}
|
||||
|
||||
disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK"))
|
||||
storage, err := resolveStorage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: %w", err)
|
||||
return nil, fmt.Errorf("storage: %w", err)
|
||||
}
|
||||
|
||||
wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "")
|
||||
@@ -76,73 +82,217 @@ func buildConfig() (*install.Config, error) {
|
||||
}
|
||||
|
||||
cfg := &install.Config{
|
||||
Disk: disk,
|
||||
Storage: storage,
|
||||
Hostname: hostname,
|
||||
RootPassword: password,
|
||||
// Optional on the headless path — empty means no --email passed to
|
||||
// spx admin init, which omits the operator identity from telemetry.
|
||||
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
|
||||
WANInterface: wanIface,
|
||||
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
|
||||
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
|
||||
}
|
||||
|
||||
if !cfg.WANDHCPMode {
|
||||
ip := os.Getenv("SPINIFEX_WAN_IP")
|
||||
mask := os.Getenv("SPINIFEX_WAN_MASK")
|
||||
gw := os.Getenv("SPINIFEX_WAN_GW")
|
||||
if ip == "" || mask == "" || gw == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
|
||||
}
|
||||
cfg.WANAddress = ip
|
||||
cfg.WANMask = mask
|
||||
cfg.WANGateway = gw
|
||||
if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" {
|
||||
cfg.WANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
cfg.WAN, err = parseRole("WAN", wanIface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.WAN.DHCPMode {
|
||||
cfg.WAN.Gateway = ""
|
||||
} else if cfg.WAN.Gateway == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
|
||||
}
|
||||
|
||||
if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" {
|
||||
lan, err := resolveNIC(lanIface, wanIface)
|
||||
// lan and vpc are optional: an unset _IFACE leaves the role folded, which
|
||||
// is how a single- or two-NIC node collapses onto the planes above it.
|
||||
for _, r := range []struct {
|
||||
plane string
|
||||
dst *install.NetworkRole
|
||||
}{{"LAN", &cfg.LAN}, {"VPC", &cfg.VPC}} {
|
||||
name := os.Getenv("SPINIFEX_" + r.plane + "_IFACE")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
iface, err := resolveNIC(name, wanIface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LAN NIC: %w", err)
|
||||
return nil, fmt.Errorf("%s NIC: %w", r.plane, err)
|
||||
}
|
||||
cfg.LANInterface = lan
|
||||
cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static"
|
||||
if !cfg.LANDHCPMode {
|
||||
cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP")
|
||||
cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK")
|
||||
if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" {
|
||||
cfg.LANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
if *r.dst, err = parseRole(r.plane, iface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only the wan plane installs a default route.
|
||||
r.dst.Gateway = ""
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("network roles: %w", err)
|
||||
}
|
||||
|
||||
if os.Getenv("SPINIFEX_GPU_PASSTHROUGH") == "1" {
|
||||
cfg.GPUPassthrough = true
|
||||
}
|
||||
|
||||
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
|
||||
if role == "" {
|
||||
role = "init"
|
||||
}
|
||||
cfg.ClusterRole = role
|
||||
skipFormation := os.Getenv("SPINIFEX_SKIP_FORMATION") == "1"
|
||||
if skipFormation {
|
||||
if os.Getenv("SPINIFEX_SKIP_FORMATION") == "1" {
|
||||
cfg.SkipFormation = true
|
||||
}
|
||||
|
||||
if role == "join" && !skipFormation {
|
||||
joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR")
|
||||
if joinAddr == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join")
|
||||
}
|
||||
cfg.JoinAddr = joinAddr
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveDisk maps the spx_disk value from grub.cfg to a block device path.
|
||||
// resolveStorage builds the disk configuration from the kernel cmdline.
|
||||
//
|
||||
// SPINIFEX_FS ext4 (default) or one of the zfs-* topologies
|
||||
// SPINIFEX_DISKS explicit ordered member list, required for multi-disk pools
|
||||
// SPINIFEX_DISK the OS disk, as before
|
||||
// SPINIFEX_DISK_SPINIFEX ext4 only: dedicated disk for /var/lib/spinifex
|
||||
// SPINIFEX_DISK_PREDASTORE ext4 only: dedicated disk for /var/lib/spinifex/predastore
|
||||
func resolveStorage() (install.DiskConfig, error) {
|
||||
cfg := install.DiskConfig{FS: install.FSExt4}
|
||||
if v := strings.TrimSpace(os.Getenv("SPINIFEX_FS")); v != "" {
|
||||
fs, err := install.ParseFSType(v)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.FS = fs
|
||||
}
|
||||
|
||||
var err error
|
||||
switch list := strings.TrimSpace(os.Getenv("SPINIFEX_DISKS")); {
|
||||
case list != "":
|
||||
if cfg.Disks, err = disksByName(strings.Split(list, ",")); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
case cfg.FS.MinDisks() > 1:
|
||||
// No auto-selection for a multi-disk pool. Choosing which disks to
|
||||
// erase is not a decision an unattended install gets to make, and the
|
||||
// member order determines the RAID10 pairing.
|
||||
return cfg, fmt.Errorf("%s needs at least %d disks — set SPINIFEX_DISKS to an explicit comma-separated list",
|
||||
cfg.FS.Label(), cfg.FS.MinDisks())
|
||||
default:
|
||||
d, derr := resolveDisk(os.Getenv("SPINIFEX_DISK"))
|
||||
if derr != nil {
|
||||
return cfg, derr
|
||||
}
|
||||
cfg.Disks = []install.Disk{d}
|
||||
}
|
||||
|
||||
if err := applyDiskRoles(&cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if cfg.ZFS, err = parseZFSOpts(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
slog.Info("autoinstall: storage selected", "fs", cfg.FS, "disks", cfg.Paths())
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// roleVars maps each optional data role to the variable that assigns it. The OS
|
||||
// role is not here: it always comes from SPINIFEX_DISK.
|
||||
var roleVars = []struct {
|
||||
role install.DiskRole
|
||||
env string
|
||||
}{
|
||||
{install.RoleSpinifex, "SPINIFEX_DISK_SPINIFEX"},
|
||||
{install.RolePredastore, "SPINIFEX_DISK_PREDASTORE"},
|
||||
}
|
||||
|
||||
// applyDiskRoles attaches dedicated data drives to an ext4 install. Roles are
|
||||
// always set on ext4, even with no extra drives, so the OS assignment is
|
||||
// explicit rather than implied by position.
|
||||
func applyDiskRoles(cfg *install.DiskConfig) error {
|
||||
if cfg.FS.IsZFS() {
|
||||
for _, rv := range roleVars {
|
||||
if strings.TrimSpace(os.Getenv(rv.env)) != "" {
|
||||
return fmt.Errorf("%s applies to ext4 only — %s spans every member already", rv.env, cfg.FS.Label())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Disks) != 1 {
|
||||
return fmt.Errorf("ext4 takes one OS disk, %d given — set SPINIFEX_DISK, and SPINIFEX_DISK_SPINIFEX or SPINIFEX_DISK_PREDASTORE for dedicated drives",
|
||||
len(cfg.Disks))
|
||||
}
|
||||
|
||||
roles := []install.RoleMount{{Role: install.RoleOS, Disk: cfg.Disks[0]}}
|
||||
for _, rv := range roleVars {
|
||||
spec := strings.TrimSpace(os.Getenv(rv.env))
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
// Resolved with the same fail-closed selector as the OS disk, so an
|
||||
// ambiguous value lists the candidates rather than picking one.
|
||||
d, err := resolveDisk(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", rv.env, err)
|
||||
}
|
||||
roles = append(roles, install.RoleMount{Role: rv.role, Disk: d})
|
||||
}
|
||||
*cfg = cfg.WithRoles(roles)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseZFSOpts reads the advanced pool tunables. Every one is optional; unset
|
||||
// values are computed from the hardware at install time.
|
||||
func parseZFSOpts() (install.ZFSOpts, error) {
|
||||
var o install.ZFSOpts
|
||||
ints := []struct {
|
||||
env string
|
||||
dst *int
|
||||
}{
|
||||
{"SPINIFEX_ZFS_ASHIFT", &o.Ashift},
|
||||
{"SPINIFEX_ZFS_COPIES", &o.Copies},
|
||||
{"SPINIFEX_ZFS_ARC_MAX_MIB", &o.ARCMaxMiB},
|
||||
}
|
||||
for _, f := range ints {
|
||||
v := strings.TrimSpace(os.Getenv(f.env))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return o, fmt.Errorf("%s: %w", f.env, err)
|
||||
}
|
||||
*f.dst = n
|
||||
}
|
||||
o.Compress = strings.TrimSpace(os.Getenv("SPINIFEX_ZFS_COMPRESS"))
|
||||
o.Checksum = strings.TrimSpace(os.Getenv("SPINIFEX_ZFS_CHECKSUM"))
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// disksByName resolves an explicit member list. Names may be kernel names
|
||||
// ("sdb"), device paths or by-id paths; the order given is preserved because
|
||||
// RAID10 pairs members in it.
|
||||
func disksByName(names []string) ([]install.Disk, error) {
|
||||
available, err := listDisks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]install.Disk, 0, len(names))
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// A by-id path or a symlink resolves to the kernel device the scan
|
||||
// reported, so both spellings name the same disk.
|
||||
resolved := name
|
||||
if !strings.HasPrefix(resolved, "/dev/") {
|
||||
resolved = "/dev/" + resolved
|
||||
}
|
||||
if target, err := filepath.EvalSymlinks(resolved); err == nil {
|
||||
resolved = target
|
||||
}
|
||||
|
||||
idx := slices.IndexFunc(available, func(d install.Disk) bool { return d.Path == resolved })
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("%q is not an available disk — found:\n%s", name, diskList(available))
|
||||
}
|
||||
out = append(out, available[idx])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveDisk maps the SPINIFEX_DISK value from grub.cfg to a disk.
|
||||
//
|
||||
// Supported values:
|
||||
//
|
||||
@@ -151,141 +301,78 @@ func buildConfig() (*install.Config, error) {
|
||||
// smallest — smallest non-removable disk (typical OS-on-SSD pattern)
|
||||
// nvme — the only NVMe disk; fail if multiple found
|
||||
// /dev/sda (etc.) — exact device path; fail if not present
|
||||
func resolveDisk(target string) (string, error) {
|
||||
switch strings.ToLower(target) {
|
||||
func resolveDisk(target string) (install.Disk, error) {
|
||||
disks, err := candidateDisks()
|
||||
if err != nil {
|
||||
return install.Disk{}, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||
case "", "auto":
|
||||
return singleDisk()
|
||||
case "largest":
|
||||
return diskBySize(true)
|
||||
case "smallest":
|
||||
return diskBySize(false)
|
||||
case "nvme":
|
||||
return nvmeDisk()
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return "", fmt.Errorf("%q not found", target)
|
||||
if len(disks) != 1 {
|
||||
return install.Disk{}, fmt.Errorf(
|
||||
"expected exactly one disk, found %d — set SPINIFEX_DISK to one of:\n%s\n"+
|
||||
" largest (largest disk)\n"+
|
||||
" smallest (smallest disk)\n"+
|
||||
" nvme (NVMe only)\n"+
|
||||
" /dev/sdX (exact path)",
|
||||
len(disks), diskList(disks))
|
||||
}
|
||||
return target, nil
|
||||
return disks[0], nil
|
||||
case "largest":
|
||||
return pickBySize(disks, true)
|
||||
case "smallest":
|
||||
return pickBySize(disks, false)
|
||||
case "nvme":
|
||||
nvmes := slices.DeleteFunc(slices.Clone(disks), func(d install.Disk) bool {
|
||||
return !strings.HasPrefix(filepath.Base(d.Path), "nvme")
|
||||
})
|
||||
if len(nvmes) != 1 {
|
||||
return install.Disk{}, fmt.Errorf("expected exactly one NVMe disk, found %d:\n%s",
|
||||
len(nvmes), diskList(nvmes))
|
||||
}
|
||||
return nvmes[0], nil
|
||||
default:
|
||||
found, err := disksByName([]string{target})
|
||||
if err != nil {
|
||||
return install.Disk{}, err
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
type diskCandidate struct {
|
||||
dev string
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// nonRemovableDisks returns all non-removable, non-virtual block devices.
|
||||
func nonRemovableDisks() ([]diskCandidate, error) {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
// candidateDisks lists the disks an unattended install may erase: fixed, and
|
||||
// not the installer's own media.
|
||||
func candidateDisks() ([]install.Disk, error) {
|
||||
all, err := listDisks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []diskCandidate
|
||||
for _, e := range entries {
|
||||
dev := e.Name()
|
||||
switch {
|
||||
case strings.HasPrefix(dev, "loop"),
|
||||
strings.HasPrefix(dev, "ram"),
|
||||
strings.HasPrefix(dev, "dm-"),
|
||||
strings.HasPrefix(dev, "sr"):
|
||||
continue
|
||||
}
|
||||
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
|
||||
if strings.TrimSpace(string(removable)) == "1" {
|
||||
continue
|
||||
}
|
||||
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
|
||||
sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
|
||||
if parseErr != nil || sectors == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, diskCandidate{dev: dev, bytes: sectors * 512})
|
||||
}
|
||||
return out, nil
|
||||
return slices.DeleteFunc(all, func(d install.Disk) bool {
|
||||
return d.Removable || d.LiveMedia
|
||||
}), nil
|
||||
}
|
||||
|
||||
// diskList returns a human-readable list of candidates for error messages.
|
||||
func diskList(disks []diskCandidate) string {
|
||||
func pickBySize(disks []install.Disk, largest bool) (install.Disk, error) {
|
||||
if len(disks) == 0 {
|
||||
return install.Disk{}, fmt.Errorf("no non-removable disks found")
|
||||
}
|
||||
sorted := slices.Clone(disks)
|
||||
slices.SortFunc(sorted, func(a, b install.Disk) int { return cmp.Compare(a.Bytes, b.Bytes) })
|
||||
if largest {
|
||||
return sorted[len(sorted)-1], nil
|
||||
}
|
||||
return sorted[0], nil
|
||||
}
|
||||
|
||||
// diskList renders candidates for an error message.
|
||||
func diskList(disks []install.Disk) string {
|
||||
lines := make([]string, len(disks))
|
||||
for i, d := range disks {
|
||||
lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30)
|
||||
lines[i] = fmt.Sprintf(" %s (%s, %s)", d.Path, d.SizeHuman(), d.Content)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// singleDisk selects the only non-removable disk, or fails listing all found.
|
||||
func singleDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(disks) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+
|
||||
" largest (largest disk)\n"+
|
||||
" smallest (smallest disk)\n"+
|
||||
" nvme (NVMe only)\n"+
|
||||
" /dev/sdX (exact path)",
|
||||
diskList(disks),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// diskBySize selects the largest (largest=true) or smallest non-removable disk.
|
||||
func diskBySize(largest bool) (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(disks) == 0 {
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
}
|
||||
sort.Slice(disks, func(i, j int) bool {
|
||||
if largest {
|
||||
return disks[i].bytes > disks[j].bytes
|
||||
}
|
||||
return disks[i].bytes < disks[j].bytes
|
||||
})
|
||||
label := "smallest"
|
||||
if largest {
|
||||
label = "largest"
|
||||
}
|
||||
slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
}
|
||||
|
||||
// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found.
|
||||
func nvmeDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var nvmes []diskCandidate
|
||||
for _, d := range disks {
|
||||
if strings.HasPrefix(d.dev, "nvme") {
|
||||
nvmes = append(nvmes, d)
|
||||
}
|
||||
}
|
||||
switch len(nvmes) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no NVMe disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30)
|
||||
return "/dev/" + nvmes[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s",
|
||||
diskList(nvmes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// virtualNICPrefixes lists interface name prefixes that identify non-physical
|
||||
// interfaces (bridges, tunnels, container/OVS veth pairs, etc.). These are
|
||||
// skipped when auto-selecting a NIC so we don't configure docker0 or
|
||||
@@ -297,6 +384,42 @@ var virtualNICPrefixes = []string{
|
||||
|
||||
// resolveNIC returns the interface name to use. "auto" or empty picks the
|
||||
// first physical (non-loopback, non-virtual) interface that is not exclude.
|
||||
// parseRole reads the SPINIFEX_<PLANE>_* kernel-cmdline vars for one plane.
|
||||
// iface is the already-resolved interface name; _VLAN and _MTU are optional
|
||||
// and default to untagged at the link MTU.
|
||||
func parseRole(plane, iface string) (install.NetworkRole, error) {
|
||||
role := install.NetworkRole{
|
||||
Interface: iface,
|
||||
DHCPMode: strings.ToLower(os.Getenv("SPINIFEX_"+plane+"_MODE")) != "static",
|
||||
}
|
||||
if v := os.Getenv("SPINIFEX_" + plane + "_VLAN"); v != "" {
|
||||
id, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return role, fmt.Errorf("SPINIFEX_%s_VLAN: %w", plane, err)
|
||||
}
|
||||
role.VLAN = id
|
||||
}
|
||||
if v := os.Getenv("SPINIFEX_" + plane + "_MTU"); v != "" {
|
||||
mtu, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return role, fmt.Errorf("SPINIFEX_%s_MTU: %w", plane, err)
|
||||
}
|
||||
role.MTU = mtu
|
||||
}
|
||||
if !role.DHCPMode {
|
||||
role.Address = os.Getenv("SPINIFEX_" + plane + "_IP")
|
||||
role.Mask = os.Getenv("SPINIFEX_" + plane + "_MASK")
|
||||
role.Gateway = os.Getenv("SPINIFEX_" + plane + "_GW")
|
||||
if role.Address == "" || role.Mask == "" {
|
||||
return role, fmt.Errorf("SPINIFEX_%s_IP and SPINIFEX_%s_MASK required for static mode", plane, plane)
|
||||
}
|
||||
}
|
||||
if dns := os.Getenv("SPINIFEX_" + plane + "_DNS"); dns != "" {
|
||||
role.DNS = strings.Split(dns, ",")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func resolveNIC(name, exclude string) (string, error) {
|
||||
if name != "" && name != "auto" {
|
||||
return name, nil
|
||||
|
||||
@@ -1,12 +1,57 @@
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
const gib = 1 << 30
|
||||
|
||||
// setEnv clears every SPINIFEX_* variable inherited from the test runner before
|
||||
// applying want, so one case cannot leak a kernel-cmdline value into the next.
|
||||
func setEnv(t *testing.T, want map[string]string) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if k, _, ok := strings.Cut(kv, "="); ok && strings.HasPrefix(k, "SPINIFEX_") {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
for k, v := range want {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// withDisks replaces the block-device scan. The clone matters: candidateDisks
|
||||
// filters with slices.DeleteFunc, which would otherwise mutate the fixture.
|
||||
func withDisks(t *testing.T, disks []install.Disk, err error) {
|
||||
t.Helper()
|
||||
prev := listDisks
|
||||
listDisks = func() ([]install.Disk, error) { return slices.Clone(disks), err }
|
||||
t.Cleanup(func() { listDisks = prev })
|
||||
}
|
||||
|
||||
func mkDisk(path string, bytes int64) install.Disk {
|
||||
return install.Disk{
|
||||
Path: path,
|
||||
ByID: "/dev/disk/by-id/test-" + strings.TrimPrefix(path, "/dev/"),
|
||||
Bytes: bytes,
|
||||
Model: "TESTDISK",
|
||||
LogicalBlockSize: 512,
|
||||
PhysicalBlockSize: 512,
|
||||
Content: "empty",
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReturnsNilWhenNotAuto(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_AUTO")
|
||||
setEnv(t, nil)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -15,3 +60,629 @@ func TestLoadReturnsNilWhenNotAuto(t *testing.T) {
|
||||
t.Fatal("expected nil config when SPINIFEX_AUTO is not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBuildsConfigFromEnv(t *testing.T) {
|
||||
withDisks(t, []install.Disk{mkDisk("/dev/vdb", 40*gib)}, nil)
|
||||
setEnv(t, map[string]string{
|
||||
"SPINIFEX_AUTO": "1",
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_HOSTNAME": "node-7",
|
||||
"SPINIFEX_EMAIL": " ops@example.com ",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
"SPINIFEX_WAN_DNS": "1.1.1.1,8.8.8.8",
|
||||
"SPINIFEX_LAN_IFACE": "eno2",
|
||||
"SPINIFEX_LAN_VLAN": "100",
|
||||
"SPINIFEX_LAN_MTU": "9000",
|
||||
"SPINIFEX_GPU_PASSTHROUGH": "1",
|
||||
})
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Hostname != "node-7" || cfg.RootPassword != "s3cret" {
|
||||
t.Errorf("identity = %q/%q", cfg.Hostname, cfg.RootPassword)
|
||||
}
|
||||
if cfg.Email != "ops@example.com" {
|
||||
t.Errorf("Email = %q, want trimmed", cfg.Email)
|
||||
}
|
||||
if cfg.Storage.FS != install.FSExt4 || len(cfg.Storage.Disks) != 1 {
|
||||
t.Errorf("Storage = %v / %d disks", cfg.Storage.FS, len(cfg.Storage.Disks))
|
||||
}
|
||||
if cfg.WAN.Interface != "eno1" || !cfg.WAN.DHCPMode {
|
||||
t.Errorf("WAN = %+v, want eno1 on DHCP", cfg.WAN)
|
||||
}
|
||||
if !slices.Equal(cfg.WAN.DNS, []string{"1.1.1.1", "8.8.8.8"}) {
|
||||
t.Errorf("WAN.DNS = %v", cfg.WAN.DNS)
|
||||
}
|
||||
if cfg.LAN.Interface != "eno2" || cfg.LAN.VLAN != 100 || cfg.LAN.MTU != 9000 {
|
||||
t.Errorf("LAN = %+v", cfg.LAN)
|
||||
}
|
||||
// Only the wan plane installs a default route.
|
||||
if cfg.LAN.Gateway != "" {
|
||||
t.Errorf("LAN.Gateway = %q, want cleared", cfg.LAN.Gateway)
|
||||
}
|
||||
if !cfg.VPC.Folded() {
|
||||
t.Errorf("VPC = %+v, want folded when no _IFACE is set", cfg.VPC)
|
||||
}
|
||||
if !cfg.GPUPassthrough {
|
||||
t.Error("GPUPassthrough not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsHostname(t *testing.T) {
|
||||
withDisks(t, []install.Disk{mkDisk("/dev/vdb", 40*gib)}, nil)
|
||||
setEnv(t, map[string]string{
|
||||
"SPINIFEX_AUTO": "1",
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
})
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Hostname != "spinifex-node" {
|
||||
t.Errorf("Hostname = %q, want spinifex-node", cfg.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresPassword(t *testing.T) {
|
||||
setEnv(t, map[string]string{"SPINIFEX_AUTO": "1"})
|
||||
|
||||
cfg, err := Load()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got config %+v", cfg)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SPINIFEX_PASSWORD") {
|
||||
t.Errorf("err = %v, want it to name SPINIFEX_PASSWORD", err)
|
||||
}
|
||||
if !strings.HasPrefix(err.Error(), "autoinstall config:") {
|
||||
t.Errorf("err = %v, want the autoinstall wrapper", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigStaticWAN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full static",
|
||||
env: map[string]string{
|
||||
"SPINIFEX_WAN_MODE": "static",
|
||||
"SPINIFEX_WAN_IP": "10.0.0.5",
|
||||
"SPINIFEX_WAN_MASK": "255.255.255.0",
|
||||
"SPINIFEX_WAN_GW": "10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing gateway",
|
||||
env: map[string]string{
|
||||
"SPINIFEX_WAN_MODE": "static",
|
||||
"SPINIFEX_WAN_IP": "10.0.0.5",
|
||||
"SPINIFEX_WAN_MASK": "255.255.255.0",
|
||||
},
|
||||
wantErr: "SPINIFEX_WAN_GW required",
|
||||
},
|
||||
{
|
||||
name: "missing address",
|
||||
env: map[string]string{"SPINIFEX_WAN_MODE": "static"},
|
||||
wantErr: "SPINIFEX_WAN_IP and SPINIFEX_WAN_MASK required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withDisks(t, []install.Disk{mkDisk("/dev/vdb", 40*gib)}, nil)
|
||||
env := map[string]string{
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
}
|
||||
maps.Copy(env, tt.env)
|
||||
setEnv(t, env)
|
||||
|
||||
cfg, err := buildConfig()
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig: %v", err)
|
||||
}
|
||||
if cfg.WAN.DHCPMode {
|
||||
t.Error("WAN.DHCPMode = true, want static")
|
||||
}
|
||||
if cfg.WAN.Address != "10.0.0.5" || cfg.WAN.Gateway != "10.0.0.1" {
|
||||
t.Errorf("WAN = %+v", cfg.WAN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SPINIFEX_ROLE and SPINIFEX_JOIN_ADDR no longer exist: the installer cannot
|
||||
// form a multi-node cluster, so a stale cmdline from an older ISO has to be
|
||||
// inert rather than producing a half-configured node.
|
||||
func TestBuildConfigSkipFormation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
wantSkipForm bool
|
||||
}{
|
||||
{
|
||||
name: "default initializes a single-node cluster",
|
||||
wantSkipForm: false,
|
||||
},
|
||||
{
|
||||
name: "formation deferred to the provisioner",
|
||||
env: map[string]string{"SPINIFEX_SKIP_FORMATION": "1"},
|
||||
wantSkipForm: true,
|
||||
},
|
||||
{
|
||||
name: "stale join cmdline is ignored",
|
||||
env: map[string]string{"SPINIFEX_ROLE": "join", "SPINIFEX_JOIN_ADDR": "10.0.0.1:9999"},
|
||||
wantSkipForm: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withDisks(t, []install.Disk{mkDisk("/dev/vdb", 40*gib)}, nil)
|
||||
env := map[string]string{
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
}
|
||||
maps.Copy(env, tt.env)
|
||||
setEnv(t, env)
|
||||
|
||||
cfg, err := buildConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig: %v", err)
|
||||
}
|
||||
if cfg.SkipFormation != tt.wantSkipForm {
|
||||
t.Errorf("SkipFormation = %v, want %v", cfg.SkipFormation, tt.wantSkipForm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigRejectsDuplicatePlaneBinding(t *testing.T) {
|
||||
withDisks(t, []install.Disk{mkDisk("/dev/vdb", 40*gib)}, nil)
|
||||
setEnv(t, map[string]string{
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
"SPINIFEX_LAN_IFACE": "eno1",
|
||||
})
|
||||
|
||||
if _, err := buildConfig(); err == nil || !strings.Contains(err.Error(), "network roles") {
|
||||
t.Fatalf("err = %v, want a network roles validation failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigPropagatesStorageError(t *testing.T) {
|
||||
setEnv(t, map[string]string{
|
||||
"SPINIFEX_PASSWORD": "s3cret",
|
||||
"SPINIFEX_WAN_IFACE": "eno1",
|
||||
"SPINIFEX_FS": "btrfs",
|
||||
})
|
||||
|
||||
_, err := buildConfig()
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "storage:") {
|
||||
t.Fatalf("err = %v, want a storage: prefix", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want install.NetworkRole
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "dhcp by default",
|
||||
want: install.NetworkRole{Interface: "eno1", DHCPMode: true},
|
||||
},
|
||||
{
|
||||
name: "vlan and mtu",
|
||||
env: map[string]string{"SPINIFEX_LAN_VLAN": "42", "SPINIFEX_LAN_MTU": "9000"},
|
||||
want: install.NetworkRole{Interface: "eno1", DHCPMode: true, VLAN: 42, MTU: 9000},
|
||||
},
|
||||
{
|
||||
name: "dns list is comma separated",
|
||||
env: map[string]string{"SPINIFEX_LAN_DNS": "1.1.1.1,9.9.9.9"},
|
||||
want: install.NetworkRole{Interface: "eno1", DHCPMode: true, DNS: []string{"1.1.1.1", "9.9.9.9"}},
|
||||
},
|
||||
{
|
||||
name: "static",
|
||||
env: map[string]string{
|
||||
"SPINIFEX_LAN_MODE": "STATIC",
|
||||
"SPINIFEX_LAN_IP": "192.168.1.2",
|
||||
"SPINIFEX_LAN_MASK": "255.255.255.0",
|
||||
"SPINIFEX_LAN_GW": "192.168.1.1",
|
||||
},
|
||||
want: install.NetworkRole{
|
||||
Interface: "eno1",
|
||||
Address: "192.168.1.2",
|
||||
Mask: "255.255.255.0",
|
||||
Gateway: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "static without mask",
|
||||
env: map[string]string{"SPINIFEX_LAN_MODE": "static", "SPINIFEX_LAN_IP": "192.168.1.2"},
|
||||
wantErr: "SPINIFEX_LAN_IP and SPINIFEX_LAN_MASK required",
|
||||
},
|
||||
{
|
||||
name: "unparsable vlan",
|
||||
env: map[string]string{"SPINIFEX_LAN_VLAN": "trunk"},
|
||||
wantErr: "SPINIFEX_LAN_VLAN",
|
||||
},
|
||||
{
|
||||
name: "unparsable mtu",
|
||||
env: map[string]string{"SPINIFEX_LAN_MTU": "jumbo"},
|
||||
wantErr: "SPINIFEX_LAN_MTU",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setEnv(t, tt.env)
|
||||
|
||||
got, err := parseRole("LAN", "eno1")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseRole: %v", err)
|
||||
}
|
||||
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", tt.want) {
|
||||
t.Errorf("role = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZFSOpts(t *testing.T) {
|
||||
t.Run("unset leaves the computed defaults", func(t *testing.T) {
|
||||
setEnv(t, nil)
|
||||
got, err := parseZFSOpts()
|
||||
if err != nil {
|
||||
t.Fatalf("parseZFSOpts: %v", err)
|
||||
}
|
||||
if got != (install.ZFSOpts{}) {
|
||||
t.Errorf("opts = %+v, want zero", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all tunables", func(t *testing.T) {
|
||||
setEnv(t, map[string]string{
|
||||
"SPINIFEX_ZFS_ASHIFT": " 12 ",
|
||||
"SPINIFEX_ZFS_COPIES": "2",
|
||||
"SPINIFEX_ZFS_ARC_MAX_MIB": "4096",
|
||||
"SPINIFEX_ZFS_COMPRESS": " lz4 ",
|
||||
"SPINIFEX_ZFS_CHECKSUM": "blake3",
|
||||
})
|
||||
got, err := parseZFSOpts()
|
||||
if err != nil {
|
||||
t.Fatalf("parseZFSOpts: %v", err)
|
||||
}
|
||||
want := install.ZFSOpts{Ashift: 12, Copies: 2, ARCMaxMiB: 4096, Compress: "lz4", Checksum: "blake3"}
|
||||
if got != want {
|
||||
t.Errorf("opts = %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
for _, env := range []string{"SPINIFEX_ZFS_ASHIFT", "SPINIFEX_ZFS_COPIES", "SPINIFEX_ZFS_ARC_MAX_MIB"} {
|
||||
t.Run("unparsable "+env, func(t *testing.T) {
|
||||
setEnv(t, map[string]string{env: "auto"})
|
||||
if _, err := parseZFSOpts(); err == nil || !strings.Contains(err.Error(), env) {
|
||||
t.Fatalf("err = %v, want it to name %s", err, env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorage(t *testing.T) {
|
||||
four := []install.Disk{
|
||||
mkDisk("/dev/vdb", 40*gib), mkDisk("/dev/vdc", 40*gib),
|
||||
mkDisk("/dev/vdd", 40*gib), mkDisk("/dev/vde", 40*gib),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
disks []install.Disk
|
||||
env map[string]string
|
||||
wantFS install.FSType
|
||||
wantPaths []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "ext4 single disk by default",
|
||||
disks: four[:1],
|
||||
wantFS: install.FSExt4,
|
||||
wantPaths: []string{"/dev/vdb"},
|
||||
},
|
||||
{
|
||||
name: "explicit member list preserves order",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_FS": "zfs-raid10", "SPINIFEX_DISKS": "vde,/dev/vdd,vdc, vdb "},
|
||||
wantFS: install.FSZFSRAID10,
|
||||
wantPaths: []string{"/dev/vde", "/dev/vdd", "/dev/vdc", "/dev/vdb"},
|
||||
},
|
||||
{
|
||||
name: "unknown filesystem",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_FS": "btrfs"},
|
||||
wantErr: "unknown filesystem",
|
||||
},
|
||||
{
|
||||
// Choosing which disks to erase is not an unattended decision.
|
||||
name: "multi-disk pool without an explicit list",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_FS": "zfs-raid1"},
|
||||
wantErr: "set SPINIFEX_DISKS to an explicit comma-separated list",
|
||||
},
|
||||
{
|
||||
name: "member that is not present",
|
||||
disks: four[:1],
|
||||
env: map[string]string{"SPINIFEX_DISKS": "vdz"},
|
||||
wantErr: `"vdz" is not an available disk`,
|
||||
},
|
||||
{
|
||||
name: "member list is for pools, not ext4 roles",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_DISKS": "vdb,vdc"},
|
||||
wantErr: "ext4 takes one OS disk",
|
||||
},
|
||||
{
|
||||
name: "dedicated role drives, os first",
|
||||
disks: four,
|
||||
env: map[string]string{
|
||||
"SPINIFEX_DISK": "vdb",
|
||||
"SPINIFEX_DISK_SPINIFEX": "vdc",
|
||||
"SPINIFEX_DISK_PREDASTORE": "vdd",
|
||||
},
|
||||
wantFS: install.FSExt4,
|
||||
wantPaths: []string{"/dev/vdb", "/dev/vdc", "/dev/vdd"},
|
||||
},
|
||||
{
|
||||
// The two-drive layout: predastore gets its own disk while
|
||||
// /var/lib/spinifex stays on the root filesystem.
|
||||
name: "predastore alone",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_DISK": "vdb", "SPINIFEX_DISK_PREDASTORE": "vdc"},
|
||||
wantFS: install.FSExt4,
|
||||
wantPaths: []string{"/dev/vdb", "/dev/vdc"},
|
||||
},
|
||||
{
|
||||
name: "role drive on a pool",
|
||||
disks: four,
|
||||
env: map[string]string{
|
||||
"SPINIFEX_FS": "zfs-raid1", "SPINIFEX_DISKS": "vdb,vdc",
|
||||
"SPINIFEX_DISK_SPINIFEX": "vdd",
|
||||
},
|
||||
wantErr: "SPINIFEX_DISK_SPINIFEX applies to ext4 only",
|
||||
},
|
||||
{
|
||||
name: "role drive that is not present",
|
||||
disks: four[:1],
|
||||
env: map[string]string{"SPINIFEX_DISK": "vdb", "SPINIFEX_DISK_PREDASTORE": "vdz"},
|
||||
wantErr: "SPINIFEX_DISK_PREDASTORE",
|
||||
},
|
||||
{
|
||||
// Two roles cannot share a drive; the disk is erased once and the
|
||||
// second mount would land on top of the first.
|
||||
name: "role drive repeats the os disk",
|
||||
disks: four,
|
||||
env: map[string]string{"SPINIFEX_DISK": "vdb", "SPINIFEX_DISK_SPINIFEX": "vdb"},
|
||||
wantErr: "selected more than once",
|
||||
},
|
||||
{
|
||||
name: "bad zfs tunable",
|
||||
disks: four[:1],
|
||||
env: map[string]string{"SPINIFEX_ZFS_ASHIFT": "big"},
|
||||
wantErr: "SPINIFEX_ZFS_ASHIFT",
|
||||
},
|
||||
{
|
||||
name: "scan failure",
|
||||
disks: nil,
|
||||
env: map[string]string{"SPINIFEX_DISKS": "vdb"},
|
||||
wantErr: "sysfs unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var scanErr error
|
||||
if tt.disks == nil {
|
||||
scanErr = errors.New("sysfs unavailable")
|
||||
}
|
||||
withDisks(t, tt.disks, scanErr)
|
||||
setEnv(t, tt.env)
|
||||
|
||||
got, err := resolveStorage()
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveStorage: %v", err)
|
||||
}
|
||||
if got.FS != tt.wantFS {
|
||||
t.Errorf("FS = %v, want %v", got.FS, tt.wantFS)
|
||||
}
|
||||
if !slices.Equal(got.Paths(), tt.wantPaths) {
|
||||
t.Errorf("Paths = %v, want %v", got.Paths(), tt.wantPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDisk(t *testing.T) {
|
||||
small := mkDisk("/dev/vdb", 40*gib)
|
||||
large := mkDisk("/dev/vdc", 400*gib)
|
||||
nvme := mkDisk("/dev/nvme0n1", 200*gib)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
disks []install.Disk
|
||||
target string
|
||||
wantPath string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "auto with one candidate", disks: []install.Disk{small}, target: "auto", wantPath: "/dev/vdb"},
|
||||
{name: "empty target is auto", disks: []install.Disk{small}, wantPath: "/dev/vdb"},
|
||||
{
|
||||
name: "auto with several candidates",
|
||||
disks: []install.Disk{small, large},
|
||||
target: "auto",
|
||||
wantErr: "expected exactly one disk, found 2",
|
||||
},
|
||||
{name: "largest", disks: []install.Disk{small, large, nvme}, target: "LARGEST", wantPath: "/dev/vdc"},
|
||||
{name: "smallest", disks: []install.Disk{large, small, nvme}, target: " smallest ", wantPath: "/dev/vdb"},
|
||||
{name: "nvme", disks: []install.Disk{small, large, nvme}, target: "nvme", wantPath: "/dev/nvme0n1"},
|
||||
{
|
||||
name: "nvme with none present",
|
||||
disks: []install.Disk{small, large},
|
||||
target: "nvme",
|
||||
wantErr: "expected exactly one NVMe disk, found 0",
|
||||
},
|
||||
{name: "exact path", disks: []install.Disk{small, large}, target: "/dev/vdc", wantPath: "/dev/vdc"},
|
||||
{
|
||||
name: "path that is not present",
|
||||
disks: []install.Disk{small},
|
||||
target: "/dev/vdz",
|
||||
wantErr: "is not an available disk",
|
||||
},
|
||||
{name: "no candidates at all", disks: []install.Disk{}, target: "largest", wantErr: "no non-removable disks found"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withDisks(t, tt.disks, nil)
|
||||
|
||||
got, err := resolveDisk(tt.target)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDisk(%q): %v", tt.target, err)
|
||||
}
|
||||
if got.Path != tt.wantPath {
|
||||
t.Errorf("path = %q, want %q", got.Path, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateDisksExcludesRemovableAndLiveMedia(t *testing.T) {
|
||||
fixed := mkDisk("/dev/vdb", 40*gib)
|
||||
usb := mkDisk("/dev/sdz", 16*gib)
|
||||
usb.Removable = true
|
||||
boot := mkDisk("/dev/sdy", 16*gib)
|
||||
boot.LiveMedia = true
|
||||
withDisks(t, []install.Disk{usb, fixed, boot}, nil)
|
||||
|
||||
got, err := candidateDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("candidateDisks: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Path != "/dev/vdb" {
|
||||
t.Fatalf("candidates = %v, want only /dev/vdb", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateDisksPropagatesScanError(t *testing.T) {
|
||||
withDisks(t, nil, errors.New("read /sys/block: denied"))
|
||||
if _, err := candidateDisks(); err == nil {
|
||||
t.Fatal("expected the scan error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBySizeRejectsEmptySet(t *testing.T) {
|
||||
if _, err := pickBySize(nil, true); err == nil {
|
||||
t.Fatal("expected an error for an empty disk set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickBySizeLeavesInputOrderIntact(t *testing.T) {
|
||||
disks := []install.Disk{mkDisk("/dev/vdb", 40*gib), mkDisk("/dev/vdc", 400*gib)}
|
||||
if _, err := pickBySize(disks, false); err != nil {
|
||||
t.Fatalf("pickBySize: %v", err)
|
||||
}
|
||||
if disks[0].Path != "/dev/vdb" {
|
||||
t.Errorf("input reordered: %v", disks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskList(t *testing.T) {
|
||||
got := diskList([]install.Disk{mkDisk("/dev/vdb", 40*gib), mkDisk("/dev/vdc", 2048*gib)})
|
||||
want := " /dev/vdb (40.0G, empty)\n /dev/vdc (2.0T, empty)"
|
||||
if got != want {
|
||||
t.Errorf("diskList =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNICExplicitName(t *testing.T) {
|
||||
got, err := resolveNIC("eno1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveNIC: %v", err)
|
||||
}
|
||||
if got != "eno1" {
|
||||
t.Errorf("nic = %q, want eno1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The auto path reads the host's real interfaces, so assert the selection rules
|
||||
// rather than a specific name.
|
||||
func TestResolveNICAutoSkipsVirtualInterfaces(t *testing.T) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Skipf("no interface list on this host: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveNIC("auto", "")
|
||||
if err != nil {
|
||||
if len(ifaces) > 0 {
|
||||
t.Logf("no physical NIC among %d interfaces: %v", len(ifaces), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
idx := slices.IndexFunc(ifaces, func(i net.Interface) bool { return i.Name == got })
|
||||
if idx < 0 {
|
||||
t.Fatalf("resolveNIC returned %q, which is not a host interface", got)
|
||||
}
|
||||
sel := ifaces[idx]
|
||||
if sel.Flags&net.FlagLoopback != 0 || sel.Flags&net.FlagBroadcast == 0 || len(sel.HardwareAddr) == 0 {
|
||||
t.Errorf("selected %q with flags %v and MAC %q", got, sel.Flags, sel.HardwareAddr)
|
||||
}
|
||||
for _, pfx := range virtualNICPrefixes {
|
||||
if strings.HasPrefix(got, pfx) {
|
||||
t.Errorf("selected virtual interface %q (prefix %q)", got, pfx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNICAutoHonoursExclude(t *testing.T) {
|
||||
first, err := resolveNIC("auto", "")
|
||||
if err != nil {
|
||||
t.Skipf("no physical NIC on this host: %v", err)
|
||||
}
|
||||
got, err := resolveNIC("", first)
|
||||
if err == nil && got == first {
|
||||
t.Errorf("resolveNIC returned the excluded interface %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,23 @@ import (
|
||||
// Config holds the values the firstboot service needs to configure the node.
|
||||
type Config struct {
|
||||
Hostname string
|
||||
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
|
||||
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
|
||||
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
|
||||
// EncapIP is the Geneve tunnel IP for OVN, taken from the vpc plane after
|
||||
// collapsing (vpc <- lan <- wan). Empty when that plane uses DHCP —
|
||||
// setup-ovn.sh auto-detects the IP from the default route in that case.
|
||||
EncapIP string
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
// LANIP is the internal cluster address, taken from the lan plane after
|
||||
// collapsing. Passed as --bind and --cluster-bind so predastore replication,
|
||||
// the NATS mesh and OVN control traffic stay off the public interface.
|
||||
// Empty leaves the 0.0.0.0 wildcard default, which is correct for a
|
||||
// single-NIC node where lan folds onto wan.
|
||||
LANIP string
|
||||
// WANIP is the public address, taken from the wan plane. Passed as
|
||||
// --advertise so northstar's :53 listener, the awsgw registry host and the
|
||||
// dial target recorded for off-host clients stay on the public interface.
|
||||
// Without it a concrete --bind is echoed back as the advertise address,
|
||||
// which would move all three onto the internal plane. Empty when wan uses
|
||||
// DHCP, leaving spx to auto-detect from the default route.
|
||||
WANIP string
|
||||
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
|
||||
// the headless path. Passed to `spx admin init --email=<value>` when set;
|
||||
// omitted entirely when empty.
|
||||
@@ -147,7 +156,8 @@ resolvconf -u
|
||||
# Write the banner (first time, spinifex-banner service will do this on reboot)
|
||||
/usr/local/bin/spx admin banner --boot-check
|
||||
|
||||
# Cluster formation — capture credentials to file for display on console.
|
||||
# Cluster formation. Output goes to the journal via the systemd unit; spx
|
||||
# admin init writes credentials to disk and never prints them.
|
||||
%s 2>&1
|
||||
|
||||
# Fix ownership of files spx admin init wrote. spx runs as root under
|
||||
@@ -197,6 +207,9 @@ fi
|
||||
|
||||
# Enable services to start, on reboot
|
||||
systemctl enable spinifex.target spinifex-banner.service
|
||||
# WantedBy=timers.target only self-activates once enabled — without this the
|
||||
# JetStream ENOSPC-latch watchdog never runs and a full disk needs a manual restart.
|
||||
systemctl enable --now spinifex-nats-watchdog.timer
|
||||
|
||||
# Mark complete only after every step above has succeeded. Until this point,
|
||||
# any failure (set -e) leaves the marker absent and the next reboot retries
|
||||
@@ -244,14 +257,67 @@ func buildClusterCmd(cfg Config) string {
|
||||
if cfg.GPUPassthrough {
|
||||
gpuFlag = " --gpu-passthrough"
|
||||
}
|
||||
switch cfg.ClusterRole {
|
||||
case "join":
|
||||
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
|
||||
default:
|
||||
return fmt.Sprintf("spx admin init --node %s --nodes 1%s%s", cfg.Hostname, emailFlag, gpuFlag)
|
||||
// Without these the node takes the 0.0.0.0 wildcard default and every
|
||||
// internal service resolves onto the auto-detected WAN address, which is
|
||||
// exactly what the three-plane model exists to prevent.
|
||||
bindFlags := ""
|
||||
if cfg.LANIP != "" {
|
||||
bindFlags = fmt.Sprintf(" --bind %s --cluster-bind %s", cfg.LANIP, cfg.LANIP)
|
||||
}
|
||||
// --advertise must be explicit whenever --bind is: spx echoes a concrete
|
||||
// bind address straight back as the advertise address and never reaches its
|
||||
// WAN auto-detection, which would silently publish the internal plane as
|
||||
// this node's public dial target.
|
||||
preamble := ""
|
||||
switch {
|
||||
case bindFlags == "":
|
||||
// Nothing pinned, so spx auto-detects both and a guessed advertise
|
||||
// address would only get in the way.
|
||||
case cfg.WANIP != "":
|
||||
bindFlags += " --advertise " + cfg.WANIP
|
||||
default:
|
||||
// A DHCP wan has no address at install time, but it does by the time
|
||||
// firstboot runs, so read it off the bridge instead of shipping the
|
||||
// bind address as the public one.
|
||||
preamble = wanAdvertisePreamble
|
||||
bindFlags += ` $SPX_ADVERTISE`
|
||||
}
|
||||
|
||||
// Always a single-node cluster. The installer cannot form a multi-node one:
|
||||
// cluster membership decides the OVN database topology and the join token
|
||||
// only exists once the primary has booted, so neither is knowable while the
|
||||
// nodes are still being installed. Multi-node is a post-install conversion,
|
||||
// documented in the multi-node install guide.
|
||||
cmd := fmt.Sprintf("spx admin init --node %s --nodes 1%s%s%s", cfg.Hostname, bindFlags, emailFlag, gpuFlag)
|
||||
return preamble + cmd
|
||||
}
|
||||
|
||||
// wanAdvertisePreamble resolves the wan plane's DHCP lease into $SPX_ADVERTISE
|
||||
// just before formation. br-wan is always the wan bridge — that plane is the one
|
||||
// role that cannot fold — so its address is the node's public identity.
|
||||
//
|
||||
// Falling through with SPX_ADVERTISE empty is deliberate: no lease means there
|
||||
// is no public address to advertise yet, and spx echoing the bind address is
|
||||
// still better than formation failing outright.
|
||||
const wanAdvertisePreamble = `# The wan plane leases its address, so it is only knowable at boot. Without this
|
||||
# spx would echo --bind back as the advertise address and publish the internal
|
||||
# plane as this node's public dial target.
|
||||
SPX_ADVERTISE=""
|
||||
echo "[firstboot] waiting for the wan plane to acquire an address..."
|
||||
for _i in $(seq 1 60); do
|
||||
_wan_ip=$(ip -4 -o addr show br-wan scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||
if [ -n "$_wan_ip" ]; then
|
||||
SPX_ADVERTISE=" --advertise $_wan_ip"
|
||||
echo "[firstboot] wan address $_wan_ip (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$SPX_ADVERTISE" ]; then
|
||||
echo "[firstboot] warning: br-wan has no address after 60s — this node will advertise its bind address"
|
||||
fi
|
||||
`
|
||||
|
||||
// shellEscapeSingle wraps s in single quotes with any embedded single
|
||||
// quotes escaped. Minimal — we only need this because the email value is
|
||||
// interpolated into a shell script written by Write().
|
||||
|
||||
@@ -2,6 +2,7 @@ package firstboot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,7 +25,7 @@ func TestWriteScriptNoCallbackWhenEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node", ClusterRole: "init"}
|
||||
cfg := Config{Hostname: "test-node"}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
@@ -45,7 +46,6 @@ func TestWriteScriptEmbedsCurlWhenCallbackSet(t *testing.T) {
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "test-node",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
@@ -70,7 +70,7 @@ func TestWriteScriptRunsOVNWhenFormationOwned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node", ClusterRole: "init"}
|
||||
cfg := Config{Hostname: "test-node"}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestWriteScriptDefersOVNWhenSkipFormation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node", ClusterRole: "init", SkipFormation: true}
|
||||
cfg := Config{Hostname: "test-node", SkipFormation: true}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
@@ -114,6 +114,25 @@ func readScript(t *testing.T, root string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestWriteScriptEnablesNatsWatchdogTimer pins that firstboot activates the
|
||||
// JetStream ENOSPC-latch watchdog timer. The unit file's WantedBy=timers.target
|
||||
// only takes effect once enabled — without this line the watchdog is dropped
|
||||
// onto disk but never runs, and a full disk needs a manual nats restart forever.
|
||||
func TestWriteScriptEnablesNatsWatchdogTimer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node"}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
content := readScript(t, root)
|
||||
|
||||
if !strings.Contains(content, "systemctl enable --now spinifex-nats-watchdog.timer") {
|
||||
t.Error("firstboot script must enable --now spinifex-nats-watchdog.timer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
@@ -121,7 +140,6 @@ func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "node1",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
@@ -146,3 +164,121 @@ func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
|
||||
t.Error("curl must appear after done marker write")
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-NIC node must keep cluster traffic on the internal plane while still
|
||||
// publishing the public one. --advertise has to be explicit to do that: spx
|
||||
// returns a concrete --bind verbatim as the advertise address and never reaches
|
||||
// its WAN auto-detection, so omitting it moves northstar's :53 listener and the
|
||||
// off-host dial target onto the internal plane.
|
||||
func TestBuildClusterCmdSeparatesBindFromAdvertise(t *testing.T) {
|
||||
cmd := buildClusterCmd(Config{
|
||||
Hostname: "hydrogen",
|
||||
LANIP: "10.0.0.3",
|
||||
WANIP: "216.218.163.99",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"--bind 10.0.0.3",
|
||||
"--cluster-bind 10.0.0.3",
|
||||
"--advertise 216.218.163.99",
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a bind address there is nothing to correct: spx auto-detects both,
|
||||
// and a stray --advertise would pin the node to an address the installer only
|
||||
// guessed at. A single-NIC node also collapses wan and lan onto one address,
|
||||
// where the split is meaningless.
|
||||
func TestBuildClusterCmdOmitsAdvertiseWithoutBind(t *testing.T) {
|
||||
cmd := buildClusterCmd(Config{
|
||||
Hostname: "node1",
|
||||
WANIP: "216.218.163.99",
|
||||
})
|
||||
if strings.Contains(cmd, "--advertise") {
|
||||
t.Errorf("advertise must not be passed without a bind address:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// A DHCP wan has no address at install time, so the advertise value has to be
|
||||
// resolved at boot. Shipping --bind alone here would republish the internal
|
||||
// plane as the node's public dial target, which is the case the static path
|
||||
// already guards against.
|
||||
func TestBuildClusterCmdResolvesDHCPWanAtBoot(t *testing.T) {
|
||||
cmd := buildClusterCmd(Config{
|
||||
Hostname: "hydrogen",
|
||||
LANIP: "10.0.0.3",
|
||||
// WANIP empty — the wan plane is on DHCP.
|
||||
})
|
||||
if !strings.Contains(cmd, "ip -4 -o addr show br-wan") {
|
||||
t.Errorf("must read the wan address off the bridge:\n%s", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, "$SPX_ADVERTISE") {
|
||||
t.Errorf("resolved address must reach the command:\n%s", cmd)
|
||||
}
|
||||
// The preamble has to run before the command that consumes it.
|
||||
if strings.Index(cmd, "SPX_ADVERTISE=\"\"") > strings.Index(cmd, "spx admin init") {
|
||||
t.Errorf("preamble must precede the spx invocation:\n%s", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, "--bind 10.0.0.3") {
|
||||
t.Errorf("bind still comes from the lan plane:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// With no bind address there is nothing to correct, so neither the literal flag
|
||||
// nor the boot-time lookup should appear — spx auto-detects both ends.
|
||||
func TestBuildClusterCmdSkipsWanLookupWithoutBind(t *testing.T) {
|
||||
cmd := buildClusterCmd(Config{Hostname: "node1"})
|
||||
if strings.Contains(cmd, "SPX_ADVERTISE") || strings.Contains(cmd, "--advertise") {
|
||||
t.Errorf("no bind means no advertise handling at all:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// The advertise handling injects a shell preamble into the generated script, so
|
||||
// the permutations that change its shape are syntax-checked rather than only
|
||||
// string-matched — a broken quote here bricks first boot with no installer error.
|
||||
func TestGeneratedScriptIsValidShell(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cfg Config
|
||||
}{
|
||||
{"dhcp wan resolves at boot", Config{Hostname: "hydrogen", LANIP: "10.0.0.3"}},
|
||||
{"static wan is inlined", Config{Hostname: "hydrogen", LANIP: "10.0.0.3", WANIP: "216.218.163.99"}},
|
||||
{"single nic, nothing pinned", Config{Hostname: "node1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
if err := Write(root, tc.cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
||||
if out, err := exec.Command("bash", "-n", path).CombinedOutput(); err != nil {
|
||||
script, _ := os.ReadFile(path)
|
||||
t.Fatalf("generated script is not valid shell: %v\n%s\n---\n%s", err, out, script)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The installer cannot form a multi-node cluster: membership decides the OVN
|
||||
// database topology, and the join token only exists once the primary has
|
||||
// booted. Neither is knowable at install time, so firstboot must only ever
|
||||
// initialize a single-node cluster and leave multi-node to a post-install
|
||||
// conversion.
|
||||
func TestBuildClusterCmdNeverJoins(t *testing.T) {
|
||||
for _, cfg := range []Config{
|
||||
{Hostname: "node1"},
|
||||
{Hostname: "node1", LANIP: "10.0.0.3", WANIP: "216.218.163.99"},
|
||||
{Hostname: "node1", LANIP: "10.0.0.3"},
|
||||
} {
|
||||
cmd := buildClusterCmd(cfg)
|
||||
if strings.Contains(cmd, "spx admin join") {
|
||||
t.Errorf("firstboot must never join a cluster:\n%s", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, "--nodes 1") {
|
||||
t.Errorf("want a single-node init, got:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Diversion wrapper for grub-install on a spinifex-boot-tool system.
|
||||
#
|
||||
# On ZFS root the bootloader lives on per-disk ESPs, not in /boot. A plain
|
||||
# grub-install writes a core image that tries to read the pool, which works
|
||||
# until the next OpenZFS feature flag lands and then silently does not. Worse,
|
||||
# the grub-pc and shim packages run grub-install from their own postinst, so an
|
||||
# ordinary `apt upgrade` would undo the layout with no visible error.
|
||||
#
|
||||
# Mirrors the wrapper Proxmox ships for the same reason.
|
||||
|
||||
set -e
|
||||
|
||||
REAL="/usr/sbin/grub-install.real"
|
||||
TOOL="/usr/sbin/spinifex-boot-tool"
|
||||
|
||||
# Not a managed system: behave exactly as the real grub-install.
|
||||
if ! "$TOOL" status --quiet 2>/dev/null; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
# dpkg sets this when a maintainer script is running. The bootloader packages
|
||||
# reinstalling themselves is expected and is redirected, not refused.
|
||||
case "${DPKG_MAINTSCRIPT_PACKAGE:-}" in
|
||||
grub-* | shim-*)
|
||||
echo "This system is managed by spinifex-boot-tool; redirecting grub-install to 'spinifex-boot-tool reinit'." >&2
|
||||
exec "$TOOL" reinit
|
||||
;;
|
||||
esac
|
||||
|
||||
cat >&2 <<EOF
|
||||
E: This system's bootloader is managed by spinifex-boot-tool.
|
||||
|
||||
Running grub-install directly would install GRUB somewhere the firmware does
|
||||
not read, and would leave the per-disk ESPs holding a stale kernel.
|
||||
|
||||
Use instead:
|
||||
spinifex-boot-tool refresh re-sync kernels and grub.cfg to every ESP
|
||||
spinifex-boot-tool reinit reinstall GRUB on every ESP
|
||||
spinifex-boot-tool status list the ESPs under management
|
||||
|
||||
To run the real grub-install anyway (you probably do not want this):
|
||||
$REAL $*
|
||||
EOF
|
||||
exit 1
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/bin/bash
|
||||
# spinifex-boot-tool — keep a bootloader on every disk of a ZFS root pool.
|
||||
#
|
||||
# GRUB cannot be trusted to read a ZFS pool: its ZFS reader lags OpenZFS by
|
||||
# years, and enabling a new feature flag on the pool can make the machine
|
||||
# unbootable with no warning. So /boot on the pool is never read by firmware.
|
||||
# Instead each pool member carries a small FAT32 ESP holding its own copy of
|
||||
# GRUB, the kernels and the initramfs images, and this tool keeps them in step.
|
||||
#
|
||||
# Modelled directly on Proxmox's proxmox-boot-tool, which has run this design
|
||||
# across their entire installed base for years.
|
||||
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
ESP_LIST="/etc/kernel/spinifex-boot-uuids"
|
||||
ESPMOUNTS="/var/tmp/espmounts"
|
||||
MARKER="spinifex-boot-uuids"
|
||||
|
||||
# Kernels are only ever copied out of the real /boot, which lives on the pool.
|
||||
# Once an ESP is bind-mounted over /boot this variable is how we still find it.
|
||||
BOOTROOT="/boot"
|
||||
|
||||
warn() { echo "W: $*" >&2; }
|
||||
die() { echo "E: $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command>
|
||||
|
||||
format <partition> [--force] make <partition> a Spinifex ESP
|
||||
init <partition> register <partition> and install a bootloader on it
|
||||
clean [--dry-run] drop ESPs that are no longer present
|
||||
refresh [--hook <name>] re-sync kernels and grub.cfg to every ESP
|
||||
reinit re-run refresh, reinstalling GRUB on every ESP
|
||||
kernel <add|remove> <ver> pin or unpin a kernel version
|
||||
status [--quiet] list registered ESPs
|
||||
EOF
|
||||
}
|
||||
|
||||
# reexec_in_mountns re-runs this script inside a private mount namespace. Every
|
||||
# code path here bind-mounts an ESP over /boot; if the script is killed at the
|
||||
# wrong moment outside a namespace, the running system is left with its real
|
||||
# /boot masked and the next kernel upgrade writes into a FAT partition.
|
||||
reexec_in_mountns() {
|
||||
if [ "${SPINIFEX_BOOT_TOOL_NS:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
export SPINIFEX_BOOT_TOOL_NS=1
|
||||
# --propagation private stops the bind mounts leaking back to the host.
|
||||
exec unshare --mount --propagation private -- "$0" "$@"
|
||||
}
|
||||
|
||||
# --- ESP registry -----------------------------------------------------------
|
||||
|
||||
# esp_list prints the registered ESP filesystem UUIDs.
|
||||
esp_list() {
|
||||
[ -f "$ESP_LIST" ] || return 0
|
||||
grep -v '^[[:space:]]*\(#\|$\)' "$ESP_LIST" || true
|
||||
}
|
||||
|
||||
# uuid_of prints the FAT filesystem UUID of a partition.
|
||||
uuid_of() {
|
||||
blkid "$1" -o value -s UUID 2>/dev/null
|
||||
}
|
||||
|
||||
# path_for_uuid resolves a registered UUID back to a device, or nothing if the
|
||||
# disk has been pulled.
|
||||
path_for_uuid() {
|
||||
local dev
|
||||
dev="/dev/disk/by-uuid/$1"
|
||||
[ -e "$dev" ] || return 1
|
||||
readlink -f "$dev"
|
||||
}
|
||||
|
||||
# mount_esp mounts the ESP for a UUID and echoes its mountpoint.
|
||||
mount_esp() {
|
||||
local uuid="$1" dev mp
|
||||
dev=$(path_for_uuid "$uuid") || return 1
|
||||
mp="$ESPMOUNTS/$uuid"
|
||||
mkdir -p "$mp"
|
||||
mountpoint -q "$mp" || mount "$dev" "$mp" || return 1
|
||||
echo "$mp"
|
||||
}
|
||||
|
||||
umount_esp() {
|
||||
local mp="$ESPMOUNTS/$1"
|
||||
mountpoint -q "$mp" && umount "$mp"
|
||||
rmdir "$mp" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# --- format / init ----------------------------------------------------------
|
||||
|
||||
do_format() {
|
||||
local part="$1" force="${2:-}"
|
||||
[ -b "$part" ] || die "$part is not a block device"
|
||||
|
||||
if [ "$force" != "--force" ]; then
|
||||
local existing
|
||||
existing=$(blkid -o value -s TYPE "$part" 2>/dev/null || true)
|
||||
[ -z "$existing" ] || die "$part already contains a $existing filesystem; pass --force to overwrite"
|
||||
fi
|
||||
mkfs.vfat -F 32 "$part" || die "mkfs.vfat failed on $part"
|
||||
}
|
||||
|
||||
do_init() {
|
||||
local part="$1"
|
||||
[ -b "$part" ] || die "$part is not a block device"
|
||||
|
||||
local uuid
|
||||
uuid=$(uuid_of "$part") || true
|
||||
[ -n "$uuid" ] || die "$part has no filesystem UUID — run '$(basename "$0") format $part' first"
|
||||
|
||||
mkdir -p "$(dirname "$ESP_LIST")"
|
||||
touch "$ESP_LIST"
|
||||
if ! grep -qx "$uuid" "$ESP_LIST"; then
|
||||
echo "$uuid" >>"$ESP_LIST"
|
||||
fi
|
||||
echo "Registered $part ($uuid)"
|
||||
|
||||
install_hooks
|
||||
# Only the ESP just registered: the others are already current, and
|
||||
# refreshing all of them here makes registering N disks an N-squared
|
||||
# sequence of grub-installs.
|
||||
refresh_one "$uuid" || die "could not make $part bootable"
|
||||
}
|
||||
|
||||
do_clean() {
|
||||
local dry="${1:-}" uuid keep=()
|
||||
for uuid in $(esp_list); do
|
||||
if path_for_uuid "$uuid" >/dev/null 2>&1; then
|
||||
keep+=("$uuid")
|
||||
else
|
||||
echo "Removing stale ESP $uuid"
|
||||
fi
|
||||
done
|
||||
if [ "$dry" = "--dry-run" ]; then
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "${keep[@]+"${keep[@]}"}" >"$ESP_LIST"
|
||||
}
|
||||
|
||||
# --- refresh ----------------------------------------------------------------
|
||||
|
||||
# kernel_versions lists the kernels present in the real /boot, newest first, so
|
||||
# the default menu entry is the newest kernel.
|
||||
kernel_versions() {
|
||||
local img
|
||||
for img in "$BOOTROOT"/vmlinuz-*; do
|
||||
[ -e "$img" ] || continue
|
||||
basename "$img" | sed 's/^vmlinuz-//'
|
||||
done | sort -Vr
|
||||
}
|
||||
|
||||
# sync_esp copies the current kernels and initramfs images onto one ESP and
|
||||
# removes anything left from a kernel that is no longer installed — an ESP is
|
||||
# small and a stale 100MB initramfs is what makes the next upgrade fail.
|
||||
sync_esp() {
|
||||
local mp="$1" kver f base
|
||||
|
||||
local -A wanted=()
|
||||
for kver in $(kernel_versions); do
|
||||
wanted["vmlinuz-$kver"]=1
|
||||
wanted["initrd.img-$kver"]=1
|
||||
done
|
||||
|
||||
for f in "$mp"/vmlinuz-* "$mp"/initrd.img-*; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
[ -n "${wanted[$base]:-}" ] || rm -f "$f"
|
||||
done
|
||||
|
||||
for kver in $(kernel_versions); do
|
||||
for base in "vmlinuz-$kver" "initrd.img-$kver"; do
|
||||
[ -e "$BOOTROOT/$base" ] || continue
|
||||
# Copy to a temp name and rename: a half-written kernel on the ESP
|
||||
# is a machine that does not come back from a reboot.
|
||||
cp -f "$BOOTROOT/$base" "$mp/$base.tmp" || return 1
|
||||
mv -f "$mp/$base.tmp" "$mp/$base" || return 1
|
||||
done
|
||||
done
|
||||
|
||||
# GRUB reads its theme from $prefix, which is the ESP's own grub directory,
|
||||
# so the branding has to be copied out of /boot alongside the kernels.
|
||||
mkdir -p "$mp/grub/fonts"
|
||||
for f in grub/theme.txt grub/splash.png grub/fonts/unicode.pf2; do
|
||||
[ -e "$BOOTROOT/$f" ] && cp -f "$BOOTROOT/$f" "$mp/$f"
|
||||
done
|
||||
|
||||
# Marks the partition as ours, so a later `format` on the wrong disk is
|
||||
# obviously a mistake rather than a silent overwrite.
|
||||
echo "$MARKER" >"$mp/$MARKER"
|
||||
return 0
|
||||
}
|
||||
|
||||
# grub_install_bin echoes the real grub-install. On a managed system the normal
|
||||
# name is a wrapper that refuses to run, and it must not intercept this tool's
|
||||
# own calls — that would make the refusal a self-deadlock.
|
||||
grub_install_bin() {
|
||||
if [ -x /usr/sbin/grub-install.real ]; then
|
||||
echo /usr/sbin/grub-install.real
|
||||
else
|
||||
echo grub-install
|
||||
fi
|
||||
}
|
||||
|
||||
# install_grub puts GRUB itself on one ESP, for both firmware types.
|
||||
#
|
||||
# --boot-directory points at the ESP, not at /boot, so the GRUB core image
|
||||
# embeds a FAT reader and a search for the ESP's own UUID. It never needs to
|
||||
# read the pool, which is the entire point of this tool.
|
||||
install_grub() {
|
||||
local mp="$1" dev="$2" rc=0 grub
|
||||
grub="$(grub_install_bin)"
|
||||
|
||||
if [ ! -d /sys/firmware/efi ]; then
|
||||
"$grub" --target=i386-pc --boot-directory="$mp" --recheck "$dev" || rc=1
|
||||
return $rc
|
||||
fi
|
||||
|
||||
# NVRAM is written at install time only. A refresh runs on every kernel
|
||||
# upgrade, and rewriting boot variables that often has bricked firmware on
|
||||
# more than one vendor's boards.
|
||||
local nvram="--no-nvram"
|
||||
[ "${SPINIFEX_BOOT_TOOL_NVRAM:-0}" = "1" ] && nvram=""
|
||||
|
||||
# Twice: once as a named entry, and once at the removable path, which is all
|
||||
# some firmware will ever look at and is what keeps the disk bootable after
|
||||
# its NVRAM entry is lost or the drive is moved to another machine.
|
||||
# shellcheck disable=SC2086 # $nvram is a single optional flag, not a path
|
||||
"$grub" --target=x86_64-efi \
|
||||
--efi-directory="$mp" --boot-directory="$mp" \
|
||||
--bootloader-id=spinifex $nvram --recheck "$dev" || rc=1
|
||||
"$grub" --target=x86_64-efi \
|
||||
--efi-directory="$mp" --boot-directory="$mp" \
|
||||
--bootloader-id=spinifex --removable --no-nvram --recheck "$dev" || rc=1
|
||||
return $rc
|
||||
}
|
||||
|
||||
# update_grub_on regenerates grub.cfg for one ESP.
|
||||
#
|
||||
# The bind mount is the trick that makes this work: grub-mkconfig probes the
|
||||
# device behind /boot to decide how to find the kernel at boot. With the ESP
|
||||
# mounted there it emits a FAT UUID search and paths relative to the ESP root,
|
||||
# so the generated config matches what the embedded core image can actually do.
|
||||
update_grub_on() {
|
||||
local mp="$1" rc=0
|
||||
mount --bind "$mp" "$BOOTROOT" || return 1
|
||||
grub-mkconfig -o "$BOOTROOT/grub/grub.cfg" || rc=1
|
||||
umount "$BOOTROOT" || warn "could not unmount the bind mount over $BOOTROOT"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# refresh_one makes a single registered ESP independently bootable: kernels,
|
||||
# bootloader, and a grub.cfg generated against that ESP's own UUID.
|
||||
refresh_one() {
|
||||
local uuid="$1" mp dev disk rc=0
|
||||
|
||||
if ! dev=$(path_for_uuid "$uuid"); then
|
||||
warn "ESP $uuid is not present, skipping"
|
||||
return 1
|
||||
fi
|
||||
if ! mp=$(mount_esp "$uuid"); then
|
||||
warn "could not mount ESP $uuid"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# The whole disk, not the partition: BIOS GRUB embeds into the boot
|
||||
# sector and the BIOS boot partition, both of which live on the disk.
|
||||
disk=$(lsblk -no pkname "$dev")
|
||||
disk="/dev/$disk"
|
||||
|
||||
if sync_esp "$mp" && install_grub "$mp" "$disk" && update_grub_on "$mp"; then
|
||||
echo "Synced $dev ($uuid)"
|
||||
else
|
||||
warn "failed to update ESP $uuid on $dev"
|
||||
rc=1
|
||||
fi
|
||||
umount_esp "$uuid"
|
||||
return $rc
|
||||
}
|
||||
|
||||
do_refresh() {
|
||||
local uuid failed=0 ok=0 total=0
|
||||
|
||||
local uuids
|
||||
uuids=$(esp_list)
|
||||
if [ -z "$uuids" ]; then
|
||||
echo "No ESPs registered, nothing to do."
|
||||
return 0
|
||||
fi
|
||||
|
||||
for uuid in $uuids; do
|
||||
total=$((total + 1))
|
||||
if refresh_one "$uuid"; then
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ok" -eq 0 ]; then
|
||||
die "no ESP could be updated ($failed of $total failed) — this system may not boot"
|
||||
fi
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
# Strict mode is used by the installer, where a partly-updated set of
|
||||
# ESPs means the install is broken and should fail loudly now rather
|
||||
# than at the first reboot.
|
||||
if [ "${SPINIFEX_BOOT_TOOL_STRICT:-0}" = "1" ]; then
|
||||
die "$failed of $total ESPs failed to update"
|
||||
fi
|
||||
warn "$failed of $total ESPs failed to update; $ok succeeded, so the system will still boot"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local quiet="${1:-}" uuid dev
|
||||
if [ ! -s "$ESP_LIST" ]; then
|
||||
[ "$quiet" = "--quiet" ] || echo "No ESPs registered — this system does not use spinifex-boot-tool."
|
||||
return 1
|
||||
fi
|
||||
[ "$quiet" = "--quiet" ] && return 0
|
||||
for uuid in $(esp_list); do
|
||||
if dev=$(path_for_uuid "$uuid"); then
|
||||
echo "$uuid $dev"
|
||||
else
|
||||
echo "$uuid MISSING"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
do_kernel() {
|
||||
local action="$1" kver="${2:-}"
|
||||
local pinfile="/etc/kernel/spinifex-boot-pin"
|
||||
case "$action" in
|
||||
add)
|
||||
[ -n "$kver" ] || die "kernel add needs a version"
|
||||
touch "$pinfile"
|
||||
grep -qx "$kver" "$pinfile" || echo "$kver" >>"$pinfile"
|
||||
;;
|
||||
remove)
|
||||
[ -n "$kver" ] || die "kernel remove needs a version"
|
||||
[ -f "$pinfile" ] && sed -i "\|^$kver\$|d" "$pinfile"
|
||||
;;
|
||||
*) die "unknown kernel action: $action" ;;
|
||||
esac
|
||||
do_refresh
|
||||
}
|
||||
|
||||
# --- hooks ------------------------------------------------------------------
|
||||
|
||||
# install_hooks wires this tool into every package operation that would
|
||||
# otherwise write a bootloader to the wrong place.
|
||||
install_hooks() {
|
||||
mkdir -p /etc/kernel/postinst.d /etc/kernel/postrm.d /etc/initramfs/post-update.d
|
||||
|
||||
# Debian's own hook runs update-grub against the real /boot, which is on the
|
||||
# pool. Firmware never reads that, so it would produce a config nothing uses
|
||||
# while the ESPs silently kept an old kernel.
|
||||
if [ -e /etc/kernel/postinst.d/zz-update-grub ]; then
|
||||
dpkg-divert --divert /etc/kernel/postinst.d/zz-update-grub.distrib \
|
||||
--rename --add /etc/kernel/postinst.d/zz-update-grub >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
for hook in /etc/kernel/postinst.d/zz-spinifex-boot-tool \
|
||||
/etc/kernel/postrm.d/zz-spinifex-boot-tool \
|
||||
/etc/initramfs/post-update.d/spinifex-boot-tool; do
|
||||
cat >"$hook" <<'HOOK'
|
||||
#!/bin/sh
|
||||
# Re-sync the ESPs after a kernel or initramfs change.
|
||||
set -e
|
||||
exec /usr/sbin/spinifex-boot-tool refresh --hook "$(basename "$0")"
|
||||
HOOK
|
||||
chmod 0755 "$hook"
|
||||
done
|
||||
}
|
||||
|
||||
# --- dispatch ---------------------------------------------------------------
|
||||
|
||||
cmd="${1:-}"
|
||||
[ $# -gt 0 ] && shift
|
||||
|
||||
case "$cmd" in
|
||||
format) do_format "$@" ;;
|
||||
init)
|
||||
reexec_in_mountns init "$@"
|
||||
do_init "$@"
|
||||
;;
|
||||
refresh)
|
||||
# --hook <name> is passed by the kernel hooks purely so the reason for a
|
||||
# refresh is visible in the log.
|
||||
if [ "${1:-}" = "--hook" ]; then shift 2 || shift; fi
|
||||
reexec_in_mountns refresh
|
||||
do_refresh
|
||||
;;
|
||||
reinit)
|
||||
reexec_in_mountns reinit
|
||||
do_refresh
|
||||
;;
|
||||
clean) do_clean "$@" ;;
|
||||
kernel)
|
||||
reexec_in_mountns kernel "$@"
|
||||
do_kernel "$@"
|
||||
;;
|
||||
status) do_status "$@" ;;
|
||||
help | --help | -h | "") usage ;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,406 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Disk is one candidate block device with every attribute the installer needs
|
||||
// to validate a selection: sizes for the same-size rule, block sizes for ashift
|
||||
// and the 4Kn/BIOS check, rotational for autotrim, and ByID for pool members.
|
||||
type Disk struct {
|
||||
Path string // /dev/sda
|
||||
ByID string // /dev/disk/by-id/ata-… — stable across controller reordering
|
||||
Bytes int64
|
||||
Model string
|
||||
|
||||
LogicalBlockSize int
|
||||
PhysicalBlockSize int
|
||||
|
||||
Rotational bool
|
||||
Removable bool
|
||||
|
||||
// LiveMedia marks the device the installer itself booted from. Selecting it
|
||||
// would erase the running system mid-install.
|
||||
LiveMedia bool
|
||||
|
||||
// Content is a human-readable summary of what is on the disk today
|
||||
// ("ext4 filesystem", "empty"), shown before the operator confirms erasure.
|
||||
Content string
|
||||
}
|
||||
|
||||
// skipPrefixes are kernel block devices that are never install targets:
|
||||
// virtual, mapped, or removable media. Mirrors Proxmox's hd_list filter.
|
||||
var skipPrefixes = []string{"ram", "loop", "md", "dm-", "fd", "sr", "zram", "nbd"}
|
||||
|
||||
// The kernel-owned trees the scan reads, indirected so tests can point it at a
|
||||
// fixture instead of the running machine's hardware.
|
||||
var (
|
||||
sysBlockDir = "/sys/block"
|
||||
sysClassBlockDir = "/sys/class/block"
|
||||
devByIDDir = "/dev/disk/by-id"
|
||||
procMountsPath = "/proc/mounts"
|
||||
procSwapsPath = "/proc/swaps"
|
||||
)
|
||||
|
||||
// ListDisks enumerates install-candidate disks from /sys/block. It reads sysfs
|
||||
// and blkid only — pool detection is separate (ImportablePools) because it is
|
||||
// far slower and not every caller needs it.
|
||||
func ListDisks() ([]Disk, error) {
|
||||
entries, err := os.ReadDir(sysBlockDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read /sys/block: %w", err)
|
||||
}
|
||||
|
||||
byID := buildByIDIndex()
|
||||
live := liveMediaDevices()
|
||||
|
||||
var disks []Disk
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if hasAnyPrefix(name, skipPrefixes) {
|
||||
continue
|
||||
}
|
||||
// A zero-sized device is an empty card reader slot, not a disk.
|
||||
sectors := sysfsInt(name, "size")
|
||||
if sectors == 0 {
|
||||
continue
|
||||
}
|
||||
d := Disk{
|
||||
Path: "/dev/" + name,
|
||||
ByID: byID[name],
|
||||
Bytes: sectors * 512,
|
||||
Model: sysfsString(name, "device/model"),
|
||||
LogicalBlockSize: sysfsIntDefault(name, "queue/logical_block_size", 512),
|
||||
PhysicalBlockSize: sysfsIntDefault(name, "queue/physical_block_size", 512),
|
||||
Rotational: sysfsInt(name, "queue/rotational") == 1,
|
||||
Removable: sysfsInt(name, "removable") == 1,
|
||||
LiveMedia: live[name],
|
||||
}
|
||||
d.Content = describeContent(d.Path)
|
||||
disks = append(disks, d)
|
||||
}
|
||||
slices.SortFunc(disks, func(a, b Disk) int { return strings.Compare(a.Path, b.Path) })
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// SizeHuman renders the disk size for display, e.g. "1.7T".
|
||||
func (d Disk) SizeHuman() string {
|
||||
switch {
|
||||
case d.Bytes >= 1<<40:
|
||||
return fmt.Sprintf("%.1fT", float64(d.Bytes)/(1<<40))
|
||||
case d.Bytes >= 1<<30:
|
||||
return fmt.Sprintf("%.1fG", float64(d.Bytes)/(1<<30))
|
||||
case d.Bytes >= 1<<20:
|
||||
return fmt.Sprintf("%.1fM", float64(d.Bytes)/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%dB", d.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Stable returns the path to use when building a pool: the by-id path when one
|
||||
// exists, else the kernel name. Pools referencing /dev/sdX break when a
|
||||
// controller enumerates disks in a different order after a reboot.
|
||||
func (d Disk) Stable() string {
|
||||
if d.ByID != "" {
|
||||
return d.ByID
|
||||
}
|
||||
return d.Path
|
||||
}
|
||||
|
||||
// PartitionPath returns the device path for partition n of this disk. NVMe and
|
||||
// other devices whose name ends in a digit take a 'p' separator.
|
||||
func (d Disk) PartitionPath(n int) string { return partitionPath(d.Path, n) }
|
||||
|
||||
// StablePartitionPath is PartitionPath against the by-id path, which udev
|
||||
// publishes with a "-partN" suffix rather than the kernel's 'p' convention.
|
||||
func (d Disk) StablePartitionPath(n int) string {
|
||||
if d.ByID == "" {
|
||||
return d.PartitionPath(n)
|
||||
}
|
||||
return fmt.Sprintf("%s-part%d", d.ByID, n)
|
||||
}
|
||||
|
||||
func partitionPath(disk string, n int) string {
|
||||
if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' {
|
||||
return fmt.Sprintf("%sp%d", disk, n)
|
||||
}
|
||||
return fmt.Sprintf("%s%d", disk, n)
|
||||
}
|
||||
|
||||
// readKernelPartitions returns the partition device names the kernel currently
|
||||
// publishes for a disk, sorted.
|
||||
//
|
||||
// It reads sysfs rather than /dev deliberately. A partition device node
|
||||
// outlives the table that created it, so a stale node is indistinguishable
|
||||
// from a fresh one by existence alone — which is how a disk whose old table
|
||||
// the kernel never dropped passes every check and then gets formatted at the
|
||||
// previous layout's offsets.
|
||||
func readKernelPartitions(disk string) ([]string, error) {
|
||||
name := filepath.Base(disk)
|
||||
entries, err := os.ReadDir(filepath.Join(sysBlockDir, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the kernel's partition list for %s: %w", disk, err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
// Partitions are the child directories carrying a "partition" attribute.
|
||||
// "queue", "device" and the plain attribute files sit alongside them.
|
||||
if _, err := os.Stat(filepath.Join(sysBlockDir, name, e.Name(), "partition")); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// kernelPartitions is indirected so tests can drive the partition-table
|
||||
// assertions without a real block device.
|
||||
var kernelPartitions = readKernelPartitions
|
||||
|
||||
// partitionDevice maps a kernel partition name back to its device node,
|
||||
// resolved alongside the disk it belongs to rather than assuming /dev.
|
||||
func partitionDevice(disk, part string) string {
|
||||
return filepath.Join(filepath.Dir(disk), part)
|
||||
}
|
||||
|
||||
// diskHolders names the md, LVM or device-mapper devices claiming this disk or
|
||||
// one of its partitions. A holder is the usual reason the kernel refuses to
|
||||
// re-read a partition table, so it is what the operator needs told when the
|
||||
// installer gives up on a disk.
|
||||
func diskHolders(disk string) []string {
|
||||
name := filepath.Base(disk)
|
||||
dirs := []string{filepath.Join(sysBlockDir, name, "holders")}
|
||||
parts, _ := kernelPartitions(disk)
|
||||
for _, p := range parts {
|
||||
dirs = append(dirs, filepath.Join(sysBlockDir, name, p, "holders"))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
seen[e.Name()] = true
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// diskMount is one filesystem mounted from a disk the installer is about to
|
||||
// erase.
|
||||
type diskMount struct {
|
||||
Device string
|
||||
Mountpoint string
|
||||
}
|
||||
|
||||
// devicesOnDisk is the set of device paths belonging to a disk: the whole disk
|
||||
// and every partition the kernel currently publishes. Matching /proc tables
|
||||
// against a set rather than a string prefix keeps /dev/nvme0n1 from claiming
|
||||
// /dev/nvme0n11.
|
||||
func devicesOnDisk(disk string) map[string]bool {
|
||||
out := map[string]bool{disk: true}
|
||||
parts, _ := kernelPartitions(disk)
|
||||
for _, p := range parts {
|
||||
out[partitionDevice(disk, p)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mountsOnDisk returns the filesystems mounted from a disk, deepest mountpoint
|
||||
// first so a nested mount is released before the one it sits inside.
|
||||
func mountsOnDisk(disk string) []diskMount {
|
||||
devs := devicesOnDisk(disk)
|
||||
var out []diskMount
|
||||
for _, fields := range procTable(procMountsPath, 0) {
|
||||
if len(fields) >= 2 && devs[fields[0]] {
|
||||
out = append(out, diskMount{Device: fields[0], Mountpoint: fields[1]})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b diskMount) int {
|
||||
return strings.Count(b.Mountpoint, "/") - strings.Count(a.Mountpoint, "/")
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// swapsOnDisk returns the active swap areas backed by a disk.
|
||||
func swapsOnDisk(disk string) []string {
|
||||
devs := devicesOnDisk(disk)
|
||||
var out []string
|
||||
// The first line of /proc/swaps is a column header, not an entry.
|
||||
for _, fields := range procTable(procSwapsPath, 1) {
|
||||
if len(fields) >= 1 && devs[fields[0]] {
|
||||
out = append(out, fields[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// procTable splits a whitespace-delimited /proc table into fields per line,
|
||||
// dropping the first skip lines and any blank ones.
|
||||
func procTable(path string, skip int) [][]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out [][]string
|
||||
n := 0
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
n++
|
||||
if n <= skip {
|
||||
continue
|
||||
}
|
||||
if fields := strings.Fields(line); len(fields) > 0 {
|
||||
out = append(out, fields)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// byIDPreference orders the /dev/disk/by-id/ namespaces we prefer to name a
|
||||
// pool member by. wwn- ids are last: they are stable but opaque, so a degraded
|
||||
// pool reports a number nobody can map to a drive bay.
|
||||
var byIDPreference = []string{"nvme-", "ata-", "scsi-", "virtio-", "usb-", "wwn-"}
|
||||
|
||||
// buildByIDIndex maps kernel device name to its preferred by-id path.
|
||||
func buildByIDIndex() map[string]string {
|
||||
entries, err := os.ReadDir(devByIDDir)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
// Track the chosen preference rank per device so a later, less-preferred
|
||||
// id cannot displace a better one already found.
|
||||
rank := map[string]int{}
|
||||
out := map[string]string{}
|
||||
for _, e := range entries {
|
||||
id := e.Name()
|
||||
// Partition links describe a slice of the disk, not the disk.
|
||||
if strings.Contains(id, "-part") {
|
||||
continue
|
||||
}
|
||||
target, err := filepath.EvalSymlinks(filepath.Join(devByIDDir, id))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dev := filepath.Base(target)
|
||||
r := prefixRank(id)
|
||||
if existing, ok := rank[dev]; ok && existing <= r {
|
||||
continue
|
||||
}
|
||||
rank[dev] = r
|
||||
out[dev] = filepath.Join(devByIDDir, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func prefixRank(id string) int {
|
||||
for i, p := range byIDPreference {
|
||||
if strings.HasPrefix(id, p) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(byIDPreference)
|
||||
}
|
||||
|
||||
// liveMediaDevices returns the set of parent disk names backing the live
|
||||
// installer's own media, so they can never be selected as install targets.
|
||||
func liveMediaDevices() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
data, err := os.ReadFile(procMountsPath)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
liveMounts := []string{"/cdrom", "/run/live/medium", "/lib/live/mount/medium"}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !strings.HasPrefix(fields[0], "/dev/") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(liveMounts, fields[1]) {
|
||||
continue
|
||||
}
|
||||
if parent := parentDisk(filepath.Base(fields[0])); parent != "" {
|
||||
out[parent] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parentDisk resolves a partition's kernel name to its whole-disk name by
|
||||
// walking /sys/class/block/<part>/.. — the sysfs parent of a partition is its
|
||||
// disk. Returns the input unchanged when it is already a whole disk.
|
||||
func parentDisk(dev string) string {
|
||||
link, err := filepath.EvalSymlinks(filepath.Join(sysClassBlockDir, dev))
|
||||
if err != nil {
|
||||
return dev
|
||||
}
|
||||
// A whole disk lives directly under a bus path; a partition sits one level
|
||||
// deeper, inside its disk's directory.
|
||||
if _, err := os.Stat(filepath.Join(link, "partition")); err != nil {
|
||||
return dev
|
||||
}
|
||||
return filepath.Base(filepath.Dir(link))
|
||||
}
|
||||
|
||||
// describeContent summarises what is currently on a disk for the confirmation
|
||||
// screen. Best-effort: an unreadable disk is reported as unknown, not empty,
|
||||
// because "empty" invites an operator to erase something that is not.
|
||||
func describeContent(dev string) string {
|
||||
out, err := exec.Command("blkid", "-p", "-o", "value", "-s", "TYPE", dev).Output()
|
||||
if err == nil {
|
||||
if t := strings.TrimSpace(string(out)); t != "" {
|
||||
return t + " filesystem"
|
||||
}
|
||||
}
|
||||
// No filesystem on the whole device: report the partition table instead,
|
||||
// since that is what tells the operator the disk is in use.
|
||||
out, err = exec.Command("blkid", "-p", "-o", "value", "-s", "PTTYPE", dev).Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if t := strings.TrimSpace(string(out)); t != "" {
|
||||
return t + " partition table"
|
||||
}
|
||||
return "empty"
|
||||
}
|
||||
|
||||
func sysfsString(dev, file string) string {
|
||||
data, err := os.ReadFile(filepath.Join(sysBlockDir, dev, file))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func sysfsInt(dev, file string) int64 {
|
||||
n, err := strconv.ParseInt(sysfsString(dev, file), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sysfsIntDefault(dev, file string, def int) int {
|
||||
if n := sysfsInt(dev, file); n > 0 {
|
||||
return int(n)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
return slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(s, p) })
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDisk describes one device to lay down in the fixture sysfs tree.
|
||||
type fakeDisk struct {
|
||||
name string
|
||||
sectors int64
|
||||
model string
|
||||
logical int
|
||||
physical int
|
||||
rotational bool
|
||||
removable bool
|
||||
partitions []string
|
||||
}
|
||||
|
||||
// fakeSysfs builds a /sys/block, /sys/class/block, /dev/disk/by-id and
|
||||
// /proc/mounts fixture and points the scan at it for the duration of the test.
|
||||
type fakeSysfs struct {
|
||||
root string
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func newFakeSysfs(t *testing.T, disks ...fakeDisk) *fakeSysfs {
|
||||
t.Helper()
|
||||
f := &fakeSysfs{root: t.TempDir(), t: t}
|
||||
|
||||
swap := func(dst *string, v string) {
|
||||
prev := *dst
|
||||
*dst = v
|
||||
t.Cleanup(func() { *dst = prev })
|
||||
}
|
||||
swap(&sysBlockDir, filepath.Join(f.root, "sys/block"))
|
||||
swap(&sysClassBlockDir, filepath.Join(f.root, "sys/class/block"))
|
||||
swap(&devByIDDir, filepath.Join(f.root, "dev/disk/by-id"))
|
||||
swap(&procMountsPath, filepath.Join(f.root, "proc/mounts"))
|
||||
swap(&procSwapsPath, filepath.Join(f.root, "proc/swaps"))
|
||||
|
||||
f.mkdirAll(sysBlockDir)
|
||||
f.mkdirAll(sysClassBlockDir)
|
||||
for _, d := range disks {
|
||||
f.addDisk(d)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeSysfs) mkdirAll(dir string) {
|
||||
f.t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
f.t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysfs) write(path, content string) {
|
||||
f.t.Helper()
|
||||
f.mkdirAll(filepath.Dir(path))
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// addDisk lays out one device the way the kernel does: the attribute files
|
||||
// under /sys/block/<dev>, and a /sys/class/block symlink per disk and
|
||||
// partition, since parentDisk walks the latter.
|
||||
func (f *fakeSysfs) addDisk(d fakeDisk) {
|
||||
f.t.Helper()
|
||||
dir := filepath.Join(sysBlockDir, d.name)
|
||||
f.write(filepath.Join(dir, "size"), strconv.FormatInt(d.sectors, 10)+"\n")
|
||||
if d.model != "" {
|
||||
f.write(filepath.Join(dir, "device/model"), d.model+"\n")
|
||||
}
|
||||
if d.logical > 0 {
|
||||
f.write(filepath.Join(dir, "queue/logical_block_size"), strconv.Itoa(d.logical)+"\n")
|
||||
}
|
||||
if d.physical > 0 {
|
||||
f.write(filepath.Join(dir, "queue/physical_block_size"), strconv.Itoa(d.physical)+"\n")
|
||||
}
|
||||
f.write(filepath.Join(dir, "queue/rotational"), boolAttr(d.rotational))
|
||||
f.write(filepath.Join(dir, "removable"), boolAttr(d.removable))
|
||||
|
||||
f.symlink(dir, filepath.Join(sysClassBlockDir, d.name))
|
||||
for _, part := range d.partitions {
|
||||
partDir := filepath.Join(dir, part)
|
||||
f.write(filepath.Join(partDir, "partition"), "1\n")
|
||||
f.symlink(partDir, filepath.Join(sysClassBlockDir, part))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysfs) symlink(target, link string) {
|
||||
f.t.Helper()
|
||||
f.mkdirAll(filepath.Dir(link))
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
f.t.Fatalf("symlink %s -> %s: %v", link, target, err)
|
||||
}
|
||||
}
|
||||
|
||||
// addByID creates a by-id alias pointing at a kernel device name. The link
|
||||
// target must exist for EvalSymlinks, so a placeholder /dev tree is used.
|
||||
func (f *fakeSysfs) addByID(id, dev string) {
|
||||
f.t.Helper()
|
||||
devPath := filepath.Join(f.root, "dev", dev)
|
||||
f.write(devPath, "")
|
||||
f.symlink(devPath, filepath.Join(devByIDDir, id))
|
||||
}
|
||||
|
||||
func (f *fakeSysfs) setMounts(content string) {
|
||||
f.t.Helper()
|
||||
f.write(procMountsPath, content)
|
||||
}
|
||||
|
||||
func (f *fakeSysfs) setSwaps(content string) {
|
||||
f.t.Helper()
|
||||
f.write(procSwapsPath, content)
|
||||
}
|
||||
|
||||
// addHolder marks a partition as claimed by md, LVM or device-mapper — the
|
||||
// state that makes the kernel refuse to re-read the disk's partition table.
|
||||
func (f *fakeSysfs) addHolder(disk, part, holder string) {
|
||||
f.t.Helper()
|
||||
f.mkdirAll(filepath.Join(sysBlockDir, disk, part, "holders", holder))
|
||||
}
|
||||
|
||||
func boolAttr(b bool) string {
|
||||
if b {
|
||||
return "1\n"
|
||||
}
|
||||
return "0\n"
|
||||
}
|
||||
|
||||
func TestListDisksReadsSysfsAttributes(t *testing.T) {
|
||||
f := newFakeSysfs(t, fakeDisk{
|
||||
name: "sdb", sectors: 2 << 20, model: "SAMSUNG MZ7", logical: 512, physical: 4096, rotational: true,
|
||||
})
|
||||
f.setMounts("")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
if len(disks) != 1 {
|
||||
t.Fatalf("got %d disks, want 1: %+v", len(disks), disks)
|
||||
}
|
||||
d := disks[0]
|
||||
if d.Path != "/dev/sdb" || d.Bytes != (2<<20)*512 {
|
||||
t.Errorf("path/size = %s/%d", d.Path, d.Bytes)
|
||||
}
|
||||
if d.Model != "SAMSUNG MZ7" {
|
||||
t.Errorf("Model = %q", d.Model)
|
||||
}
|
||||
if d.LogicalBlockSize != 512 || d.PhysicalBlockSize != 4096 {
|
||||
t.Errorf("block sizes = %d/%d", d.LogicalBlockSize, d.PhysicalBlockSize)
|
||||
}
|
||||
if !d.Rotational || d.Removable || d.LiveMedia {
|
||||
t.Errorf("flags = rot %v, rem %v, live %v", d.Rotational, d.Removable, d.LiveMedia)
|
||||
}
|
||||
if d.Content == "" {
|
||||
t.Error("Content is empty; an unreadable device must report something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksSkipsVirtualAndEmptyDevices(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "sda", sectors: 1 << 20},
|
||||
fakeDisk{name: "loop0", sectors: 1 << 20},
|
||||
fakeDisk{name: "dm-0", sectors: 1 << 20},
|
||||
fakeDisk{name: "zram0", sectors: 1 << 20},
|
||||
fakeDisk{name: "sr0", sectors: 1 << 20},
|
||||
fakeDisk{name: "nbd0", sectors: 1 << 20},
|
||||
// An empty card-reader slot reports zero sectors.
|
||||
fakeDisk{name: "mmcblk0", sectors: 0},
|
||||
)
|
||||
f.setMounts("")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, d := range disks {
|
||||
paths = append(paths, d.Path)
|
||||
}
|
||||
if !slices.Equal(paths, []string{"/dev/sda"}) {
|
||||
t.Errorf("paths = %v, want only /dev/sda", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksSortsByPath(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "sdc", sectors: 1 << 20},
|
||||
fakeDisk{name: "sda", sectors: 1 << 20},
|
||||
fakeDisk{name: "sdb", sectors: 1 << 20},
|
||||
)
|
||||
f.setMounts("")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, d := range disks {
|
||||
paths = append(paths, d.Path)
|
||||
}
|
||||
if !slices.Equal(paths, []string{"/dev/sda", "/dev/sdb", "/dev/sdc"}) {
|
||||
t.Errorf("paths = %v, want sorted", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksDefaultsBlockSizesWhenSysfsIsSilent(t *testing.T) {
|
||||
f := newFakeSysfs(t, fakeDisk{name: "vda", sectors: 1 << 20})
|
||||
f.setMounts("")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
if disks[0].LogicalBlockSize != 512 || disks[0].PhysicalBlockSize != 512 {
|
||||
t.Errorf("block sizes = %d/%d, want the 512 default",
|
||||
disks[0].LogicalBlockSize, disks[0].PhysicalBlockSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksErrorsWhenSysfsIsUnreadable(t *testing.T) {
|
||||
newFakeSysfs(t)
|
||||
sysBlockDir = filepath.Join(t.TempDir(), "does-not-exist")
|
||||
|
||||
if _, err := ListDisks(); err == nil {
|
||||
t.Fatal("expected an error when /sys/block cannot be read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksMarksTheLiveMedium(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "sda", sectors: 1 << 20},
|
||||
fakeDisk{name: "sdz", sectors: 1 << 20, removable: true, partitions: []string{"sdz1"}},
|
||||
)
|
||||
// The installer boots from a partition; the whole disk must be excluded.
|
||||
f.setMounts("/dev/sdz1 /run/live/medium iso9660 ro 0 0\n" +
|
||||
"proc /proc proc rw 0 0\n" +
|
||||
"/dev/sda1 / ext4 rw 0 0\n")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
byPath := map[string]Disk{}
|
||||
for _, d := range disks {
|
||||
byPath[d.Path] = d
|
||||
}
|
||||
if !byPath["/dev/sdz"].LiveMedia {
|
||||
t.Error("/dev/sdz backs the live medium and was not marked")
|
||||
}
|
||||
if byPath["/dev/sda"].LiveMedia {
|
||||
t.Error("/dev/sda is not the live medium but was marked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveMediaDevicesIsEmptyWithoutProcMounts(t *testing.T) {
|
||||
newFakeSysfs(t)
|
||||
if got := liveMediaDevices(); len(got) != 0 {
|
||||
t.Errorf("liveMediaDevices = %v, want empty when /proc/mounts is missing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentDiskLeavesWholeDisksAlone(t *testing.T) {
|
||||
newFakeSysfs(t, fakeDisk{name: "sda", sectors: 1 << 20, partitions: []string{"sda1"}})
|
||||
|
||||
if got := parentDisk("sda"); got != "sda" {
|
||||
t.Errorf("parentDisk(sda) = %q, want sda", got)
|
||||
}
|
||||
if got := parentDisk("sda1"); got != "sda" {
|
||||
t.Errorf("parentDisk(sda1) = %q, want sda", got)
|
||||
}
|
||||
// An unknown name cannot be resolved and is returned unchanged.
|
||||
if got := parentDisk("sdq9"); got != "sdq9" {
|
||||
t.Errorf("parentDisk(sdq9) = %q, want it unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildByIDIndexPrefersTheMostLegibleAlias(t *testing.T) {
|
||||
f := newFakeSysfs(t, fakeDisk{name: "sda", sectors: 1 << 20})
|
||||
// wwn- is stable but opaque, so ata- must win regardless of scan order.
|
||||
f.addByID("wwn-0x5000c500", "sda")
|
||||
f.addByID("ata-SAMSUNG_MZ7_S1", "sda")
|
||||
f.addByID("ata-SAMSUNG_MZ7_S1-part1", "sda")
|
||||
|
||||
idx := buildByIDIndex()
|
||||
want := filepath.Join(devByIDDir, "ata-SAMSUNG_MZ7_S1")
|
||||
if idx["sda"] != want {
|
||||
t.Errorf("by-id[sda] = %q, want %q", idx["sda"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildByIDIndexIsEmptyWhenTheDirIsAbsent(t *testing.T) {
|
||||
newFakeSysfs(t)
|
||||
if got := buildByIDIndex(); len(got) != 0 {
|
||||
t.Errorf("buildByIDIndex = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDisksAttachesByIDPath(t *testing.T) {
|
||||
f := newFakeSysfs(t, fakeDisk{name: "sda", sectors: 1 << 20})
|
||||
f.addByID("ata-TEST_SERIAL0", "sda")
|
||||
f.setMounts("")
|
||||
|
||||
disks, err := ListDisks()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDisks: %v", err)
|
||||
}
|
||||
want := filepath.Join(devByIDDir, "ata-TEST_SERIAL0")
|
||||
if disks[0].ByID != want {
|
||||
t.Errorf("ByID = %q, want %q", disks[0].ByID, want)
|
||||
}
|
||||
if disks[0].Stable() != want {
|
||||
t.Errorf("Stable() = %q, want the by-id path", disks[0].Stable())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixRank(t *testing.T) {
|
||||
if prefixRank("nvme-Foo") >= prefixRank("wwn-0x1") {
|
||||
t.Error("nvme- must outrank wwn-")
|
||||
}
|
||||
if prefixRank("md-uuid-x") != len(byIDPreference) {
|
||||
t.Error("an unknown namespace must rank last")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeHuman(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{2 << 40, "2.0T"},
|
||||
{500 << 30, "500.0G"},
|
||||
{64 << 20, "64.0M"},
|
||||
{512, "512B"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := (Disk{Bytes: tt.bytes}).SizeHuman(); got != tt.want {
|
||||
t.Errorf("SizeHuman(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableFallsBackToKernelName(t *testing.T) {
|
||||
d := Disk{Path: "/dev/sda"}
|
||||
if d.Stable() != "/dev/sda" {
|
||||
t.Errorf("Stable() = %q", d.Stable())
|
||||
}
|
||||
if d.StablePartitionPath(2) != "/dev/sda2" {
|
||||
t.Errorf("StablePartitionPath(2) = %q", d.StablePartitionPath(2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyPrefix(t *testing.T) {
|
||||
if !hasAnyPrefix("loop3", skipPrefixes) {
|
||||
t.Error("loop3 should match")
|
||||
}
|
||||
if hasAnyPrefix("sda", skipPrefixes) {
|
||||
t.Error("sda should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKernelPartitionsListsOnlyPartitions(t *testing.T) {
|
||||
newFakeSysfs(t, fakeDisk{
|
||||
name: "nvme0n1", sectors: 1 << 22,
|
||||
partitions: []string{"nvme0n1p3", "nvme0n1p1", "nvme0n1p2"},
|
||||
})
|
||||
|
||||
got, err := readKernelPartitions("/dev/nvme0n1")
|
||||
if err != nil {
|
||||
t.Fatalf("readKernelPartitions: %v", err)
|
||||
}
|
||||
// Sorted, and "queue" and "device" alongside them are not partitions.
|
||||
want := []string{"nvme0n1p1", "nvme0n1p2", "nvme0n1p3"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKernelPartitionsIsEmptyForAnUnpartitionedDisk(t *testing.T) {
|
||||
newFakeSysfs(t, fakeDisk{name: "nvme2n1", sectors: 1 << 22})
|
||||
|
||||
got, err := readKernelPartitions("/dev/nvme2n1")
|
||||
if err != nil {
|
||||
t.Fatalf("readKernelPartitions: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKernelPartitionsFailsOnAnUnknownDisk(t *testing.T) {
|
||||
newFakeSysfs(t)
|
||||
|
||||
if _, err := readKernelPartitions("/dev/nvme9n1"); err == nil {
|
||||
t.Fatal("expected an error for a disk with no sysfs entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskHoldersNamesEveryClaimant(t *testing.T) {
|
||||
f := newFakeSysfs(t, fakeDisk{
|
||||
name: "sda", sectors: 1 << 22, partitions: []string{"sda1", "sda2"},
|
||||
})
|
||||
f.addHolder("sda", "sda1", "md0")
|
||||
f.addHolder("sda", "sda2", "dm-0")
|
||||
|
||||
if got, want := diskHolders("/dev/sda"), []string{"dm-0", "md0"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskHoldersIsEmptyWhenNothingClaimsTheDisk(t *testing.T) {
|
||||
newFakeSysfs(t, fakeDisk{name: "sda", sectors: 1 << 22, partitions: []string{"sda1"}})
|
||||
|
||||
if got := diskHolders("/dev/sda"); len(got) != 0 {
|
||||
t.Errorf("got %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountsOnDiskFindsOnlyThisDiskDeepestFirst(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "sda", sectors: 1 << 22, partitions: []string{"sda1", "sda2"}},
|
||||
fakeDisk{name: "sdb", sectors: 1 << 22, partitions: []string{"sdb1"}},
|
||||
)
|
||||
f.setMounts(strings.Join([]string{
|
||||
"/dev/sda1 /oldroot ext4 rw 0 0",
|
||||
"/dev/sda2 /oldroot/boot/efi vfat rw 0 0",
|
||||
"/dev/sdb1 /other ext4 rw 0 0",
|
||||
"proc /proc proc rw 0 0",
|
||||
}, "\n") + "\n")
|
||||
|
||||
got := mountsOnDisk("/dev/sda")
|
||||
want := []diskMount{
|
||||
{Device: "/dev/sda2", Mountpoint: "/oldroot/boot/efi"},
|
||||
{Device: "/dev/sda1", Mountpoint: "/oldroot"},
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// /dev/nvme0n1 must not claim /dev/nvme0n11, which a string-prefix match would.
|
||||
func TestMountsOnDiskDoesNotMatchASiblingNamespace(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "nvme0n1", sectors: 1 << 22, partitions: []string{"nvme0n1p1"}},
|
||||
fakeDisk{name: "nvme0n11", sectors: 1 << 22, partitions: []string{"nvme0n11p1"}},
|
||||
)
|
||||
f.setMounts("/dev/nvme0n11p1 /elsewhere ext4 rw 0 0\n")
|
||||
|
||||
if got := mountsOnDisk("/dev/nvme0n1"); len(got) != 0 {
|
||||
t.Errorf("got %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapsOnDiskSkipsTheHeaderAndOtherDisks(t *testing.T) {
|
||||
f := newFakeSysfs(t,
|
||||
fakeDisk{name: "sda", sectors: 1 << 22, partitions: []string{"sda1"}},
|
||||
fakeDisk{name: "sdb", sectors: 1 << 22, partitions: []string{"sdb1"}},
|
||||
)
|
||||
f.setSwaps(strings.Join([]string{
|
||||
"Filename\t\t\t\tType\t\tSize\tUsed\tPriority",
|
||||
"/dev/sda1 partition 8388604 0 -2",
|
||||
"/dev/sdb1 partition 8388604 0 -3",
|
||||
}, "\n") + "\n")
|
||||
|
||||
if got, want := swapsOnDisk("/dev/sda"), []string{"/dev/sda1"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapsOnDiskIsEmptyWithoutASwapsTable(t *testing.T) {
|
||||
newFakeSysfs(t, fakeDisk{name: "sda", sectors: 1 << 22, partitions: []string{"sda1"}})
|
||||
|
||||
if got := swapsOnDisk("/dev/sda"); len(got) != 0 {
|
||||
t.Errorf("got %v, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/spinifex-boot-tool
|
||||
var bootToolScript []byte
|
||||
|
||||
//go:embed assets/grub-install-wrapper
|
||||
var grubInstallWrapper []byte
|
||||
|
||||
const (
|
||||
bootToolPath = "/usr/sbin/spinifex-boot-tool"
|
||||
grubInstallPath = "/usr/sbin/grub-install"
|
||||
grubInstallReal = "/usr/sbin/grub-install.real"
|
||||
bootToolUUIDList = "/etc/kernel/spinifex-boot-uuids"
|
||||
)
|
||||
|
||||
// installBootToolZFS sets up per-disk bootloaders on a ZFS root system.
|
||||
//
|
||||
// Nothing in the boot path is allowed to read the pool: GRUB's ZFS support
|
||||
// trails OpenZFS, so a pool feature flag enabled years from now could make the
|
||||
// machine unbootable. Each disk instead carries a self-contained ESP with its
|
||||
// own GRUB, kernels and initramfs.
|
||||
func installBootToolZFS(cfg DiskConfig) error {
|
||||
if err := writeTargetFile(bootToolPath, bootToolScript, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeGrubDefaults(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
|
||||
// Diverted before the first init: init runs a refresh, and refresh calls
|
||||
// grub-install. With the wrapper already in place that call has to reach
|
||||
// the real binary, which is what the diversion arranges.
|
||||
if err := divertGrubInstall(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A partially-updated set of ESPs is an install that boots today and fails
|
||||
// after the next kernel upgrade, so every disk must succeed here.
|
||||
env := []string{"SPINIFEX_BOOT_TOOL_STRICT=1"}
|
||||
for _, d := range cfg.Disks {
|
||||
esp := d.PartitionPath(espPartNum)
|
||||
if err := runEnv(env, "chroot", mountRoot, bootToolPath, "init", esp); err != nil {
|
||||
return fmt.Errorf("spinifex-boot-tool init %s: %w", esp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The one moment firmware boot variables are written. Refreshes run on
|
||||
// every kernel upgrade and deliberately leave NVRAM alone: repeatedly
|
||||
// rewriting boot entries has bricked more than one vendor's boards. Losing
|
||||
// the entry is survivable anyway, because each ESP also carries the
|
||||
// removable-path loader that firmware falls back to.
|
||||
if err := runEnv(append(env, "SPINIFEX_BOOT_TOOL_NVRAM=1"),
|
||||
"chroot", mountRoot, bootToolPath, "refresh"); err != nil {
|
||||
return fmt.Errorf("spinifex-boot-tool refresh: %w", err)
|
||||
}
|
||||
return verifyBootConfig(cfg)
|
||||
}
|
||||
|
||||
// divertGrubInstall moves the real grub-install aside and puts the wrapper in
|
||||
// its place. dpkg-divert makes the move survive package upgrades, which is the
|
||||
// point: grub-pc's postinst runs grub-install and would otherwise quietly
|
||||
// replace a working boot layout with one that reads the pool.
|
||||
func divertGrubInstall() error {
|
||||
if err := run("chroot", mountRoot, "dpkg-divert", "--divert", grubInstallReal,
|
||||
"--rename", "--add", grubInstallPath); err != nil {
|
||||
return fmt.Errorf("divert grub-install: %w", err)
|
||||
}
|
||||
return writeTargetFile(grubInstallPath, grubInstallWrapper, 0o755)
|
||||
}
|
||||
|
||||
// verifyBootConfig asserts that the config the machine will actually boot from
|
||||
// names the ZFS root. grub-mkconfig degrades quietly when grub-probe cannot
|
||||
// read the pool, emitting a config that gets as far as the GRUB menu and then
|
||||
// panics with "unable to mount root fs" — which looks like a hardware fault.
|
||||
func verifyBootConfig(cfg DiskConfig) error {
|
||||
uuids, err := os.ReadFile(filepath.Join(mountRoot, strings.TrimPrefix(bootToolUUIDList, "/")))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ESP list: %w", err)
|
||||
}
|
||||
if len(strings.Fields(string(uuids))) != len(cfg.Disks) {
|
||||
return fmt.Errorf("expected %d registered ESPs, found %d — some disks have no bootloader",
|
||||
len(cfg.Disks), len(strings.Fields(string(uuids))))
|
||||
}
|
||||
|
||||
// One ESP is enough to prove grub-mkconfig produced the right thing; they
|
||||
// are all generated by the same code path in the same run.
|
||||
mp := filepath.Join(mountRoot, "var/tmp/verify-esp")
|
||||
if err := os.MkdirAll(mp, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.Remove(mp) }()
|
||||
|
||||
esp := cfg.Disks[0].PartitionPath(espPartNum)
|
||||
if err := run("mount", esp, mp); err != nil {
|
||||
return fmt.Errorf("mount %s for verification: %w", esp, err)
|
||||
}
|
||||
defer func() { _ = run("umount", mp) }()
|
||||
|
||||
grubCfg, err := os.ReadFile(filepath.Join(mp, "grub/grub.cfg"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read grub.cfg from %s: %w", esp, err)
|
||||
}
|
||||
want := "root=ZFS=" + ZFSRootDataset
|
||||
if !strings.Contains(string(grubCfg), want) {
|
||||
return fmt.Errorf("grub.cfg on %s does not contain %q — the installed system would not find its root filesystem", esp, want)
|
||||
}
|
||||
if !strings.Contains(string(grubCfg), "vmlinuz-") {
|
||||
return fmt.Errorf("grub.cfg on %s references no kernel", esp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTargetFile writes a file into the install target.
|
||||
func writeTargetFile(path string, content []byte, mode os.FileMode) error {
|
||||
full := filepath.Join(mountRoot, strings.TrimPrefix(path, "/"))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(full, content, mode); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
// WriteFile does not apply the mode to a file that already exists, and this
|
||||
// path is re-run on a retried install.
|
||||
return os.Chmod(full, mode)
|
||||
}
|
||||
|
||||
// writeGrubDefaults writes /etc/default/grub and the branding snippet.
|
||||
//
|
||||
// Both consoles are listed: tty0 stays primary for a local install while ttyS0
|
||||
// mirrors kernel output for headless racks and CI. The last console= on the
|
||||
// cmdline becomes the system console, so serial-only hardware still works.
|
||||
func writeGrubDefaults(cfg DiskConfig) error {
|
||||
cmdline := "console=tty0 console=ttyS0,115200 systemd.show_status=1"
|
||||
if cfg.FS.IsZFS() {
|
||||
// Stated explicitly rather than relying on grub-mkconfig's probe. It
|
||||
// appears after the root= that 10_linux computes, so it wins if the
|
||||
// probe came back with something wrong.
|
||||
cmdline = fmt.Sprintf("root=ZFS=%s boot=zfs %s", ZFSRootDataset, cmdline)
|
||||
}
|
||||
grubDefault := fmt.Sprintf(`GRUB_DEFAULT=0
|
||||
GRUB_TIMEOUT=5
|
||||
GRUB_DISTRIBUTOR=Spinifex
|
||||
GRUB_CMDLINE_LINUX_DEFAULT=""
|
||||
GRUB_CMDLINE_LINUX="%s"
|
||||
`, cmdline)
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
|
||||
return fmt.Errorf("write /etc/default/grub: %w", err)
|
||||
}
|
||||
|
||||
// gfxterm must be activated before serial is appended — background_image
|
||||
// silently does nothing in text mode. $prefix is used rather than a literal
|
||||
// /boot/grub so the same snippet is correct whether GRUB's root is the ext4
|
||||
// filesystem or a per-disk ESP.
|
||||
grubTheme := `#!/bin/sh
|
||||
exec tail -n +3 $0
|
||||
insmod all_video
|
||||
insmod font
|
||||
if loadfont $prefix/fonts/unicode.pf2; then
|
||||
set gfxmode=auto
|
||||
insmod gfxterm
|
||||
terminal_output gfxterm
|
||||
fi
|
||||
insmod serial
|
||||
if serial --unit=0 --speed=115200 --timeout=1; then
|
||||
terminal_input --append serial
|
||||
terminal_output --append serial
|
||||
fi
|
||||
|
||||
# --- Branding ---
|
||||
insmod png
|
||||
set theme=$prefix/theme.txt
|
||||
export theme
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
|
||||
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshBootToolNote logs the command an operator needs after replacing a
|
||||
// disk. Nothing in the installer can do this for them later.
|
||||
func refreshBootToolNote(cfg DiskConfig) {
|
||||
if cfg.FS.IsZFS() {
|
||||
slog.Info("bootloader installed on every pool member",
|
||||
"disks", len(cfg.Disks),
|
||||
"after_disk_replacement", "spinifex-boot-tool format <esp> && spinifex-boot-tool init <esp>")
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,107 @@
|
||||
// collected configuration.
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Plane names the three traffic planes a node presents. Each plane is a role
|
||||
// bound to a target, not a fixed NIC: the same three roles collapse onto one,
|
||||
// two or three interfaces depending on the hardware.
|
||||
type Plane string
|
||||
|
||||
const (
|
||||
// PlaneWAN carries public north-south traffic: ingress/egress, EIPs and
|
||||
// SNAT/DNAT. It is the OVN external bridge-mapping.
|
||||
PlaneWAN Plane = "wan"
|
||||
|
||||
// PlaneLAN carries internal cluster traffic: predastore replication, the
|
||||
// NATS cluster mesh, OVN NB/SB control, Raft and S3DB.
|
||||
PlaneLAN Plane = "lan"
|
||||
|
||||
// PlaneVPC carries the tenant overlay: OVN Geneve tunnels between nodes.
|
||||
// It is the ovn-encap-ip endpoint.
|
||||
PlaneVPC Plane = "vpc"
|
||||
)
|
||||
|
||||
// Bridge returns the Linux bridge that carries this plane.
|
||||
func (p Plane) Bridge() string { return "br-" + string(p) }
|
||||
|
||||
// NetworkRole binds one plane to a network target. An empty Interface means
|
||||
// the role is folded onto the next role up the chain (vpc <- lan <- wan) and
|
||||
// no bridge of its own is created.
|
||||
//
|
||||
// VLAN is what allows two roles to share one physical NIC while staying
|
||||
// separate planes: each gets its own NIC.<vlan> subinterface and bridge. Two
|
||||
// roles on the same Interface with no VLAN are the same plane, not two.
|
||||
type NetworkRole struct {
|
||||
// Interface is the physical NIC this role binds to; empty means folded.
|
||||
Interface string
|
||||
|
||||
// VLAN is the 802.1Q tag, or 0 for untagged.
|
||||
VLAN int
|
||||
|
||||
// DHCPMode leaves addressing to the DHCP client; Address/Mask are then unset.
|
||||
DHCPMode bool
|
||||
|
||||
Address string
|
||||
Mask string
|
||||
|
||||
// Gateway is only meaningful on the wan role — the other planes are
|
||||
// link-local to the rack and must not install a default route.
|
||||
Gateway string
|
||||
|
||||
DNS []string
|
||||
|
||||
// MTU is written as MTUBytes= on the link; 0 leaves the kernel default.
|
||||
// East-west planes typically run 9000, wan stays at 1500.
|
||||
MTU int
|
||||
|
||||
// WiFiSSID and WiFiPass are set when the selected NIC is wireless.
|
||||
WiFiSSID string
|
||||
WiFiPass string
|
||||
}
|
||||
|
||||
// Folded reports whether this role has no interface of its own and inherits
|
||||
// the plane of the role above it.
|
||||
func (r NetworkRole) Folded() bool { return r.Interface == "" }
|
||||
|
||||
// Link returns the name of the interface carrying this role's address: the
|
||||
// physical NIC, or its VLAN subinterface when tagged. Empty when folded.
|
||||
func (r NetworkRole) Link() string {
|
||||
if r.Folded() {
|
||||
return ""
|
||||
}
|
||||
if r.VLAN > 0 {
|
||||
return fmt.Sprintf("%s.%d", r.Interface, r.VLAN)
|
||||
}
|
||||
return r.Interface
|
||||
}
|
||||
|
||||
// Config holds all values collected by the installer UI.
|
||||
type Config struct {
|
||||
// Disk is the block device path to install onto (e.g. /dev/sda).
|
||||
Disk string
|
||||
// Storage is the filesystem choice and the ordered set of disks it is
|
||||
// built from. In ext4 mode it holds exactly one disk.
|
||||
Storage DiskConfig
|
||||
|
||||
// WAN interface — always present. The management IP lives on br-wan (a Linux
|
||||
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
|
||||
// to safely attach to the bridge without disrupting host connectivity.
|
||||
WANInterface string
|
||||
WANDHCPMode bool
|
||||
WANAddress string // empty if WANDHCPMode
|
||||
WANMask string // empty if WANDHCPMode
|
||||
WANGateway string // empty if WANDHCPMode
|
||||
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
|
||||
WANWiFiSSID string // non-empty if a WiFi NIC was selected
|
||||
WANWiFiPass string // non-empty if a WiFi NIC was selected
|
||||
// WAN is always present and can never be folded — a node needs an uplink.
|
||||
// The address lives on br-wan (a Linux bridge over the NIC), not on the
|
||||
// physical NIC itself, so OVN/OVS can attach to the bridge without
|
||||
// disrupting host connectivity.
|
||||
WAN NetworkRole
|
||||
|
||||
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
|
||||
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
|
||||
LANInterface string
|
||||
LANDHCPMode bool
|
||||
LANAddress string
|
||||
LANMask string
|
||||
LANDNS []string
|
||||
LANWiFiSSID string
|
||||
LANWiFiPass string
|
||||
// LAN carries internal cluster traffic. Folded onto WAN on single-NIC nodes.
|
||||
LAN NetworkRole
|
||||
|
||||
// VPC carries Geneve overlay traffic and supplies ovn-encap-ip. Folded onto
|
||||
// LAN unless a node dedicates an interface or VLAN to it.
|
||||
VPC NetworkRole
|
||||
|
||||
// Node identity
|
||||
Hostname string
|
||||
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
|
||||
// JoinAddr is the primary node address (host:port) when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
|
||||
// SkipFormation skips spx admin init/join in firstboot; the provisioning
|
||||
// controller (e.g. bm-bootstrap.sh) owns cluster formation instead.
|
||||
SkipFormation bool
|
||||
@@ -60,3 +125,111 @@ type Config struct {
|
||||
// Passes --gpu-passthrough to `spx admin init` during firstboot.
|
||||
GPUPassthrough bool
|
||||
}
|
||||
|
||||
// Resolve returns the role that actually carries the given plane after
|
||||
// collapsing folded roles up the chain vpc <- lan <- wan, along with the plane
|
||||
// it landed on. A folded vpc on a single-NIC node resolves to the wan role.
|
||||
func (c *Config) Resolve(p Plane) (NetworkRole, Plane) {
|
||||
switch p {
|
||||
case PlaneVPC:
|
||||
if !c.VPC.Folded() {
|
||||
return c.VPC, PlaneVPC
|
||||
}
|
||||
return c.Resolve(PlaneLAN)
|
||||
case PlaneLAN:
|
||||
if !c.LAN.Folded() {
|
||||
return c.LAN, PlaneLAN
|
||||
}
|
||||
return c.WAN, PlaneWAN
|
||||
default:
|
||||
return c.WAN, PlaneWAN
|
||||
}
|
||||
}
|
||||
|
||||
// PlaneAddress returns the configured address for a plane after collapsing.
|
||||
// Empty when the resolved role uses DHCP, since no static address is known
|
||||
// until the lease is taken.
|
||||
func (c *Config) PlaneAddress(p Plane) string {
|
||||
role, _ := c.Resolve(p)
|
||||
if role.DHCPMode {
|
||||
return ""
|
||||
}
|
||||
return role.Address
|
||||
}
|
||||
|
||||
// PlaneBridge returns the bridge carrying a plane after collapsing, so callers
|
||||
// asking for the vpc bridge on a single-NIC node correctly get br-wan.
|
||||
func (c *Config) PlaneBridge(p Plane) string {
|
||||
_, landed := c.Resolve(p)
|
||||
return landed.Bridge()
|
||||
}
|
||||
|
||||
// RoleBinding pairs a plane with the role that carries it.
|
||||
type RoleBinding struct {
|
||||
Plane Plane
|
||||
Role NetworkRole
|
||||
}
|
||||
|
||||
// Roles returns the bound roles in bridge-creation order, skipping folded ones.
|
||||
// wan comes first because br-lan and br-vpc are activated manually after it.
|
||||
func (c *Config) Roles() []RoleBinding {
|
||||
out := []RoleBinding{{PlaneWAN, c.WAN}}
|
||||
if !c.LAN.Folded() {
|
||||
out = append(out, RoleBinding{PlaneLAN, c.LAN})
|
||||
}
|
||||
if !c.VPC.Folded() {
|
||||
out = append(out, RoleBinding{PlaneVPC, c.VPC})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate enforces the sharing rules the UI and the headless path must both
|
||||
// obey: wan always needs a target, and two roles may only share an interface
|
||||
// when distinct VLAN ids keep them on separate broadcast domains.
|
||||
func (c *Config) Validate() error {
|
||||
if c.WAN.Folded() {
|
||||
return fmt.Errorf("wan role must be bound to an interface")
|
||||
}
|
||||
|
||||
seen := map[string]Plane{}
|
||||
for _, r := range c.Roles() {
|
||||
link := r.Role.Link()
|
||||
if prev, dup := seen[link]; dup {
|
||||
return fmt.Errorf("%s and %s both bind %s: give each a distinct VLAN id or fold one onto the other",
|
||||
prev, r.Plane, link)
|
||||
}
|
||||
seen[link] = r.Plane
|
||||
if r.Role.VLAN < 0 || r.Role.VLAN > 4094 {
|
||||
return fmt.Errorf("%s: VLAN id %d out of range (1-4094)", r.Plane, r.Role.VLAN)
|
||||
}
|
||||
if r.Role.MTU != 0 && (r.Role.MTU < 68 || r.Role.MTU > 9216) {
|
||||
return fmt.Errorf("%s: MTU %d out of range (68-9216)", r.Plane, r.Role.MTU)
|
||||
}
|
||||
// Resolvers belong to the plane that holds the default route. Accepting
|
||||
// one here and dropping it during generation would leave an unattended
|
||||
// install believing it had set a resolver that was never written.
|
||||
if r.Plane != PlaneWAN && len(r.Role.DNS) > 0 {
|
||||
return fmt.Errorf("%s: DNS servers belong to the wan plane, which holds the default route", r.Plane)
|
||||
}
|
||||
// Netgen converts the mask with net.ParseIP, so a prefix length reaches
|
||||
// it as garbage and fails the install after the disk is partitioned.
|
||||
// Catch it here, where the operator can still fix it.
|
||||
if m := strings.TrimSpace(r.Role.Mask); m != "" && !validDottedMask(m) {
|
||||
return fmt.Errorf("%s: subnet mask %q must be dotted-decimal, e.g. 255.255.255.0", r.Plane, m)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validDottedMask reports whether s is a contiguous dotted-decimal IPv4 subnet
|
||||
// mask. Prefix-length form ("24", "/24") is deliberately not accepted: netgen
|
||||
// parses the mask with net.ParseIP, so anything else fails the install itself.
|
||||
func validDottedMask(s string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(s))
|
||||
if ip == nil || ip.To4() == nil {
|
||||
return false
|
||||
}
|
||||
// Size reports zero bits for a non-contiguous mask, which netgen rejects too.
|
||||
_, bits := net.IPMask(ip.To4()).Size()
|
||||
return bits != 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Node layouts drawn from the us-west-1 racks, which between them exercise
|
||||
// every collapse permutation the installer has to express.
|
||||
func threeNIC() *Config { // hydrogen / radon
|
||||
return &Config{
|
||||
WAN: NetworkRole{Interface: "eno6", Address: "216.218.163.99", Mask: "255.255.255.224", Gateway: "216.218.163.97"},
|
||||
LAN: NetworkRole{Interface: "ens1f0np0", Address: "10.0.0.3", Mask: "255.255.0.0", MTU: 9000},
|
||||
VPC: NetworkRole{Interface: "ens1f1np1", Address: "10.1.0.3", Mask: "255.255.0.0", MTU: 9000},
|
||||
}
|
||||
}
|
||||
|
||||
func vlanSplit() *Config { // helium — one 100gbe carrying lan and vpc
|
||||
return &Config{
|
||||
WAN: NetworkRole{Interface: "enx6c1ff7c1475a", DHCPMode: true},
|
||||
LAN: NetworkRole{Interface: "enp129s0np0", VLAN: 10, Address: "10.0.0.2", Mask: "255.255.0.0", MTU: 9000},
|
||||
VPC: NetworkRole{Interface: "enp129s0np0", VLAN: 20, Address: "10.1.0.2", Mask: "255.255.0.0", MTU: 9000},
|
||||
}
|
||||
}
|
||||
|
||||
func dualPortTagged() *Config { // us-west-1-az2 — wan and lan share port 1
|
||||
return &Config{
|
||||
WAN: NetworkRole{Interface: "enp1s0f0", VLAN: 30, Address: "216.218.163.10", Mask: "255.255.255.224", Gateway: "216.218.163.97"},
|
||||
LAN: NetworkRole{Interface: "enp1s0f0", VLAN: 10, Address: "10.0.0.10", Mask: "255.255.0.0", MTU: 9000},
|
||||
VPC: NetworkRole{Interface: "enp1s0f1", VLAN: 20, Address: "10.1.0.10", Mask: "255.255.0.0", MTU: 9000},
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCollapsesUpTheChain(t *testing.T) {
|
||||
singleNIC := &Config{WAN: NetworkRole{Interface: "eth0", Address: "192.168.1.10", Mask: "255.255.255.0"}}
|
||||
twoNIC := &Config{
|
||||
WAN: NetworkRole{Interface: "eth0", Address: "192.168.1.10", Mask: "255.255.255.0"},
|
||||
LAN: NetworkRole{Interface: "eth1", Address: "10.0.0.5", Mask: "255.255.0.0"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
plane Plane
|
||||
wantAddr string
|
||||
wantPlane Plane
|
||||
}{
|
||||
{"1 NIC: lan folds onto wan", singleNIC, PlaneLAN, "192.168.1.10", PlaneWAN},
|
||||
{"1 NIC: vpc folds all the way to wan", singleNIC, PlaneVPC, "192.168.1.10", PlaneWAN},
|
||||
{"2 NIC: vpc folds onto lan", twoNIC, PlaneVPC, "10.0.0.5", PlaneLAN},
|
||||
{"2 NIC: lan stays put", twoNIC, PlaneLAN, "10.0.0.5", PlaneLAN},
|
||||
{"3 NIC: vpc is its own plane", threeNIC(), PlaneVPC, "10.1.0.3", PlaneVPC},
|
||||
{"VLAN split: vpc is its own plane", vlanSplit(), PlaneVPC, "10.1.0.2", PlaneVPC},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.cfg.PlaneAddress(tt.plane); got != tt.wantAddr {
|
||||
t.Errorf("PlaneAddress(%s) = %q, want %q", tt.plane, got, tt.wantAddr)
|
||||
}
|
||||
if _, landed := tt.cfg.Resolve(tt.plane); landed != tt.wantPlane {
|
||||
t.Errorf("Resolve(%s) landed on %s, want %s", tt.plane, landed, tt.wantPlane)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A DHCP plane has no address known at install time; setup-ovn.sh auto-detects
|
||||
// it from the default route at boot instead.
|
||||
func TestPlaneAddressEmptyUnderDHCP(t *testing.T) {
|
||||
if got := vlanSplit().PlaneAddress(PlaneWAN); got != "" {
|
||||
t.Errorf("PlaneAddress(wan) = %q, want empty for a DHCP role", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
wantErr string
|
||||
}{
|
||||
{"three physical NICs", threeNIC(), ""},
|
||||
{"VLAN split on one NIC", vlanSplit(), ""},
|
||||
{"dual port, all tagged", dualPortTagged(), ""},
|
||||
{
|
||||
"wan cannot be folded",
|
||||
&Config{LAN: NetworkRole{Interface: "eth1"}},
|
||||
"wan role must be bound",
|
||||
},
|
||||
{
|
||||
"two roles share a NIC untagged",
|
||||
&Config{
|
||||
WAN: NetworkRole{Interface: "eth0"},
|
||||
LAN: NetworkRole{Interface: "eth0"},
|
||||
},
|
||||
"both bind eth0",
|
||||
},
|
||||
{
|
||||
"duplicate VLAN id on one NIC",
|
||||
&Config{
|
||||
WAN: NetworkRole{Interface: "eth0", VLAN: 10},
|
||||
LAN: NetworkRole{Interface: "eth0", VLAN: 10},
|
||||
},
|
||||
"both bind eth0.10",
|
||||
},
|
||||
{
|
||||
"VLAN id out of range",
|
||||
&Config{WAN: NetworkRole{Interface: "eth0", VLAN: 5000}},
|
||||
"out of range",
|
||||
},
|
||||
{
|
||||
"MTU out of range",
|
||||
&Config{WAN: NetworkRole{Interface: "eth0", MTU: 70000}},
|
||||
"MTU 70000 out of range",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("Validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two roles sharing one NIC must produce a single parent .network carrying both
|
||||
// VLAN declarations — not two files racing to configure the same interface.
|
||||
func TestWriteParentNICTaggedSharesOneFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := &parentNIC{vlans: []string{"enp129s0np0.10", "enp129s0np0.20"}, mtu: 9000}
|
||||
if err := writeParentNIC(dir, "enp129s0np0", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := readFile(t, dir, "05-spinifex-nic-enp129s0np0.network")
|
||||
for _, want := range []string{
|
||||
"Name=enp129s0np0",
|
||||
"MTUBytes=9000",
|
||||
"VLAN=enp129s0np0.10",
|
||||
"VLAN=enp129s0np0.20",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("parent .network missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// A tagged parent is never itself enslaved — the VLAN devices are.
|
||||
if strings.Contains(got, "Bridge=") {
|
||||
t.Errorf("tagged parent must not be bridged directly:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNetworkdBridgeVLAN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := vlanSplit()
|
||||
if err := writeNetworkdBridge(dir, PlaneVPC, cfg.VPC, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
netdev := readFile(t, dir, "10-spinifex-vpc-vlan.netdev")
|
||||
if !strings.Contains(netdev, "Kind=vlan") || !strings.Contains(netdev, "Id=20") {
|
||||
t.Errorf("VLAN netdev wrong:\n%s", netdev)
|
||||
}
|
||||
if !strings.Contains(netdev, "Name=enp129s0np0.20") {
|
||||
t.Errorf("VLAN netdev name wrong:\n%s", netdev)
|
||||
}
|
||||
|
||||
vlanNet := readFile(t, dir, "11-spinifex-vpc-vlan.network")
|
||||
if !strings.Contains(vlanNet, "Bridge=br-vpc") {
|
||||
t.Errorf("VLAN device must be enslaved to br-vpc:\n%s", vlanNet)
|
||||
}
|
||||
|
||||
brNet := readFile(t, dir, "20-spinifex-vpc.network")
|
||||
if !strings.Contains(brNet, "Address=10.1.0.2/16") {
|
||||
t.Errorf("bridge address wrong:\n%s", brNet)
|
||||
}
|
||||
// Only wan installs a default route; an east-west plane must not.
|
||||
if strings.Contains(brNet, "Gateway=") {
|
||||
t.Errorf("vpc plane must not install a default route:\n%s", brNet)
|
||||
}
|
||||
if !strings.Contains(brNet, "ActivationPolicy=manual") {
|
||||
t.Errorf("east-west bridge must not auto-activate:\n%s", brNet)
|
||||
}
|
||||
}
|
||||
|
||||
// The wan plane is the one that carries a default route and auto-activates.
|
||||
func TestWriteNetworkdBridgeWANUntagged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := threeNIC()
|
||||
if err := writeNetworkdBridge(dir, PlaneWAN, cfg.WAN, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "10-spinifex-wan-vlan.netdev")); !os.IsNotExist(err) {
|
||||
t.Error("untagged role must not produce a VLAN netdev")
|
||||
}
|
||||
brNet := readFile(t, dir, "20-spinifex-wan.network")
|
||||
if !strings.Contains(brNet, "Gateway=216.218.163.97") {
|
||||
t.Errorf("wan must install its default route:\n%s", brNet)
|
||||
}
|
||||
if strings.Contains(brNet, "ActivationPolicy=manual") {
|
||||
t.Errorf("wan bridge must auto-activate:\n%s", brNet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFirstbootConfigSourcesPlanes(t *testing.T) {
|
||||
cfg := threeNIC()
|
||||
cfg.Hostname = "hydrogen"
|
||||
fb := cfg.toFirstbootConfig()
|
||||
|
||||
if fb.EncapIP != "10.1.0.3" {
|
||||
t.Errorf("EncapIP = %q, want the vpc plane address", fb.EncapIP)
|
||||
}
|
||||
if fb.LANIP != "10.0.0.3" {
|
||||
t.Errorf("LANIP = %q, want the lan plane address", fb.LANIP)
|
||||
}
|
||||
// The public address has to travel separately: spx echoes a concrete --bind
|
||||
// back as the advertise address, so leaving this empty would publish the
|
||||
// internal plane as the node's public dial target.
|
||||
if fb.WANIP != "216.218.163.99" {
|
||||
t.Errorf("WANIP = %q, want the wan plane address", fb.WANIP)
|
||||
}
|
||||
|
||||
// On a single-NIC node all three collapse onto wan.
|
||||
single := &Config{WAN: NetworkRole{Interface: "eth0", Address: "192.168.1.10", Mask: "255.255.255.0"}}
|
||||
fb = single.toFirstbootConfig()
|
||||
if fb.EncapIP != "192.168.1.10" || fb.LANIP != "192.168.1.10" || fb.WANIP != "192.168.1.10" {
|
||||
t.Errorf("single-NIC collapse wrong: EncapIP=%q LANIP=%q WANIP=%q", fb.EncapIP, fb.LANIP, fb.WANIP)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Resolvers belong to the plane holding the default route. The east-west
|
||||
// bridges are link-local and activate manually, so a resolver pinned to one is
|
||||
// unreachable while resolved still tries it — a timeout in front of every
|
||||
// lookup. The TUI cannot express this; the kernel cmdline can, so it is caught.
|
||||
func TestValidateRejectsDNSOffWAN(t *testing.T) {
|
||||
cfg := threeNIC()
|
||||
cfg.LAN.DNS = []string{"10.0.0.1"}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("lan must not carry DNS servers")
|
||||
}
|
||||
|
||||
cfg = threeNIC()
|
||||
cfg.VPC.DNS = []string{"10.1.0.1"}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("vpc must not carry DNS servers")
|
||||
}
|
||||
|
||||
cfg = threeNIC()
|
||||
cfg.WAN.DNS = []string{"8.8.8.8", "1.1.1.1"}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("wan carries the resolvers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Even if a role somehow carries DNS, generation must not write it onto an
|
||||
// east-west bridge.
|
||||
func TestWriteNetworkdBridgeKeepsDNSOnWAN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lan := NetworkRole{Interface: "ens1f0np0", Address: "10.0.0.3", Mask: "255.255.0.0", DNS: []string{"10.0.0.1"}}
|
||||
if err := writeNetworkdBridge(dir, PlaneLAN, lan, true); err != nil {
|
||||
t.Fatalf("writeNetworkdBridge: %v", err)
|
||||
}
|
||||
if got := readFile(t, dir, "20-spinifex-lan.network"); strings.Contains(got, "DNS=") {
|
||||
t.Errorf("lan bridge must carry no resolver:\n%s", got)
|
||||
}
|
||||
|
||||
wan := NetworkRole{Interface: "eno6", Address: "216.218.163.99", Mask: "255.255.255.224", Gateway: "216.218.163.97", DNS: []string{"8.8.8.8"}}
|
||||
if err := writeNetworkdBridge(dir, PlaneWAN, wan, false); err != nil {
|
||||
t.Fatalf("writeNetworkdBridge: %v", err)
|
||||
}
|
||||
if got := readFile(t, dir, "20-spinifex-wan.network"); !strings.Contains(got, "DNS=8.8.8.8") {
|
||||
t.Errorf("wan bridge must carry the resolver:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Without an override, RAM drives the size and the disk caps it.
|
||||
ram, err := totalRAM()
|
||||
if err != nil {
|
||||
t.Fatalf("totalRAM: %v", err)
|
||||
}
|
||||
got, err := swapSize(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("swapSize: %v", err)
|
||||
}
|
||||
want := ram / 2
|
||||
if ram >= swapRAMThreshold {
|
||||
want = swapLargeBytes
|
||||
}
|
||||
if got > want {
|
||||
t.Errorf("swapSize = %d, must not exceed what RAM implies (%d)", got, want)
|
||||
}
|
||||
if got != 0 && got < swapMinBytes {
|
||||
t.Errorf("swapSize = %d, must be 0 rather than below the %d floor", got, int64(swapMinBytes))
|
||||
}
|
||||
|
||||
// An explicit size wins, so a large node can be told to take the full 32G
|
||||
// and a constrained one can opt out entirely.
|
||||
for _, tt := range []struct {
|
||||
env string
|
||||
want int64
|
||||
}{
|
||||
{"32G", 32 << 30},
|
||||
{"512M", 512 << 20},
|
||||
{"0", 0},
|
||||
{"1073741824", 1 << 30},
|
||||
} {
|
||||
t.Setenv("SPINIFEX_SWAP_SIZE", tt.env)
|
||||
got, err := swapSize(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("swapSize(%s): %v", tt.env, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("swapSize(%s) = %d, want %d", tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv("SPINIFEX_SWAP_SIZE", "banana")
|
||||
if _, err := swapSize(dir); err == nil {
|
||||
t.Error("an unparseable size must be reported, not silently ignored")
|
||||
}
|
||||
}
|
||||
|
||||
// The swap line must only appear when the file backing it exists — systemd
|
||||
// fails the boot's swap unit on a missing file rather than skipping it.
|
||||
func TestFstabSwapLineTracksTheFile(t *testing.T) {
|
||||
if _, err := parseSize("8G"); err != nil {
|
||||
t.Fatalf("parseSize: %v", err)
|
||||
}
|
||||
if n, _ := parseSize("2G"); n != 2<<30 {
|
||||
t.Errorf("parseSize(2G) = %d, want %d", n, int64(2)<<30)
|
||||
}
|
||||
if _, err := parseSize("-5G"); err == nil {
|
||||
t.Error("negative sizes must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge ports and VLAN trunks never reach the "degraded" state
|
||||
// systemd-networkd-wait-online requires — the address sits on the bridge above
|
||||
// them. Leaving them required makes that unit fail on every boot and stall
|
||||
// until it times out, which is what a three-NIC node was doing.
|
||||
func TestSlaveLinksAreNotRequiredForOnline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Untagged bridge port.
|
||||
if err := writeParentNIC(dir, "ens4", &parentNIC{bridge: "br-lan", mtu: 9000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readFile(t, dir, "05-spinifex-nic-ens4.network")
|
||||
if !strings.Contains(got, "RequiredForOnline=no") {
|
||||
t.Errorf("bridge port must not gate network-online:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "MTUBytes=9000") {
|
||||
t.Errorf("MTU must survive alongside it:\n%s", got)
|
||||
}
|
||||
|
||||
// VLAN trunk carrying tagged planes.
|
||||
if err := writeParentNIC(dir, "enp129s0np0", &parentNIC{vlans: []string{"enp129s0np0.10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, dir, "05-spinifex-nic-enp129s0np0.network"); !strings.Contains(got, "RequiredForOnline=no") {
|
||||
t.Errorf("VLAN trunk must not gate network-online:\n%s", got)
|
||||
}
|
||||
|
||||
// The VLAN device itself is enslaved too.
|
||||
cfg := vlanSplit()
|
||||
if err := writeNetworkdBridge(dir, PlaneVPC, cfg.VPC, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, dir, "11-spinifex-vpc-vlan.network"); !strings.Contains(got, "RequiredForOnline=no") {
|
||||
t.Errorf("VLAN device must not gate network-online:\n%s", got)
|
||||
}
|
||||
|
||||
// The wan bridge is what should gate it, and still does.
|
||||
three := threeNIC()
|
||||
if err := writeNetworkdBridge(dir, PlaneWAN, three.WAN, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, dir, "20-spinifex-wan.network"); strings.Contains(got, "RequiredForOnline=no") {
|
||||
t.Errorf("wan bridge must remain required for network-online:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix length reaches netgen's net.ParseIP as garbage and fails the install
|
||||
// after the disk has been partitioned, so it has to be rejected up front. The
|
||||
// TUI advertised "255.255.255.0 or 24" while only the first ever worked.
|
||||
func TestValidateRejectsPrefixLengthMask(t *testing.T) {
|
||||
for _, bad := range []string{"24", "/24", "16", "255.255.0", "255.0.255.0", "banana"} {
|
||||
cfg := threeNIC()
|
||||
cfg.LAN.Mask = bad
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Errorf("mask %q must be rejected", bad)
|
||||
} else if !strings.Contains(err.Error(), "dotted-decimal") {
|
||||
t.Errorf("mask %q: error should name the expected form, got: %v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, good := range []string{"255.255.255.0", "255.255.0.0", "255.255.255.252", "255.255.255.255"} {
|
||||
cfg := threeNIC()
|
||||
cfg.LAN.Mask = good
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("mask %q must be accepted: %v", good, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A DHCP role carries no mask, and that must stay valid.
|
||||
cfg := threeNIC()
|
||||
cfg.LAN = NetworkRole{Interface: "ens1f0np0", DHCPMode: true}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("a DHCP role has no mask to check: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A bridged uplink must present the enslaved NIC's address. Left to its default
|
||||
// policy, systemd-networkd generates one for the bridge, so the node appears on
|
||||
// the wire as an address belonging to no NIC — DHCP requests carry a source that
|
||||
// disagrees with their own chaddr and the unicast reply is never delivered.
|
||||
func TestBridgeInheritsPortMAC(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := threeNIC()
|
||||
|
||||
for _, tc := range []struct {
|
||||
plane Plane
|
||||
role NetworkRole
|
||||
manual bool
|
||||
}{
|
||||
{PlaneWAN, cfg.WAN, false},
|
||||
{PlaneLAN, cfg.LAN, true},
|
||||
{PlaneVPC, cfg.VPC, true},
|
||||
} {
|
||||
if err := writeNetworkdBridge(dir, tc.plane, tc.role, tc.manual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readFile(t, dir, "20-spinifex-"+string(tc.plane)+".netdev")
|
||||
if !strings.Contains(got, "MACAddressPolicy=none") {
|
||||
t.Errorf("%s bridge must inherit its port's MAC:\n%s", tc.plane, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Partition numbers, identical on every disk the installer touches so that a
|
||||
// replacement drive can be cloned with `sgdisk -R` and nothing has to be
|
||||
// recomputed at 3am.
|
||||
const (
|
||||
biosPartNum = 1
|
||||
espPartNum = 2
|
||||
rootPartNum = 3
|
||||
|
||||
// dataPartNum is the sole partition on a role drive. Data drives carry no
|
||||
// bootloader, so they start at 1 rather than mirroring the boot layout.
|
||||
dataPartNum = 1
|
||||
)
|
||||
|
||||
// GPT partition type GUIDs, by their sgdisk short code.
|
||||
const (
|
||||
typeBIOSBoot = "EF02"
|
||||
typeESP = "EF00"
|
||||
typeLinuxFS = "8300"
|
||||
typeZFS = "BF00"
|
||||
)
|
||||
|
||||
// Partition geometry. The ESP is sized as Proxmox does: 512MiB is enough for
|
||||
// two kernels, but a 1GiB ESP on a real disk costs nothing and keeps room for
|
||||
// the extra kernels an operator pins during an upgrade.
|
||||
const (
|
||||
espSmallMiB = 512
|
||||
espLargeMiB = 1024
|
||||
espLargeDiskGiB = 100
|
||||
|
||||
// biosBootMiB is the BIOS Boot Partition GRUB's i386-pc core.img is embedded
|
||||
// into. GPT leaves no post-MBR gap, so without it there is nowhere to put it.
|
||||
biosBootMiB = 1
|
||||
|
||||
// tailReserveMiB is left unallocated at the end of every disk. A replacement
|
||||
// drive of the "same" size is routinely a few thousand sectors smaller, and
|
||||
// without slack `sgdisk -R` refuses to clone the table onto it — which is
|
||||
// discovered while standing in front of a degraded pool.
|
||||
tailReserveMiB = 1024
|
||||
)
|
||||
|
||||
// partitionSettleTimeout bounds the wait for the kernel and udev to publish a
|
||||
// disk's new partition table. A var so tests do not pay it.
|
||||
var partitionSettleTimeout = 15 * time.Second
|
||||
|
||||
// releaseDisk detaches whatever the live environment has attached to a disk
|
||||
// before it is erased. A mounted filesystem or an active swap area makes
|
||||
// BLKRRPART return EBUSY, and the kernel then keeps serving the disk's old
|
||||
// partition table while sgdisk writes the new one and reports success.
|
||||
func releaseDisk(d Disk) {
|
||||
for _, m := range mountsOnDisk(d.Path) {
|
||||
err := runQuiet("umount", m.Device)
|
||||
if err != nil {
|
||||
// Lazy detach: a busy mount must not stall an install that is about to
|
||||
// erase the filesystem underneath it anyway.
|
||||
err = runQuiet("umount", "-l", m.Device)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("could not unmount before erase", "device", m.Device, "mountpoint", m.Mountpoint, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("released mount before erase", "device", m.Device, "mountpoint", m.Mountpoint)
|
||||
}
|
||||
for _, s := range swapsOnDisk(d.Path) {
|
||||
if err := runQuiet("swapoff", s); err != nil {
|
||||
slog.Warn("could not disable swap before erase", "device", s, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("disabled swap before erase", "device", s)
|
||||
}
|
||||
}
|
||||
|
||||
// settlePartitions forces the kernel to re-read a disk's partition table and
|
||||
// blocks until its view matches want, naming the partitions by number.
|
||||
//
|
||||
// An empty want asserts the disk has no partitions at all. That is the only
|
||||
// state no leftover can satisfy: a disk whose previous table had the same
|
||||
// partition numbers as the one being written — an old OS install is p1, p2, p3,
|
||||
// exactly what the boot layout writes — passes an "are the expected partitions
|
||||
// there" check on stale nodes alone.
|
||||
func settlePartitions(d Disk, want ...int) error {
|
||||
if err := runQuiet("partprobe", d.Path); err != nil {
|
||||
// partprobe rescans the whole disk and fails if it cannot open any part of
|
||||
// it; the ioctl on its own sometimes still gets through.
|
||||
if err := runQuiet("blockdev", "--rereadpt", d.Path); err != nil {
|
||||
slog.Warn("could not force a partition table re-read", "disk", d.Path, "err", err)
|
||||
}
|
||||
}
|
||||
_ = runQuiet("udevadm", "settle", "--timeout=10")
|
||||
|
||||
wanted := make([]string, 0, len(want))
|
||||
for _, n := range want {
|
||||
wanted = append(wanted, filepath.Base(d.PartitionPath(n)))
|
||||
}
|
||||
slices.Sort(wanted)
|
||||
|
||||
deadline := time.Now().Add(partitionSettleTimeout)
|
||||
for {
|
||||
got, err := kernelPartitions(d.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Equal(got, wanted) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return staleTableError(d, wanted, got)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// staleTableError explains a kernel partition view that never caught up. The
|
||||
// erase case gets its own wording because it is the dangerous one — proceeding
|
||||
// would format the previous layout — and because the holder is usually the
|
||||
// whole diagnosis.
|
||||
func staleTableError(d Disk, wanted, got []string) error {
|
||||
if len(wanted) == 0 {
|
||||
msg := fmt.Sprintf("%s still shows partitions [%s] after its table was erased — the kernel is using the old table, so formatting would write at the previous layout's offsets",
|
||||
d.Path, strings.Join(got, ", "))
|
||||
if h := diskHolders(d.Path); len(h) > 0 {
|
||||
return fmt.Errorf("%s; it is held by %s, which must be stopped before this disk can be installed to",
|
||||
msg, strings.Join(h, ", "))
|
||||
}
|
||||
return fmt.Errorf("%s; something in the live environment is holding it open", msg)
|
||||
}
|
||||
return fmt.Errorf("%s: the kernel shows partitions [%s] but the new table has [%s] — the table was written but never picked up",
|
||||
d.Path, strings.Join(got, ", "), strings.Join(wanted, ", "))
|
||||
}
|
||||
|
||||
// espSizeMiB picks the ESP size for a disk.
|
||||
func espSizeMiB(d Disk) int {
|
||||
if d.Bytes >= espLargeDiskGiB<<30 {
|
||||
return espLargeMiB
|
||||
}
|
||||
return espSmallMiB
|
||||
}
|
||||
|
||||
// partitionDisks lays down an identical GPT on every selected disk. Each disk
|
||||
// gets a BIOS boot partition and an ESP whether or not this machine booted in
|
||||
// EFI mode: the cost is 1GiB, and it means a board swap or a firmware change
|
||||
// from BIOS to UEFI does not leave an unbootable pool.
|
||||
func partitionDisks(cfg DiskConfig) error {
|
||||
ptype := typeLinuxFS
|
||||
if cfg.FS.IsZFS() {
|
||||
ptype = typeZFS
|
||||
}
|
||||
for _, d := range cfg.bootDisks() {
|
||||
if err := partitionOne(d, ptype); err != nil {
|
||||
return fmt.Errorf("partition %s: %w", d.Path, err)
|
||||
}
|
||||
}
|
||||
for _, rm := range cfg.DataMounts() {
|
||||
if err := partitionDataDisk(rm); err != nil {
|
||||
return fmt.Errorf("partition %s (%s): %w", rm.Disk.Path, rm.Role, err)
|
||||
}
|
||||
}
|
||||
return waitForPartitions(cfg)
|
||||
}
|
||||
|
||||
// partitionDataDisk writes the single-partition GPT for a role drive. It gets
|
||||
// no bios_boot and no ESP: nothing installs a bootloader there, and a formatted
|
||||
// ESP no firmware entry points at only misleads whoever reads lsblk in an
|
||||
// incident.
|
||||
func partitionDataDisk(rm RoleMount) error {
|
||||
if err := clearDisk(rm.Disk); err != nil {
|
||||
return fmt.Errorf("clear: %w", err)
|
||||
}
|
||||
// Same tail reserve as a boot drive, for the same reason: a replacement of
|
||||
// the "same" size is routinely a few thousand sectors smaller.
|
||||
if err := run("sgdisk",
|
||||
"-n", fmt.Sprintf("%d:1M:-%dM", dataPartNum, tailReserveMiB),
|
||||
"-t", fmt.Sprintf("%d:%s", dataPartNum, typeLinuxFS),
|
||||
"-c", fmt.Sprintf("%d:%s", dataPartNum, rm.Role.Label()),
|
||||
rm.Disk.Path,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("sgdisk", "-G", rm.Disk.Path); err != nil {
|
||||
slog.Warn("could not randomise GPT GUIDs", "disk", rm.Disk.Path, "err", err)
|
||||
}
|
||||
return settlePartitions(rm.Disk, dataPartNum)
|
||||
}
|
||||
|
||||
// partitionOne writes the GPT for a single disk.
|
||||
func partitionOne(d Disk, rootType string) error {
|
||||
// Stale ZFS labels and filesystem signatures survive a new partition table
|
||||
// and make a retried install fail with errors that read like hardware faults.
|
||||
if err := clearDisk(d); err != nil {
|
||||
return fmt.Errorf("clear: %w", err)
|
||||
}
|
||||
|
||||
esp := espSizeMiB(d)
|
||||
if err := run("sgdisk",
|
||||
"-n", fmt.Sprintf("%d:1M:+%dM", biosPartNum, biosBootMiB),
|
||||
"-t", fmt.Sprintf("%d:%s", biosPartNum, typeBIOSBoot),
|
||||
"-c", fmt.Sprintf("%d:bios_boot", biosPartNum),
|
||||
"-n", fmt.Sprintf("%d:0:+%dM", espPartNum, esp),
|
||||
"-t", fmt.Sprintf("%d:%s", espPartNum, typeESP),
|
||||
"-c", fmt.Sprintf("%d:ESP", espPartNum),
|
||||
"-n", fmt.Sprintf("%d:0:-%dM", rootPartNum, tailReserveMiB),
|
||||
"-t", fmt.Sprintf("%d:%s", rootPartNum, rootType),
|
||||
"-c", fmt.Sprintf("%d:root", rootPartNum),
|
||||
d.Path,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// A fresh disk-level GUID: cloning a table with `sgdisk -R` copies it, and
|
||||
// two disks sharing one leaves the pool ambiguous to udev.
|
||||
if err := run("sgdisk", "-G", d.Path); err != nil {
|
||||
slog.Warn("could not randomise GPT GUIDs", "disk", d.Path, "err", err)
|
||||
}
|
||||
return settlePartitions(d, biosPartNum, espPartNum, rootPartNum)
|
||||
}
|
||||
|
||||
// waitForPartitions ensures every partition device node the installer is about
|
||||
// to use exists. The kernel's own view was already asserted per disk as each
|
||||
// table was written; udev publishes the /dev nodes and by-id symlinks after
|
||||
// that, and Trixie is slow enough about it that mkfs races them.
|
||||
func waitForPartitions(cfg DiskConfig) error {
|
||||
deadline := time.Now().Add(partitionSettleTimeout)
|
||||
for _, d := range cfg.bootDisks() {
|
||||
wanted := []string{d.PartitionPath(espPartNum), d.PartitionPath(rootPartNum)}
|
||||
// The pool is built from by-id paths, so their symlinks have to exist
|
||||
// too — udev publishes them later than the kernel device node.
|
||||
if d.ByID != "" {
|
||||
wanted = append(wanted, d.StablePartitionPath(rootPartNum))
|
||||
}
|
||||
for _, part := range wanted {
|
||||
if err := waitForPath(part, deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rm := range cfg.DataMounts() {
|
||||
if err := waitForPath(rm.Disk.PartitionPath(dataPartNum), deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForPath(path string, deadline time.Time) error {
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("device %s did not appear within timeout — the kernel or udev did not pick up the new partition table", path)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// formatESPs makes a FAT32 filesystem on every disk's ESP. Each one is
|
||||
// independent — there is no RAID here, they are kept in step by content.
|
||||
func formatESPs(cfg DiskConfig) error {
|
||||
for _, d := range cfg.bootDisks() {
|
||||
esp := d.PartitionPath(espPartNum)
|
||||
// A distinct label per member so `lsblk -f` on a degraded machine tells
|
||||
// the operator which disk each ESP belongs to.
|
||||
if err := run("mkfs.fat", "-F", "32", "-n", "EFI", esp); err != nil {
|
||||
return fmt.Errorf("format ESP %s: %w", esp, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pool and dataset names. rpool matches Proxmox, which is what every ZFS
|
||||
// recovery guide and forum answer assumes — the name an operator will be
|
||||
// reading off a serial console at 3am.
|
||||
const (
|
||||
ZFSPoolName = "rpool"
|
||||
ZFSRootDataset = ZFSPoolName + "/ROOT/spinifex"
|
||||
)
|
||||
|
||||
// FSType selects the root filesystem and, for ZFS, the vdev topology.
|
||||
type FSType string
|
||||
|
||||
const (
|
||||
FSExt4 FSType = "ext4"
|
||||
FSZFSRAID0 FSType = "zfs-raid0"
|
||||
FSZFSRAID1 FSType = "zfs-raid1"
|
||||
FSZFSRAID10 FSType = "zfs-raid10"
|
||||
FSZFSRAIDZ1 FSType = "zfs-raidz1"
|
||||
FSZFSRAIDZ2 FSType = "zfs-raidz2"
|
||||
FSZFSRAIDZ3 FSType = "zfs-raidz3"
|
||||
)
|
||||
|
||||
// AllFSTypes is the selector order presented by the UI. ext4 is first and is
|
||||
// the default, so ZFS — which holds the kernel at its shipped version and
|
||||
// reserves RAM for the ARC — is always an explicit choice.
|
||||
var AllFSTypes = []FSType{
|
||||
FSExt4, FSZFSRAID0, FSZFSRAID1, FSZFSRAID10, FSZFSRAIDZ1, FSZFSRAIDZ2, FSZFSRAIDZ3,
|
||||
}
|
||||
|
||||
// fsMeta carries the per-topology facts that drive display and validation.
|
||||
var fsMeta = map[FSType]struct {
|
||||
label string
|
||||
minDisks int
|
||||
redundant bool // subject to the same-size rule
|
||||
vdev string // zpool create keyword; empty means a plain stripe
|
||||
}{
|
||||
FSExt4: {"ext4", 1, false, ""},
|
||||
FSZFSRAID0: {"zfs (RAID0)", 1, false, ""},
|
||||
FSZFSRAID1: {"zfs (RAID1)", 2, true, "mirror"},
|
||||
FSZFSRAID10: {"zfs (RAID10)", 4, true, "mirror"},
|
||||
FSZFSRAIDZ1: {"zfs (RAIDZ-1)", 3, true, "raidz1"},
|
||||
FSZFSRAIDZ2: {"zfs (RAIDZ-2)", 4, true, "raidz2"},
|
||||
FSZFSRAIDZ3: {"zfs (RAIDZ-3)", 5, true, "raidz3"},
|
||||
}
|
||||
|
||||
// IsZFS reports whether this mode creates a pool rather than a plain filesystem.
|
||||
func (f FSType) IsZFS() bool { return strings.HasPrefix(string(f), "zfs-") }
|
||||
|
||||
// Label is the display name, matching proxinstall's wording so the screen is
|
||||
// recognisable to anyone who has installed Proxmox.
|
||||
func (f FSType) Label() string { return fsMeta[f].label }
|
||||
|
||||
// MinDisks is the smallest member count the topology can be built from.
|
||||
func (f FSType) MinDisks() int { return fsMeta[f].minDisks }
|
||||
|
||||
// Redundant reports whether the topology stores parity or mirrors, and so
|
||||
// requires members of matching size. A RAID0 stripe over unequal disks is
|
||||
// legitimate and is deliberately not covered.
|
||||
func (f FSType) Redundant() bool { return fsMeta[f].redundant }
|
||||
|
||||
// vdevKeyword is the zpool create keyword introducing each vdev.
|
||||
func (f FSType) vdevKeyword() string { return fsMeta[f].vdev }
|
||||
|
||||
// ParseFSType maps a SPINIFEX_FS value to an FSType.
|
||||
func ParseFSType(s string) (FSType, error) {
|
||||
f := FSType(strings.ToLower(strings.TrimSpace(s)))
|
||||
if _, ok := fsMeta[f]; !ok {
|
||||
names := make([]string, 0, len(AllFSTypes))
|
||||
for _, t := range AllFSTypes {
|
||||
names = append(names, string(t))
|
||||
}
|
||||
return "", fmt.Errorf("unknown filesystem %q — must be one of: %s", s, strings.Join(names, ", "))
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// DiskRole is the purpose a selected drive serves on an ext4 install. A ZFS
|
||||
// pool carries every workload on one set of members, so roles do not apply
|
||||
// there — the topology already spans all the disks.
|
||||
type DiskRole string
|
||||
|
||||
const (
|
||||
RoleOS DiskRole = "os"
|
||||
RoleSpinifex DiskRole = "spinifex"
|
||||
RolePredastore DiskRole = "predastore"
|
||||
)
|
||||
|
||||
// AllDiskRoles is canonical order, outermost mountpoint first. Mounting, fstab
|
||||
// emission and rsync protect filters all walk it forwards; teardown walks it
|
||||
// back, because predastore nests inside spinifex.
|
||||
var AllDiskRoles = []DiskRole{RoleOS, RoleSpinifex, RolePredastore}
|
||||
|
||||
// roleMountpoints is where each role lands on the installed system. A role left
|
||||
// unassigned simply stays on the filesystem above it.
|
||||
var roleMountpoints = map[DiskRole]string{
|
||||
RoleOS: "/",
|
||||
RoleSpinifex: "/var/lib/spinifex",
|
||||
RolePredastore: "/var/lib/spinifex/predastore",
|
||||
}
|
||||
|
||||
// Label is the role name as shown in the UI and written to partition labels.
|
||||
func (r DiskRole) Label() string { return string(r) }
|
||||
|
||||
// Mountpoint is the role's path on the installed system.
|
||||
func (r DiskRole) Mountpoint() string { return roleMountpoints[r] }
|
||||
|
||||
// RoleMount binds one drive to one role.
|
||||
type RoleMount struct {
|
||||
Role DiskRole
|
||||
Disk Disk
|
||||
}
|
||||
|
||||
// Mountpoint is where this drive's filesystem is mounted on the target.
|
||||
func (r RoleMount) Mountpoint() string { return r.Role.Mountpoint() }
|
||||
|
||||
// ZFSOpts are the advanced tunables. A zero value means "use the computed
|
||||
// default", which is why Ashift and ARCMaxMiB are plain ints — 0 is not legal
|
||||
// for either, so it is unambiguous as a sentinel.
|
||||
type ZFSOpts struct {
|
||||
Ashift int
|
||||
Compress string
|
||||
Checksum string
|
||||
Copies int
|
||||
ARCMaxMiB int
|
||||
}
|
||||
|
||||
// DiskConfig is the storage half of the installer's configuration. Disks is
|
||||
// ordered and the order is significant: RAID10 pairs members two at a time, and
|
||||
// on ext4 the first entry is the OS drive.
|
||||
type DiskConfig struct {
|
||||
FS FSType
|
||||
Disks []Disk
|
||||
ZFS ZFSOpts
|
||||
|
||||
// Roles assigns ext4 drives to mountpoints, in AllDiskRoles order. Empty
|
||||
// means a single-disk install with everything on the root filesystem.
|
||||
Roles []RoleMount
|
||||
}
|
||||
|
||||
// WithRoles returns a copy with ext4 drive roles assigned and Disks rebuilt
|
||||
// from them in canonical order, so the list of drives to erase and the list of
|
||||
// roles cannot drift apart — there is one place a drive can be named, not two.
|
||||
func (d DiskConfig) WithRoles(roles []RoleMount) DiskConfig {
|
||||
d.Roles, d.Disks = nil, nil
|
||||
add := func(rm RoleMount) {
|
||||
d.Roles = append(d.Roles, rm)
|
||||
d.Disks = append(d.Disks, rm.Disk)
|
||||
}
|
||||
for _, want := range AllDiskRoles {
|
||||
for _, rm := range roles {
|
||||
if rm.Role == want {
|
||||
add(rm)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Anything with an unknown or duplicate role is kept so Validate can name
|
||||
// it, rather than being dropped into a silently smaller selection.
|
||||
for _, rm := range roles {
|
||||
if _, known := roleMountpoints[rm.Role]; !known {
|
||||
add(rm)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// RolesForDisks assigns roles positionally, which is the default the UI offers:
|
||||
// first disk picked is the OS, second is spinifex, third is predastore.
|
||||
func RolesForDisks(disks []Disk) []RoleMount {
|
||||
out := make([]RoleMount, 0, len(disks))
|
||||
for i, disk := range disks {
|
||||
var role DiskRole
|
||||
if i < len(AllDiskRoles) {
|
||||
role = AllDiskRoles[i]
|
||||
}
|
||||
out = append(out, RoleMount{Role: role, Disk: disk})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DataMounts is every role except the OS drive, shallowest mountpoint first.
|
||||
// This is the traversal every stage of the install shares.
|
||||
func (d DiskConfig) DataMounts() []RoleMount {
|
||||
var out []RoleMount
|
||||
for _, rm := range d.Roles {
|
||||
if rm.Role != RoleOS {
|
||||
out = append(out, rm)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bootDisks are the drives a bootloader is installed to. Every ZFS member gets
|
||||
// its own ESP; on ext4 only the OS drive does, because a data drive holding a
|
||||
// bootloader that nothing points at is a trap during recovery.
|
||||
func (d DiskConfig) bootDisks() []Disk {
|
||||
if d.FS.IsZFS() || len(d.Disks) == 0 {
|
||||
return d.Disks
|
||||
}
|
||||
return d.Disks[:1]
|
||||
}
|
||||
|
||||
// Primary is the first selected disk — the OS drive in ext4 mode, and the
|
||||
// disk shown in single-disk summaries.
|
||||
func (d DiskConfig) Primary() Disk {
|
||||
if len(d.Disks) == 0 {
|
||||
return Disk{}
|
||||
}
|
||||
return d.Disks[0]
|
||||
}
|
||||
|
||||
// Paths lists the selected device paths, for logs and confirmation screens.
|
||||
func (d DiskConfig) Paths() []string {
|
||||
out := make([]string, len(d.Disks))
|
||||
for i, disk := range d.Disks {
|
||||
out[i] = disk.Path
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Buildable reports whether the selection has enough members, of the right
|
||||
// count, to form the topology at all. Capacity and fault tolerance are
|
||||
// meaningless until it does — a 3-disk RAIDZ-3 has no geometry to describe.
|
||||
func (d DiskConfig) Buildable() bool {
|
||||
if _, ok := fsMeta[d.FS]; !ok {
|
||||
return false
|
||||
}
|
||||
if len(d.Disks) < d.FS.MinDisks() {
|
||||
return false
|
||||
}
|
||||
return d.FS != FSZFSRAID10 || len(d.Disks)%2 == 0
|
||||
}
|
||||
|
||||
// Requirement describes what the topology still needs, for the preview line
|
||||
// shown in place of a capacity that cannot be computed yet.
|
||||
func (d DiskConfig) Requirement() string {
|
||||
req := fmt.Sprintf("needs %d disks", d.FS.MinDisks())
|
||||
if d.FS == FSZFSRAID10 {
|
||||
req = "needs an even number of disks, at least 4"
|
||||
}
|
||||
if d.FS.Redundant() {
|
||||
req += " of the same size"
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// UsableBytes estimates post-parity capacity, for the geometry preview. It is
|
||||
// an operator-facing approximation and deliberately ignores metadata overhead.
|
||||
// Returns 0 for a selection that cannot form the topology, since the parity
|
||||
// arithmetic below goes to zero and then negative as members run out.
|
||||
func (d DiskConfig) UsableBytes() int64 {
|
||||
n := int64(len(d.Disks))
|
||||
if n == 0 || !d.Buildable() {
|
||||
return 0
|
||||
}
|
||||
smallest := d.Disks[0].Bytes
|
||||
for _, disk := range d.Disks {
|
||||
smallest = min(smallest, disk.Bytes)
|
||||
}
|
||||
switch d.FS {
|
||||
case FSExt4, FSZFSRAID0:
|
||||
var total int64
|
||||
for _, disk := range d.Disks {
|
||||
total += disk.Bytes
|
||||
}
|
||||
return total
|
||||
case FSZFSRAID1:
|
||||
return smallest
|
||||
case FSZFSRAID10:
|
||||
return smallest * (n / 2)
|
||||
case FSZFSRAIDZ1:
|
||||
return smallest * (n - 1)
|
||||
case FSZFSRAIDZ2:
|
||||
return smallest * (n - 2)
|
||||
case FSZFSRAIDZ3:
|
||||
return smallest * (n - 3)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Tolerated returns the number of simultaneous disk failures the topology
|
||||
// survives, for the geometry preview.
|
||||
func (d DiskConfig) Tolerated() int {
|
||||
if !d.Buildable() {
|
||||
return 0
|
||||
}
|
||||
switch d.FS {
|
||||
case FSZFSRAID1:
|
||||
return max(len(d.Disks)-1, 0)
|
||||
case FSZFSRAID10:
|
||||
return 1 // one per mirror pair; the honest worst case is one
|
||||
case FSZFSRAIDZ1:
|
||||
return 1
|
||||
case FSZFSRAIDZ2:
|
||||
return 2
|
||||
case FSZFSRAIDZ3:
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sizeTolerance is the fraction by which pool members may differ in size.
|
||||
// Matches Proxmox's zfs_mirror_size_check: anything beyond 10% and the pool
|
||||
// silently truncates every member to the smallest.
|
||||
const sizeTolerance = 0.10
|
||||
|
||||
// bootedEFI reports whether firmware booted this machine in EFI mode. GRUB's
|
||||
// i386-pc target cannot read a 4Kn drive, so legacy BIOS constrains selection.
|
||||
func bootedEFI() bool {
|
||||
_, err := os.Stat("/sys/firmware/efi")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Validate checks a disk selection against every rule that must hold before any
|
||||
// destructive command runs. The UI calls it on each keystroke so failures block
|
||||
// Continue, rather than aborting an install that has already repartitioned.
|
||||
func (d DiskConfig) Validate() error {
|
||||
if _, ok := fsMeta[d.FS]; !ok {
|
||||
return fmt.Errorf("no filesystem selected")
|
||||
}
|
||||
if len(d.Disks) == 0 {
|
||||
return fmt.Errorf("%s: select at least one disk", d.FS.Label())
|
||||
}
|
||||
if err := d.validateRoles(); err != nil {
|
||||
return err
|
||||
}
|
||||
if n := len(d.Disks); n < d.FS.MinDisks() {
|
||||
return fmt.Errorf("%s: needs at least %d disks, %d selected", d.FS.Label(), d.FS.MinDisks(), n)
|
||||
}
|
||||
if d.FS == FSZFSRAID10 && len(d.Disks)%2 != 0 {
|
||||
return fmt.Errorf("%s: needs an even number of disks, %d selected", d.FS.Label(), len(d.Disks))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, disk := range d.Disks {
|
||||
if seen[disk.Path] {
|
||||
return fmt.Errorf("%s is selected more than once", disk.Path)
|
||||
}
|
||||
seen[disk.Path] = true
|
||||
|
||||
if disk.LiveMedia {
|
||||
return fmt.Errorf("%s is the installer's own boot media and cannot be erased", disk.Path)
|
||||
}
|
||||
if disk.Bytes < minRootBytes {
|
||||
return fmt.Errorf("%s is too small (%s, need at least %dGiB)",
|
||||
disk.Path, disk.SizeHuman(), minRootBytes>>30)
|
||||
}
|
||||
}
|
||||
|
||||
// GRUB's i386-pc target cannot read a 4Kn drive, so on legacy BIOS a drive
|
||||
// holding a bootloader would leave the installed system unreachable. Only
|
||||
// boot drives are constrained: a data role is never booted from.
|
||||
for _, disk := range d.bootDisks() {
|
||||
if !bootedEFI() && disk.LogicalBlockSize == 4096 {
|
||||
return fmt.Errorf("%s is a 4Kn drive (4096-byte sectors) and this machine booted in legacy BIOS mode — booting from it is not supported; enable UEFI in firmware",
|
||||
disk.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return d.validateSizes()
|
||||
}
|
||||
|
||||
// validateRoles checks the ext4 role assignment. Roles are the only way to use
|
||||
// more than one disk without ZFS, so a multi-disk selection without them is
|
||||
// rejected rather than quietly installed onto the first drive.
|
||||
func (d DiskConfig) validateRoles() error {
|
||||
if d.FS.IsZFS() {
|
||||
if len(d.Roles) > 0 {
|
||||
return fmt.Errorf("%s: drive roles apply to ext4 only — a pool spans every member already", d.FS.Label())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(d.Roles) == 0 {
|
||||
if len(d.Disks) > 1 {
|
||||
return fmt.Errorf("%s: %d disks selected but no roles assigned — assign os, spinifex and predastore, or choose a zfs mode",
|
||||
d.FS.Label(), len(d.Disks))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(d.Roles) > len(AllDiskRoles) {
|
||||
return fmt.Errorf("%s: at most %d disks, %d selected — the roles are os, spinifex and predastore",
|
||||
d.FS.Label(), len(AllDiskRoles), len(d.Roles))
|
||||
}
|
||||
|
||||
seen := map[DiskRole]bool{}
|
||||
for _, rm := range d.Roles {
|
||||
if _, ok := roleMountpoints[rm.Role]; !ok {
|
||||
return fmt.Errorf("%s has no role assigned — every selected disk needs one", rm.Disk.Path)
|
||||
}
|
||||
if seen[rm.Role] {
|
||||
return fmt.Errorf("two disks are assigned the %s role", rm.Role)
|
||||
}
|
||||
seen[rm.Role] = true
|
||||
}
|
||||
if !seen[RoleOS] {
|
||||
return fmt.Errorf("%s: no disk assigned the os role — one disk must hold the operating system", d.FS.Label())
|
||||
}
|
||||
if len(d.Roles) != len(d.Disks) {
|
||||
return fmt.Errorf("internal: %d roles for %d disks", len(d.Roles), len(d.Disks))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// minRootBytes matches Proxmox's hard floor for a root disk.
|
||||
const minRootBytes = 2 << 30
|
||||
|
||||
// validateSizes enforces the same-size rule where the topology needs it:
|
||||
// across all members for mirrors and raidz, and per-pair for RAID10 — a
|
||||
// striped mirror only requires each pair to match, not every disk in the pool.
|
||||
func (d DiskConfig) validateSizes() error {
|
||||
if !d.FS.Redundant() {
|
||||
return nil
|
||||
}
|
||||
if d.FS == FSZFSRAID10 {
|
||||
for i := 0; i+1 < len(d.Disks); i += 2 {
|
||||
if err := sizesMatch(d.Disks[i], d.Disks[i+1]); err != nil {
|
||||
return fmt.Errorf("%s mirror pair %d: %w", d.FS.Label(), i/2+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, disk := range d.Disks[1:] {
|
||||
if err := sizesMatch(d.Disks[0], disk); err != nil {
|
||||
return fmt.Errorf("%s: %w", d.FS.Label(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SizesWithinTolerance reports whether two disks could be members of the same
|
||||
// redundant pool. The UI uses it to decide whether suggesting ZFS is honest.
|
||||
func SizesWithinTolerance(a, b Disk) bool { return sizesMatch(a, b) == nil }
|
||||
|
||||
// sizesMatch reports whether two members are within the tolerance, naming both
|
||||
// disks and their sizes — that message is the operator's only diagnostic.
|
||||
func sizesMatch(a, b Disk) error {
|
||||
if math.Abs(float64(a.Bytes-b.Bytes)) > float64(a.Bytes)*sizeTolerance {
|
||||
return fmt.Errorf("disks must be the same size within %.0f%%: %s is %s, %s is %s",
|
||||
sizeTolerance*100, a.Path, a.SizeHuman(), b.Path, b.SizeHuman())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Warnings returns non-blocking advice for a valid selection. A RAID0 stripe
|
||||
// over mismatched disks is legal but almost always a mistake, so it is
|
||||
// surfaced without preventing the install — which is what Proxmox does.
|
||||
func (d DiskConfig) Warnings() []string {
|
||||
var out []string
|
||||
if d.FS == FSZFSRAID0 && len(d.Disks) > 1 {
|
||||
for _, disk := range d.Disks[1:] {
|
||||
if sizesMatch(d.Disks[0], disk) != nil {
|
||||
out = append(out, "striping disks of different sizes — capacity is usable but a failure of any disk loses the pool")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.FS.IsZFS() && d.FS != FSZFSRAID0 && d.Buildable() && d.Tolerated() == 0 {
|
||||
out = append(out, "this topology stores no redundancy")
|
||||
}
|
||||
// A three-drive role layout looks like an array and is not one: each drive
|
||||
// is an independent point of failure, so the node loses whatever sat on it.
|
||||
if len(d.DataMounts()) > 0 {
|
||||
out = append(out, "role-assigned drives store no redundancy — losing any one drive loses what it held")
|
||||
}
|
||||
for _, rm := range d.DataMounts() {
|
||||
if rm.Role == RolePredastore && rm.Disk.Bytes < smallPredastoreBytes {
|
||||
out = append(out, fmt.Sprintf("%s is small for object storage (%s) — predastore will fill quickly",
|
||||
rm.Disk.Path, rm.Disk.SizeHuman()))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// smallPredastoreBytes is where a dedicated object-store drive stops being
|
||||
// worth the drive. Advisory only: the install is legal below it.
|
||||
const smallPredastoreBytes = 64 << 30
|
||||
@@ -0,0 +1,267 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiskConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg DiskConfig
|
||||
wantErr string
|
||||
}{
|
||||
{"ext4 on one disk", DiskConfig{FS: FSExt4, Disks: disks(1, 40)}, ""},
|
||||
{"raidz1 on three matching disks", DiskConfig{FS: FSZFSRAIDZ1, Disks: disks(3, 40)}, ""},
|
||||
{"raid10 on four matching disks", DiskConfig{FS: FSZFSRAID10, Disks: disks(4, 40)}, ""},
|
||||
// The single-drive ZFS case: no redundancy, but checksums, compression
|
||||
// and snapshots still apply.
|
||||
{"raid0 on one disk", DiskConfig{FS: FSZFSRAID0, Disks: disks(1, 40)}, ""},
|
||||
|
||||
{"no filesystem chosen", DiskConfig{Disks: disks(1, 40)}, "no filesystem selected"},
|
||||
{"no disks", DiskConfig{FS: FSZFSRAIDZ1}, "select at least one disk"},
|
||||
{"ext4 spans disks only with roles", DiskConfig{FS: FSExt4, Disks: disks(2, 40)}, "no roles assigned"},
|
||||
{"raidz1 needs three", DiskConfig{FS: FSZFSRAIDZ1, Disks: disks(2, 40)}, "at least 3 disks"},
|
||||
{"raidz2 needs four", DiskConfig{FS: FSZFSRAIDZ2, Disks: disks(3, 40)}, "at least 4 disks"},
|
||||
{"raidz3 needs five", DiskConfig{FS: FSZFSRAIDZ3, Disks: disks(4, 40)}, "at least 5 disks"},
|
||||
{"raid1 needs two", DiskConfig{FS: FSZFSRAID1, Disks: disks(1, 40)}, "at least 2 disks"},
|
||||
{"raid10 rejects an odd count", DiskConfig{FS: FSZFSRAID10, Disks: disks(5, 40)}, "even number"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
switch {
|
||||
case tt.wantErr == "" && err != nil:
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
case tt.wantErr != "" && err == nil:
|
||||
t.Fatalf("Validate() = nil, want error containing %q", tt.wantErr)
|
||||
case tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr):
|
||||
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsDuplicateDisk(t *testing.T) {
|
||||
d := disk("a", 40)
|
||||
err := DiskConfig{FS: FSZFSRAID1, Disks: []Disk{d, d}}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "more than once") {
|
||||
t.Fatalf("Validate() = %v, want a duplicate-selection error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsLiveMedia(t *testing.T) {
|
||||
d := disk("a", 40)
|
||||
d.LiveMedia = true
|
||||
err := DiskConfig{FS: FSExt4, Disks: []Disk{d}}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "boot media") {
|
||||
t.Fatalf("Validate() = %v, want the installer's own media to be refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsTooSmallDisk(t *testing.T) {
|
||||
err := DiskConfig{FS: FSExt4, Disks: []Disk{disk("a", 1)}}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "too small") {
|
||||
t.Fatalf("Validate() = %v, want a minimum-size error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A pool truncates every member to the smallest, so mismatched disks in a
|
||||
// redundant topology silently throw capacity away.
|
||||
func TestValidateSizeTolerance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fs FSType
|
||||
sizes []int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"identical disks", FSZFSRAIDZ1, []int64{40, 40, 40}, false},
|
||||
{"within 10%", FSZFSRAIDZ1, []int64{100, 95, 92}, false},
|
||||
{"beyond 10%", FSZFSRAIDZ1, []int64{100, 100, 80}, true},
|
||||
{"mirror with a mismatched member", FSZFSRAID1, []int64{100, 50}, true},
|
||||
// A stripe has no parity to waste, so mismatched sizes are a warning
|
||||
// rather than a refusal — matching Proxmox.
|
||||
{"raid0 permits mismatched disks", FSZFSRAID0, []int64{100, 40}, false},
|
||||
{"raid10 checks each pair, not the whole pool", FSZFSRAID10, []int64{40, 40, 100, 100}, false},
|
||||
{"raid10 rejects a mismatched pair", FSZFSRAID10, []int64{40, 100, 100, 100}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
members := make([]Disk, len(tt.sizes))
|
||||
for i, gib := range tt.sizes {
|
||||
members[i] = disk(string(rune('a'+i)), gib)
|
||||
}
|
||||
err := DiskConfig{FS: tt.fs, Disks: members}.Validate()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("Validate() = nil, want a size-mismatch error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningsFlagMismatchedStripe(t *testing.T) {
|
||||
cfg := DiskConfig{FS: FSZFSRAID0, Disks: []Disk{disk("a", 100), disk("b", 40)}}
|
||||
warnings := cfg.Warnings()
|
||||
if len(warnings) == 0 {
|
||||
t.Fatal("a stripe over mismatched disks should warn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsableBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fs FSType
|
||||
n int
|
||||
wantGiB int64
|
||||
}{
|
||||
{"stripe adds every disk", FSZFSRAID0, 3, 120},
|
||||
{"mirror gives one disk", FSZFSRAID1, 2, 40},
|
||||
{"raid10 gives half", FSZFSRAID10, 4, 80},
|
||||
{"raidz1 loses one to parity", FSZFSRAIDZ1, 3, 80},
|
||||
{"raidz2 loses two", FSZFSRAIDZ2, 4, 80},
|
||||
{"raidz3 loses three", FSZFSRAIDZ3, 5, 80},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DiskConfig{FS: tt.fs, Disks: disks(tt.n, 40)}
|
||||
if got := cfg.UsableBytes() >> 30; got != tt.wantGiB {
|
||||
t.Errorf("UsableBytes() = %dG, want %dG", got, tt.wantGiB)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The parity arithmetic goes to zero and then negative as members run out, and
|
||||
// a preview reading "-40GiB usable" is worse than no figure at all.
|
||||
func TestUsableBytesIsZeroWhenTopologyCannotBeBuilt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fs FSType
|
||||
n int
|
||||
}{
|
||||
{"raidz3 with exactly its parity count", FSZFSRAIDZ3, 3},
|
||||
{"raidz3 with fewer disks than parity", FSZFSRAIDZ3, 2},
|
||||
{"raidz2 with two disks", FSZFSRAIDZ2, 2},
|
||||
{"raidz1 with one disk", FSZFSRAIDZ1, 1},
|
||||
{"mirror with one disk", FSZFSRAID1, 1},
|
||||
{"raid10 with an odd count", FSZFSRAID10, 3},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DiskConfig{FS: tt.fs, Disks: disks(tt.n, 40)}
|
||||
if cfg.Buildable() {
|
||||
t.Fatalf("Buildable() = true for %s across %d disks", tt.fs, tt.n)
|
||||
}
|
||||
if got := cfg.UsableBytes(); got != 0 {
|
||||
t.Errorf("UsableBytes() = %d, want 0", got)
|
||||
}
|
||||
if got := cfg.Tolerated(); got != 0 {
|
||||
t.Errorf("Tolerated() = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A selection that cannot be built must not also be described as unredundant:
|
||||
// the operator's problem is the disk count, not the topology.
|
||||
func TestWarningsStaySilentUntilBuildable(t *testing.T) {
|
||||
cfg := DiskConfig{FS: FSZFSRAIDZ3, Disks: disks(3, 40)}
|
||||
if got := cfg.Warnings(); len(got) != 0 {
|
||||
t.Errorf("Warnings() = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirementNamesTheSameSizeRule(t *testing.T) {
|
||||
if got := (DiskConfig{FS: FSZFSRAIDZ2}).Requirement(); !strings.Contains(got, "4 disks") ||
|
||||
!strings.Contains(got, "same size") {
|
||||
t.Errorf("Requirement() = %q, want the disk count and the same-size rule", got)
|
||||
}
|
||||
if got := (DiskConfig{FS: FSZFSRAID10}).Requirement(); !strings.Contains(got, "even") {
|
||||
t.Errorf("Requirement() = %q, want the even-count rule", got)
|
||||
}
|
||||
// A stripe has no matching requirement, so claiming one would be wrong.
|
||||
if got := (DiskConfig{FS: FSZFSRAID0}).Requirement(); strings.Contains(got, "same size") {
|
||||
t.Errorf("Requirement() = %q, want no same-size rule for a stripe", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTolerated(t *testing.T) {
|
||||
tests := []struct {
|
||||
fs FSType
|
||||
n int
|
||||
want int
|
||||
}{
|
||||
{FSExt4, 1, 0},
|
||||
{FSZFSRAID0, 3, 0},
|
||||
{FSZFSRAID1, 2, 1},
|
||||
{FSZFSRAID1, 3, 2},
|
||||
{FSZFSRAID10, 4, 1},
|
||||
{FSZFSRAIDZ1, 3, 1},
|
||||
{FSZFSRAIDZ2, 4, 2},
|
||||
{FSZFSRAIDZ3, 5, 3},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.fs), func(t *testing.T) {
|
||||
cfg := DiskConfig{FS: tt.fs, Disks: disks(tt.n, 40)}
|
||||
if got := cfg.Tolerated(); got != tt.want {
|
||||
t.Errorf("Tolerated() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFSType(t *testing.T) {
|
||||
for _, fs := range AllFSTypes {
|
||||
got, err := ParseFSType(strings.ToUpper(string(fs)))
|
||||
if err != nil {
|
||||
t.Errorf("ParseFSType(%q): %v", fs, err)
|
||||
continue
|
||||
}
|
||||
if got != fs {
|
||||
t.Errorf("ParseFSType(%q) = %q", fs, got)
|
||||
}
|
||||
}
|
||||
// The error has to list the valid values: it is read off a serial console
|
||||
// by someone whose unattended install just stopped.
|
||||
_, err := ParseFSType("btrfs")
|
||||
if err == nil || !strings.Contains(err.Error(), "zfs-raidz1") {
|
||||
t.Fatalf("ParseFSType(btrfs) = %v, want an error listing the accepted values", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionPathSeparator(t *testing.T) {
|
||||
tests := []struct{ dev, want string }{
|
||||
{"/dev/sda", "/dev/sda3"},
|
||||
// A trailing digit needs a 'p' or the name is ambiguous.
|
||||
{"/dev/nvme0n1", "/dev/nvme0n1p3"},
|
||||
{"/dev/vda", "/dev/vda3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := (Disk{Path: tt.dev}).PartitionPath(3); got != tt.want {
|
||||
t.Errorf("PartitionPath(%s) = %s, want %s", tt.dev, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// udev names partition links with a -partN suffix regardless of the kernel's
|
||||
// separator, so the by-id form must not reuse the 'p' rule.
|
||||
func TestStablePartitionPathUsesPartSuffix(t *testing.T) {
|
||||
d := Disk{Path: "/dev/nvme0n1", ByID: "/dev/disk/by-id/nvme-SAMSUNG_1234"}
|
||||
if got := d.StablePartitionPath(3); got != "/dev/disk/by-id/nvme-SAMSUNG_1234-part3" {
|
||||
t.Errorf("StablePartitionPath() = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestESPSizeMiB(t *testing.T) {
|
||||
if got := espSizeMiB(disk("a", 40)); got != espSmallMiB {
|
||||
t.Errorf("espSizeMiB(40G) = %d, want %d", got, espSmallMiB)
|
||||
}
|
||||
if got := espSizeMiB(disk("a", 500)); got != espLargeMiB {
|
||||
t.Errorf("espSizeMiB(500G) = %d, want %d", got, espLargeMiB)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ func TestToFirstbootConfigIncludesInstallCallback(t *testing.T) {
|
||||
const url = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
t.Setenv("SPINIFEX_INSTALL_CALLBACK", url)
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
cfg := &Config{Hostname: "node1"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != url {
|
||||
t.Errorf("InstallCallback = %q, want %q", fb.InstallCallback, url)
|
||||
@@ -19,7 +19,7 @@ func TestToFirstbootConfigIncludesInstallCallback(t *testing.T) {
|
||||
func TestToFirstbootConfigEmptyCallbackWhenEnvUnset(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_INSTALL_CALLBACK")
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
cfg := &Config{Hostname: "node1"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != "" {
|
||||
t.Errorf("InstallCallback = %q, want empty", fb.InstallCallback)
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// roleCfg builds an ext4 configuration from role/disk-name pairs, in whatever
|
||||
// order they are given — WithRoles is responsible for normalising them.
|
||||
func roleCfg(pairs ...any) DiskConfig {
|
||||
var roles []RoleMount
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
roles = append(roles, RoleMount{
|
||||
Role: pairs[i].(DiskRole),
|
||||
Disk: disk(pairs[i+1].(string), 100),
|
||||
})
|
||||
}
|
||||
return DiskConfig{FS: FSExt4}.WithRoles(roles)
|
||||
}
|
||||
|
||||
// fakeUUIDs makes partUUID return a value derived from the device path, so a
|
||||
// generated fstab can be checked without touching a real disk.
|
||||
func fakeUUIDs(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := partUUID
|
||||
partUUID = func(dev string) (string, error) { return "uuid" + strings.ReplaceAll(dev, "/", "-"), nil }
|
||||
t.Cleanup(func() { partUUID = prev })
|
||||
}
|
||||
|
||||
func TestWithRolesNormalisesOrderAndDerivesDisks(t *testing.T) {
|
||||
// Given deepest-first, which is the order that breaks mounting.
|
||||
cfg := roleCfg(RolePredastore, "c", RoleSpinifex, "b", RoleOS, "a")
|
||||
|
||||
wantRoles := []DiskRole{RoleOS, RoleSpinifex, RolePredastore}
|
||||
for i, rm := range cfg.Roles {
|
||||
if rm.Role != wantRoles[i] {
|
||||
t.Fatalf("role %d = %s, want %s", i, rm.Role, wantRoles[i])
|
||||
}
|
||||
}
|
||||
// Disks is derived, so the OS drive is first and every role drive appears
|
||||
// exactly once in the list the installer erases.
|
||||
if got := cfg.Paths(); !slices.Equal(got, []string{"/dev/a", "/dev/b", "/dev/c"}) {
|
||||
t.Fatalf("Paths() = %v, want the roles in canonical order", got)
|
||||
}
|
||||
if cfg.Primary().Path != "/dev/a" {
|
||||
t.Errorf("Primary() = %s, want the os drive", cfg.Primary().Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataMountsExcludeTheOSDriveAndNestShallowestFirst(t *testing.T) {
|
||||
got := roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c").DataMounts()
|
||||
want := []string{"/var/lib/spinifex", "/var/lib/spinifex/predastore"}
|
||||
for i, rm := range got {
|
||||
if rm.Mountpoint() != want[i] {
|
||||
t.Fatalf("mount %d = %s, want %s", i, rm.Mountpoint(), want[i])
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d data mounts, want %d", len(got), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// A predastore drive without a spinifex drive is the two-drive layout, and it
|
||||
// nests a mount inside the root filesystem rather than inside another mount.
|
||||
func TestPredastoreRoleWithoutSpinifexIsValid(t *testing.T) {
|
||||
cfg := roleCfg(RoleOS, "a", RolePredastore, "b")
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
}
|
||||
if got := cfg.DataMounts(); len(got) != 1 || got[0].Mountpoint() != "/var/lib/spinifex/predastore" {
|
||||
t.Fatalf("DataMounts() = %v, want predastore only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoles(t *testing.T) {
|
||||
twoRoles := func(a, b DiskRole) DiskConfig { return roleCfg(a, "a", b, "b") }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg DiskConfig
|
||||
wantErr string
|
||||
}{
|
||||
{"single disk needs no roles", DiskConfig{FS: FSExt4, Disks: disks(1, 40)}, ""},
|
||||
{"os alone", roleCfg(RoleOS, "a"), ""},
|
||||
{"all three", roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c"), ""},
|
||||
{"no os role", twoRoles(RoleSpinifex, RolePredastore), "no disk assigned the os role"},
|
||||
{"duplicate role", twoRoles(RoleOS, RoleOS), "two disks are assigned the os role"},
|
||||
{"unknown role", roleCfg(RoleOS, "a", DiskRole("cache"), "b"), "no role assigned"},
|
||||
{"more disks than roles", roleCfg(
|
||||
RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c", DiskRole("cache"), "d"), "at most 3 disks"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
switch {
|
||||
case tt.wantErr == "" && err != nil:
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)):
|
||||
t.Fatalf("Validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Roles describe where a workload lives on a plain filesystem. A pool already
|
||||
// spans every member, so accepting them there would silently do nothing.
|
||||
func TestValidateRejectsRolesOnZFS(t *testing.T) {
|
||||
cfg := DiskConfig{FS: FSZFSRAID1, Disks: disks(2, 40)}
|
||||
cfg.Roles = []RoleMount{{Role: RoleOS, Disk: cfg.Disks[0]}}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "ext4 only") {
|
||||
t.Fatalf("Validate() = %v, want a roles-are-ext4-only error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootDisksAreOnlyTheOSDriveOnExt4(t *testing.T) {
|
||||
cfg := roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c")
|
||||
if got := cfg.bootDisks(); len(got) != 1 || got[0].Path != "/dev/a" {
|
||||
t.Fatalf("bootDisks() = %v, want the os drive only", got)
|
||||
}
|
||||
// Every ZFS member carries its own ESP, so all of them stay boot disks.
|
||||
zfs := DiskConfig{FS: FSZFSRAID1, Disks: disks(2, 40)}
|
||||
if got := zfs.bootDisks(); len(got) != 2 {
|
||||
t.Fatalf("bootDisks() = %v, want every pool member", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFstabEmitsRoleMountsShallowestFirst(t *testing.T) {
|
||||
root := withMountRoot(t)
|
||||
targetSkeleton(t, root)
|
||||
fakeUUIDs(t)
|
||||
|
||||
cfg := roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c")
|
||||
if err := writeFstab(cfg); err != nil {
|
||||
t.Fatalf("writeFstab: %v", err)
|
||||
}
|
||||
got := readFile(t, root, "etc/fstab")
|
||||
|
||||
// Order matters: `mount -a` in a rescue shell walks the file top-down, and
|
||||
// predastore cannot be mounted before the filesystem it sits inside.
|
||||
wantOrder := []string{" / ", "/boot/efi", " /var/lib/spinifex ", " /var/lib/spinifex/predastore "}
|
||||
at := -1
|
||||
for _, want := range wantOrder {
|
||||
i := strings.Index(got, want)
|
||||
if i < 0 {
|
||||
t.Fatalf("fstab is missing %q:\n%s", want, got)
|
||||
}
|
||||
if i < at {
|
||||
t.Fatalf("%q is out of order:\n%s", want, got)
|
||||
}
|
||||
at = i
|
||||
}
|
||||
|
||||
for _, dev := range []string{"/dev/b1", "/dev/c1"} {
|
||||
if want := "UUID=uuid-dev-" + strings.TrimPrefix(dev, "/dev/"); !strings.Contains(got, want) {
|
||||
t.Errorf("fstab does not mount %s by UUID:\n%s", dev, got)
|
||||
}
|
||||
}
|
||||
// Data mounts are fsck'd after root, never alongside it.
|
||||
for line := range strings.SplitSeq(got, "\n") {
|
||||
if strings.Contains(line, "/var/lib/spinifex") && !strings.HasSuffix(line, "0 2") {
|
||||
t.Errorf("data mount should be fsck pass 2: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed by design: a missing data drive must stop the boot for an admin,
|
||||
// not let predastore write objects onto the OS drive while everything reports
|
||||
// healthy. nofail would silently produce exactly that.
|
||||
func TestWriteFstabNeverMarksRoleMountsNofail(t *testing.T) {
|
||||
root := withMountRoot(t)
|
||||
targetSkeleton(t, root)
|
||||
fakeUUIDs(t)
|
||||
|
||||
if err := writeFstab(roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c")); err != nil {
|
||||
t.Fatalf("writeFstab: %v", err)
|
||||
}
|
||||
if got := readFile(t, root, "etc/fstab"); strings.Contains(got, "nofail") {
|
||||
t.Errorf("role mounts must not be nofail:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFstabSingleDiskIsUnchanged(t *testing.T) {
|
||||
root := withMountRoot(t)
|
||||
targetSkeleton(t, root)
|
||||
fakeUUIDs(t)
|
||||
|
||||
if err := writeFstab(roleCfg(RoleOS, "a")); err != nil {
|
||||
t.Fatalf("writeFstab: %v", err)
|
||||
}
|
||||
if got := readFile(t, root, "etc/fstab"); strings.Contains(got, "/var/lib/spinifex") {
|
||||
t.Errorf("a single-disk install should mount nothing extra:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// rsync runs with --delete, so every mountpoint that exists before the copy
|
||||
// needs protecting or the copy can delete across a mount boundary on a retry.
|
||||
func TestMountProtectFiltersCoverEveryRoleMount(t *testing.T) {
|
||||
got := mountProtectFilters(roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c"))
|
||||
want := []string{"--filter=P /var/lib/spinifex", "--filter=P /var/lib/spinifex/predastore"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("mountProtectFilters() = %v, want %v", got, want)
|
||||
}
|
||||
if got := mountProtectFilters(roleCfg(RoleOS, "a")); len(got) != 0 {
|
||||
t.Errorf("a single-disk install has no mounts to protect, got %v", got)
|
||||
}
|
||||
// ZFS keeps its dataset filters — the union is by mode, not additive.
|
||||
if got := mountProtectFilters(DiskConfig{FS: FSZFSRAID1, Disks: disks(2, 40)}); len(got) == 0 {
|
||||
t.Error("zfs mode should still protect its dataset mountpoints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountPartitionsMountsRolesAfterRootAndBeforeTheCopy(t *testing.T) {
|
||||
f := fakeCommands(t)
|
||||
root := withMountRoot(t)
|
||||
|
||||
cfg := roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c")
|
||||
if err := mountPartitions(cfg); err != nil {
|
||||
t.Fatalf("mountPartitions: %v", err)
|
||||
}
|
||||
|
||||
// The nested mount cannot precede the one it sits inside, or it is masked.
|
||||
want := []string{
|
||||
"mount /dev/a3 " + root,
|
||||
"mount /dev/a2 " + root + "/boot/efi",
|
||||
"mount /dev/b1 " + root + "/var/lib/spinifex",
|
||||
"mount /dev/c1 " + root + "/var/lib/spinifex/predastore",
|
||||
}
|
||||
var mounts []string
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "mount ") {
|
||||
mounts = append(mounts, c)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(mounts, want) {
|
||||
t.Fatalf("mount order:\n got %v\nwant %v", mounts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupTargetUnmountsDeepestFirst(t *testing.T) {
|
||||
f := fakeCommands(t)
|
||||
root := withMountRoot(t)
|
||||
|
||||
cleanupTarget(roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c"))
|
||||
|
||||
// Unmounting the outer filesystem first fails with EBUSY and leaves a
|
||||
// half-mounted target behind for the next attempt.
|
||||
want := []string{
|
||||
"umount " + root + "/var/lib/spinifex/predastore",
|
||||
"umount " + root + "/var/lib/spinifex",
|
||||
"umount " + root + "/boot/efi",
|
||||
"umount " + root,
|
||||
}
|
||||
// The chroot binds are released first and are not part of the target order.
|
||||
var got []string
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "umount ") && slices.Contains(want, c) {
|
||||
got = append(got, c)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("unmount order:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionDisksGivesDataDrivesOnePartitionAndNoESP(t *testing.T) {
|
||||
f := fakeCommands(t)
|
||||
nodes := tempDisks(t, 2, 200)
|
||||
cfg := DiskConfig{FS: FSExt4}.WithRoles(
|
||||
[]RoleMount{{Role: RoleOS, Disk: nodes[0]}, {Role: RoleSpinifex, Disk: nodes[1]}})
|
||||
osDisk, data := nodes[0].Path, nodes[1].Path
|
||||
|
||||
if err := partitionDisks(cfg); err != nil {
|
||||
t.Fatalf("partitionDisks: %v", err)
|
||||
}
|
||||
|
||||
f.mustRun(t, "sgdisk", osDisk, "-n 3:0:-1024M", "-t 3:8300")
|
||||
f.mustRun(t, "sgdisk", osDisk, "-t 2:EF00")
|
||||
// A data drive is never booted from, so a bios_boot or an ESP there would
|
||||
// only mislead whoever reads lsblk during an incident.
|
||||
for _, c := range f.calls {
|
||||
if strings.Contains(c, data) && (strings.Contains(c, "EF02") || strings.Contains(c, "EF00")) {
|
||||
t.Errorf("data drive must get no bios_boot or ESP: %q", c)
|
||||
}
|
||||
}
|
||||
f.mustRun(t, "sgdisk", data, "-n 1:1M:-1024M", "-t 1:8300", "-c 1:spinifex")
|
||||
}
|
||||
|
||||
func TestFormatPartitionsLabelsEachRoleFilesystem(t *testing.T) {
|
||||
f := fakeCommands(t)
|
||||
cfg := roleCfg(RoleOS, "a", RoleSpinifex, "b", RolePredastore, "c")
|
||||
|
||||
if err := formatPartitions(cfg); err != nil {
|
||||
t.Fatalf("formatPartitions: %v", err)
|
||||
}
|
||||
// One ESP only: a data drive holding a bootloader nothing points at would
|
||||
// mislead whoever reads lsblk during an incident.
|
||||
esps := 0
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "mkfs.fat") {
|
||||
esps++
|
||||
}
|
||||
}
|
||||
if esps != 1 {
|
||||
t.Errorf("want one ESP, got %d:\n%v", esps, f.calls)
|
||||
}
|
||||
f.mustRun(t, "mkfs.ext4", "-L", "spinifex", "/dev/b1")
|
||||
f.mustRun(t, "mkfs.ext4", "-L", "predastore", "/dev/c1")
|
||||
}
|
||||
|
||||
func TestWarningsFlagTheAbsenceOfRedundancy(t *testing.T) {
|
||||
got := strings.Join(roleCfg(RoleOS, "a", RoleSpinifex, "b").Warnings(), "\n")
|
||||
if !strings.Contains(got, "no redundancy") {
|
||||
t.Errorf("a multi-drive role layout must warn it is not an array, got %q", got)
|
||||
}
|
||||
if got := roleCfg(RoleOS, "a").Warnings(); len(got) != 0 {
|
||||
t.Errorf("a single-disk install has nothing to warn about, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningsFlagASmallPredastoreDrive(t *testing.T) {
|
||||
cfg := DiskConfig{FS: FSExt4}.WithRoles([]RoleMount{
|
||||
{Role: RoleOS, Disk: disk("a", 500)},
|
||||
{Role: RolePredastore, Disk: disk("b", 8)},
|
||||
})
|
||||
got := strings.Join(cfg.Warnings(), "\n")
|
||||
if !strings.Contains(got, "small for object storage") {
|
||||
t.Errorf("an 8GiB predastore drive should be flagged, got %q", got)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("the warning must not block the install: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesForDisksAssignsPositionally(t *testing.T) {
|
||||
got := RolesForDisks(disks(3, 40))
|
||||
for i, rm := range got {
|
||||
if want := AllDiskRoles[i]; rm.Role != want {
|
||||
t.Errorf("disk %d got role %s, want %s", i, rm.Role, want)
|
||||
}
|
||||
}
|
||||
// A fourth disk has no role, which Validate is left to reject by name.
|
||||
if extra := RolesForDisks(disks(4, 40)); extra[3].Role != "" {
|
||||
t.Errorf("disk 4 got role %q, want none", extra[3].Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFourKnDataDriveIsAllowedWhereABootDriveIsNot(t *testing.T) {
|
||||
if bootedEFI() {
|
||||
t.Skip("machine booted in EFI mode; the 4Kn rule does not apply")
|
||||
}
|
||||
fourKn := func(name string) Disk {
|
||||
d := disk(name, 100)
|
||||
d.LogicalBlockSize = 4096
|
||||
return d
|
||||
}
|
||||
// GRUB's i386-pc target cannot read a 4Kn drive, but nothing boots from a
|
||||
// data drive, so only the OS role is constrained.
|
||||
ok := DiskConfig{FS: FSExt4}.WithRoles(
|
||||
[]RoleMount{{Role: RoleOS, Disk: disk("a", 100)}, {Role: RoleSpinifex, Disk: fourKn("b")}})
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Errorf("a 4Kn data drive should be accepted: %v", err)
|
||||
}
|
||||
|
||||
bad := DiskConfig{FS: FSExt4}.WithRoles(
|
||||
[]RoleMount{{Role: RoleOS, Disk: fourKn("a")}, {Role: RoleSpinifex, Disk: disk("b", 100)}})
|
||||
if err := bad.Validate(); err == nil || !strings.Contains(err.Error(), "4Kn") {
|
||||
t.Errorf("Validate() = %v, want a 4Kn rejection for the os drive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRoleLayoutCatchesAMountThatNeverHappened(t *testing.T) {
|
||||
root := withMountRoot(t)
|
||||
// Plain directories on one filesystem: exactly what a mount performed after
|
||||
// the rootfs copy, or not at all, leaves behind.
|
||||
if err := os.MkdirAll(filepath.Join(root, "var/lib/spinifex/predastore"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := verifyRoleLayout(roleCfg(RoleOS, "a", RoleSpinifex, "b"))
|
||||
if err == nil || !strings.Contains(err.Error(), "not a separate filesystem") {
|
||||
t.Fatalf("verifyRoleLayout() = %v, want a missing-mount error", err)
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(err), "/dev/b") {
|
||||
t.Errorf("the error must name the drive that was not mounted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/bits"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ARC bounds. Spinifex RAM is guest RAM: Proxmox defaults non-PVE products to
|
||||
// half of system memory, which on a 256GB compute node would remove 128GB of
|
||||
// schedulable instance capacity. 10% capped at 16GiB is their PVE behaviour and
|
||||
// the right trade for a hypervisor.
|
||||
const (
|
||||
arcMinMiB = 64
|
||||
arcMaxCapMiB = 16 * 1024
|
||||
arcFraction = 0.10
|
||||
arcHeadroomMi = 1024 // always leave the OS a GiB above the ARC ceiling
|
||||
)
|
||||
|
||||
// zfsDataset describes one dataset created at install time. Order matters:
|
||||
// parents must precede children so mountpoints nest correctly.
|
||||
type zfsDataset struct {
|
||||
name string
|
||||
mountpoint string
|
||||
props []string
|
||||
}
|
||||
|
||||
// datasets is the layout described in the plan. Recordsizes are matched to each
|
||||
// service's access pattern; 16K on the predastore index keeps RAIDZ-1 parity
|
||||
// overhead at 33% rather than the ~50% a 4-8K record would cost.
|
||||
var datasets = []zfsDataset{
|
||||
{ZFSPoolName + "/ROOT", "none", []string{"canmount=off"}},
|
||||
{ZFSRootDataset, "/", []string{"acltype=posix"}},
|
||||
{ZFSPoolName + "/data", "/var/lib/spinifex", []string{"canmount=off"}},
|
||||
{ZFSPoolName + "/data/viperblock", "/var/lib/spinifex/viperblock",
|
||||
[]string{"recordsize=128K", "logbias=throughput"}},
|
||||
{ZFSPoolName + "/data/predastore", "/var/lib/spinifex/predastore",
|
||||
[]string{"recordsize=1M"}},
|
||||
// Broken out so the later move of object data onto dedicated drives is a
|
||||
// zfs send | zfs recv rather than an rsync with downtime.
|
||||
{ZFSPoolName + "/data/predastore-nodes", "/var/lib/spinifex/predastore/distributed/nodes", nil},
|
||||
// BadgerDB index plus the Raft BoltDB: small random, fsync-heavy.
|
||||
{ZFSPoolName + "/data/predastore-db", "/var/lib/spinifex/predastore/distributed/db",
|
||||
[]string{"recordsize=16K", "logbias=latency"}},
|
||||
{ZFSPoolName + "/data/nats", "/var/lib/spinifex/nats", []string{"recordsize=16K"}},
|
||||
{ZFSPoolName + "/log", "/var/log/spinifex", []string{"compression=zstd"}},
|
||||
}
|
||||
|
||||
// buildVdev renders the vdev specification for zpool create. Pure: it is the
|
||||
// function that decides pool shape, so it is exhaustively unit-tested rather
|
||||
// than discovered to be wrong on hardware.
|
||||
func buildVdev(cfg DiskConfig) (string, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !cfg.FS.IsZFS() {
|
||||
return "", fmt.Errorf("buildVdev: %s is not a zfs topology", cfg.FS)
|
||||
}
|
||||
|
||||
// Members are named by their partition-3 by-id path: a pool built on
|
||||
// /dev/sdX breaks the first time a controller enumerates in a new order.
|
||||
members := make([]string, len(cfg.Disks))
|
||||
for i, d := range cfg.Disks {
|
||||
members[i] = d.StablePartitionPath(rootPartNum)
|
||||
}
|
||||
|
||||
keyword := cfg.FS.vdevKeyword()
|
||||
if keyword == "" {
|
||||
return strings.Join(members, " "), nil // plain stripe
|
||||
}
|
||||
// RAID10 is a stripe of two-disk mirrors, paired in selection order — which
|
||||
// is why the UI numbers selected disks instead of just ticking them.
|
||||
if cfg.FS == FSZFSRAID10 {
|
||||
var parts []string
|
||||
for i := 0; i+1 < len(members); i += 2 {
|
||||
parts = append(parts, fmt.Sprintf("mirror %s %s", members[i], members[i+1]))
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
return keyword + " " + strings.Join(members, " "), nil
|
||||
}
|
||||
|
||||
// detectAshift derives the pool sector size from the widest sector any member
|
||||
// reports. ashift cannot be changed after creation, and one that is too small
|
||||
// costs read-modify-write on every block for the life of the pool, so the floor
|
||||
// is 12 (4K) even when every disk claims 512.
|
||||
func detectAshift(disks []Disk) int {
|
||||
widest := 4096
|
||||
for _, d := range disks {
|
||||
widest = max(widest, d.PhysicalBlockSize, d.LogicalBlockSize)
|
||||
}
|
||||
// ashift is the base-2 exponent of the sector size.
|
||||
return bits.Len(uint(widest)) - 1
|
||||
}
|
||||
|
||||
// defaultARCMaxMiB computes the ARC ceiling from total system memory.
|
||||
func defaultARCMaxMiB(totalMiB int) int {
|
||||
want := int(math.Round(float64(totalMiB) * arcFraction))
|
||||
want = min(want, arcMaxCapMiB)
|
||||
// Never let the ARC crowd the OS out of its last GiB on a small node.
|
||||
if ceiling := totalMiB - arcHeadroomMi; ceiling > 0 {
|
||||
want = min(want, ceiling)
|
||||
}
|
||||
return max(want, arcMinMiB)
|
||||
}
|
||||
|
||||
// totalMemoryMiB reads MemTotal from /proc/meminfo.
|
||||
func totalMemoryMiB() int {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
if !strings.HasPrefix(line, "MemTotal:") {
|
||||
continue
|
||||
}
|
||||
var kb int
|
||||
if _, err := fmt.Sscanf(line, "MemTotal: %d kB", &kb); err == nil {
|
||||
return kb / 1024
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveZFSOpts fills unset advanced options with their computed defaults, so
|
||||
// every value written to the pool is explicit and auditable afterwards.
|
||||
func resolveZFSOpts(cfg DiskConfig) ZFSOpts {
|
||||
o := cfg.ZFS
|
||||
if o.Ashift == 0 {
|
||||
o.Ashift = detectAshift(cfg.Disks)
|
||||
}
|
||||
if o.Compress == "" {
|
||||
// Named explicitly rather than "on" so a future OpenZFS default change
|
||||
// cannot silently alter what this pool does.
|
||||
o.Compress = "lz4"
|
||||
}
|
||||
if o.Checksum == "" {
|
||||
o.Checksum = "on"
|
||||
}
|
||||
if o.Copies == 0 {
|
||||
o.Copies = 1
|
||||
}
|
||||
if o.ARCMaxMiB == 0 {
|
||||
o.ARCMaxMiB = defaultARCMaxMiB(totalMemoryMiB())
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// loadZFSModule brings up the ZFS kernel module in the live environment and
|
||||
// waits for /dev/zfs. The module is prebuilt against the shipped kernel at ISO
|
||||
// build time, so failure here means the ISO is broken, not the hardware.
|
||||
func loadZFSModule() error {
|
||||
if err := run("modprobe", "zfs"); err != nil {
|
||||
slog.Warn("modprobe zfs failed, checking for /dev/zfs anyway", "err", err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat("/dev/zfs"); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("/dev/zfs did not appear after modprobe zfs — the ZFS module is missing or does not match the running kernel")
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ImportablePools returns the names of pools visible to `zpool import`. There
|
||||
// is still no machine-readable output in OpenZFS 2.3, so the human format is
|
||||
// parsed — the same approach Proxmox::Sys::ZFS takes.
|
||||
func ImportablePools() ([]string, error) {
|
||||
out, err := exec.Command("zpool", "import").Output()
|
||||
if err != nil {
|
||||
// zpool exits non-zero when there is nothing to import, which is the
|
||||
// normal case. Anything else — no binary, no /dev/zfs — must surface, or
|
||||
// a machine with an existing pool looks empty to the caller.
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("zpool import: %w", err)
|
||||
}
|
||||
return parseImportablePools(string(out)), nil
|
||||
}
|
||||
|
||||
// parseImportablePools extracts pool names from `zpool import` output. Split
|
||||
// out from the command call so it can be tested against captured fixtures.
|
||||
func parseImportablePools(out string) []string {
|
||||
var pools []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Only the "pool:" key introduces a pool; "id:", "state:" and the
|
||||
// config tree that follows are attributes of the one before it.
|
||||
if name, ok := strings.CutPrefix(line, "pool:"); ok {
|
||||
if n := strings.TrimSpace(name); n != "" {
|
||||
pools = append(pools, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pools
|
||||
}
|
||||
|
||||
// createPool builds the root pool across the selected disks. altroot keeps
|
||||
// every dataset mounted under the install target instead of over the live
|
||||
// filesystem, and cachefile=none stops the live environment from claiming it.
|
||||
func createPool(cfg DiskConfig, opts ZFSOpts) error {
|
||||
vdev, err := buildVdev(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"create", "-f",
|
||||
"-o", "cachefile=none",
|
||||
"-o", fmt.Sprintf("ashift=%d", opts.Ashift),
|
||||
"-O", "atime=on", "-O", "relatime=on",
|
||||
"-O", "xattr=sa",
|
||||
"-O", fmt.Sprintf("compression=%s", opts.Compress),
|
||||
"-O", fmt.Sprintf("checksum=%s", opts.Checksum),
|
||||
"-O", fmt.Sprintf("copies=%d", opts.Copies),
|
||||
"-O", "mountpoint=none",
|
||||
"-R", mountRoot,
|
||||
ZFSPoolName,
|
||||
}
|
||||
args = append(args, strings.Fields(vdev)...)
|
||||
if err := run("zpool", args...); err != nil {
|
||||
return fmt.Errorf("zpool create: %w", err)
|
||||
}
|
||||
|
||||
// TRIM matters on a pool that churns EBS volumes: without it the drives'
|
||||
// garbage collection degrades write latency over months, and diagnosing
|
||||
// that after the fact is miserable. Detected, never prompted.
|
||||
if allFlash(cfg.Disks) {
|
||||
if err := run("zpool", "set", "autotrim=on", ZFSPoolName); err != nil {
|
||||
slog.Warn("could not enable autotrim", "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allFlash reports whether every member is non-rotational.
|
||||
func allFlash(disks []Disk) bool {
|
||||
for _, d := range disks {
|
||||
if d.Rotational {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(disks) > 0
|
||||
}
|
||||
|
||||
// createDatasets lays out the service datasets. Mountpoints are set relative to
|
||||
// the pool's altroot, so they land under the install target now and at their
|
||||
// real paths once the installed system imports the pool.
|
||||
func createDatasets() error {
|
||||
for _, ds := range datasets {
|
||||
args := []string{"create", "-o", "mountpoint=" + ds.mountpoint}
|
||||
for _, p := range ds.props {
|
||||
args = append(args, "-o", p)
|
||||
}
|
||||
args = append(args, ds.name)
|
||||
if err := run("zfs", args...); err != nil {
|
||||
return fmt.Errorf("create dataset %s: %w", ds.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// datasetProtectFilters keeps rsync --delete away from the dataset mountpoints.
|
||||
//
|
||||
// The datasets are mounted before the copy so files land on the right one, but
|
||||
// the live source has nothing at those paths, so --delete tries to rmdir a busy
|
||||
// mountpoint and fails the whole transfer. Protect rules exempt them.
|
||||
func datasetProtectFilters(cfg DiskConfig) []string {
|
||||
if !cfg.FS.IsZFS() {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ds := range datasets {
|
||||
if ds.mountpoint == "/" || ds.mountpoint == "none" {
|
||||
continue
|
||||
}
|
||||
out = append(out, "--filter=P "+ds.mountpoint)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeZFSSystemConfig writes the target's ZFS tunables: the ARC ceiling, and
|
||||
// the matching host reserve so the daemon's admission gate stops handing out
|
||||
// memory the ARC is holding.
|
||||
func writeZFSSystemConfig(opts ZFSOpts) error {
|
||||
modprobeDir := filepath.Join(mountRoot, "etc/modprobe.d")
|
||||
if err := os.MkdirAll(modprobeDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
arcBytes := int64(opts.ARCMaxMiB) << 20
|
||||
conf := fmt.Sprintf("# Generated by the Spinifex installer.\n"+
|
||||
"# Guest RAM is the scarce resource on a hypervisor, so the ARC is\n"+
|
||||
"# capped well below the ZFS default of half of system memory.\n"+
|
||||
"options zfs zfs_arc_max=%d\n", arcBytes)
|
||||
if err := os.WriteFile(filepath.Join(modprobeDir, "zfs.conf"), []byte(conf), 0o644); err != nil {
|
||||
return fmt.Errorf("write zfs.conf: %w", err)
|
||||
}
|
||||
|
||||
// The daemon computes schedulable memory as host total minus this reserve
|
||||
// (defaultHostReserve in daemon/resource.go). ARC is invisible to it, so
|
||||
// the ceiling has to be declared or instances get admitted into memory the
|
||||
// ARC already owns.
|
||||
reserve := defaultHostReserveGB + float64(opts.ARCMaxMiB)/1024.0
|
||||
env := fmt.Sprintf("# Generated by the Spinifex installer.\n"+
|
||||
"# %.1fGB base reserve + %.1fGB ZFS ARC ceiling.\n"+
|
||||
"SPINIFEX_RESERVED_MEM_GB=%.1f\n",
|
||||
defaultHostReserveGB, float64(opts.ARCMaxMiB)/1024.0, reserve)
|
||||
confDir := filepath.Join(mountRoot, "etc/spinifex")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(confDir, "host.env"), []byte(env), 0o644)
|
||||
}
|
||||
|
||||
// defaultHostReserveGB mirrors defaultHostReserve.memGB in
|
||||
// spinifex/daemon/resource.go. Changing one without the other silently
|
||||
// misreports how much memory a ZFS node can schedule.
|
||||
const defaultHostReserveGB = 2.0
|
||||
|
||||
// exportPool releases the pool so a retry after a failed install is not blocked
|
||||
// by "pool is busy". The live environment runs spinifex-init as PID 1, so
|
||||
// nothing else will ever clean this up.
|
||||
func exportPool() {
|
||||
if err := run("zpool", "export", ZFSPoolName); err != nil {
|
||||
slog.Warn("zpool export failed", "pool", ZFSPoolName, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearDisk takes a disk over unconditionally, whatever it holds: everything
|
||||
// attached to it is released, every signature on it is wiped, its partition
|
||||
// table is erased, and the kernel is made to agree the disk is now empty.
|
||||
//
|
||||
// The last part is the point. sgdisk writes to the platters and exits 0 even
|
||||
// when the kernel refuses to drop the old table, so without the assertion an
|
||||
// install proceeds against partition nodes describing the previous layout.
|
||||
//
|
||||
// The probes are quiet because a blank disk makes all of them fail, and their
|
||||
// stderr on the install console reads like the install is going wrong.
|
||||
func clearDisk(d Disk) error {
|
||||
releaseDisk(d)
|
||||
|
||||
// Every partition, not just the root slice. ZFS labels live at both ends of
|
||||
// a device and wipefs does not know about them, so a data drive that was
|
||||
// once a pool member keeps its label and fails a later zpool create.
|
||||
parts, err := kernelPartitions(d.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range parts {
|
||||
dev := partitionDevice(d.Path, p)
|
||||
_ = runQuiet("zpool", "labelclear", "-f", dev)
|
||||
_ = runQuiet("wipefs", "-a", dev)
|
||||
}
|
||||
|
||||
_ = runQuiet("wipefs", "-a", d.Path)
|
||||
if err := runQuiet("sgdisk", "-Z", d.Path); err != nil {
|
||||
return fmt.Errorf("erase partition table: %w", err)
|
||||
}
|
||||
return settlePartitions(d)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// disk builds a test disk with a by-id path, since that is what pool members
|
||||
// are actually named by.
|
||||
func disk(name string, gib int64) Disk {
|
||||
return Disk{
|
||||
Path: "/dev/" + name,
|
||||
ByID: "/dev/disk/by-id/virtio-" + name,
|
||||
Bytes: gib << 30,
|
||||
LogicalBlockSize: 512,
|
||||
PhysicalBlockSize: 512,
|
||||
}
|
||||
}
|
||||
|
||||
func disks(n int, gib int64) []Disk {
|
||||
out := make([]Disk, n)
|
||||
for i := range out {
|
||||
out[i] = disk(string(rune('a'+i)), gib)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildVdev(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fs FSType
|
||||
disks []Disk
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"raid0 is a bare stripe with no keyword",
|
||||
FSZFSRAID0, disks(2, 40),
|
||||
"/dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3",
|
||||
},
|
||||
{
|
||||
"single-disk raid0 is the one-drive case",
|
||||
FSZFSRAID0, disks(1, 40),
|
||||
"/dev/disk/by-id/virtio-a-part3",
|
||||
},
|
||||
{
|
||||
"raid1 is one mirror vdev",
|
||||
FSZFSRAID1, disks(2, 40),
|
||||
"mirror /dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3",
|
||||
},
|
||||
{
|
||||
"raid1 across three disks is a three-way mirror, not a stripe",
|
||||
FSZFSRAID1, disks(3, 40),
|
||||
"mirror /dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3 /dev/disk/by-id/virtio-c-part3",
|
||||
},
|
||||
{
|
||||
"raid10 pairs members in selection order",
|
||||
FSZFSRAID10, disks(4, 40),
|
||||
"mirror /dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3 " +
|
||||
"mirror /dev/disk/by-id/virtio-c-part3 /dev/disk/by-id/virtio-d-part3",
|
||||
},
|
||||
{
|
||||
"raidz1 across three disks",
|
||||
FSZFSRAIDZ1, disks(3, 40),
|
||||
"raidz1 /dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3 /dev/disk/by-id/virtio-c-part3",
|
||||
},
|
||||
{
|
||||
"raidz2 needs four",
|
||||
FSZFSRAIDZ2, disks(4, 40),
|
||||
"raidz2 /dev/disk/by-id/virtio-a-part3 /dev/disk/by-id/virtio-b-part3 " +
|
||||
"/dev/disk/by-id/virtio-c-part3 /dev/disk/by-id/virtio-d-part3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildVdev(DiskConfig{FS: tt.fs, Disks: tt.disks})
|
||||
if err != nil {
|
||||
t.Fatalf("buildVdev: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("buildVdev()\n got: %s\nwant: %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pool built on /dev/sdX silently breaks when the controller enumerates in a
|
||||
// different order, so the fallback only applies when there is no by-id path.
|
||||
func TestBuildVdevFallsBackToKernelNameWithoutByID(t *testing.T) {
|
||||
d := disk("a", 40)
|
||||
d.ByID = ""
|
||||
got, err := buildVdev(DiskConfig{FS: FSZFSRAID0, Disks: []Disk{d}})
|
||||
if err != nil {
|
||||
t.Fatalf("buildVdev: %v", err)
|
||||
}
|
||||
if got != "/dev/a3" {
|
||||
t.Errorf("buildVdev() = %q, want /dev/a3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVdevRejectsExt4(t *testing.T) {
|
||||
if _, err := buildVdev(DiskConfig{FS: FSExt4, Disks: disks(1, 40)}); err == nil {
|
||||
t.Fatal("buildVdev accepted ext4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVdevValidatesFirst(t *testing.T) {
|
||||
// Three disks cannot form striped mirrors, and producing a two-disk mirror
|
||||
// plus a silently dropped disk would be worse than an error.
|
||||
if _, err := buildVdev(DiskConfig{FS: FSZFSRAID10, Disks: disks(3, 40)}); err == nil {
|
||||
t.Fatal("buildVdev accepted an odd disk count for RAID10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAshift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
logical int
|
||||
physical int
|
||||
want int
|
||||
}{
|
||||
// ashift cannot be changed after creation and one that is too small
|
||||
// costs a read-modify-write per block forever, so 512e drives are
|
||||
// deliberately treated as 4K.
|
||||
{"512n drive is floored at 4K", 512, 512, 12},
|
||||
{"512e drive reports 4K physical", 512, 4096, 12},
|
||||
{"4Kn drive", 4096, 4096, 12},
|
||||
{"8K native drive", 8192, 8192, 13},
|
||||
{"16K native drive", 16384, 16384, 14},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := Disk{LogicalBlockSize: tt.logical, PhysicalBlockSize: tt.physical}
|
||||
if got := detectAshift([]Disk{d}); got != tt.want {
|
||||
t.Errorf("detectAshift() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pool cannot have two sector sizes, so the widest member wins — the reverse
|
||||
// would make every write to the large-sector disk a read-modify-write.
|
||||
func TestDetectAshiftTakesWidestMember(t *testing.T) {
|
||||
got := detectAshift([]Disk{
|
||||
{LogicalBlockSize: 512, PhysicalBlockSize: 512},
|
||||
{LogicalBlockSize: 512, PhysicalBlockSize: 8192},
|
||||
})
|
||||
if got != 13 {
|
||||
t.Errorf("detectAshift() = %d, want 13", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultARCMaxMiB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
totalMi int
|
||||
want int
|
||||
}{
|
||||
{"tiny VM gets 10% of very little", 2048, 205},
|
||||
{"8GB node gets 10%", 8192, 819},
|
||||
{"64GB node gets 10%", 65536, 6554},
|
||||
// The point of the cap: guest RAM is what this machine sells, and half
|
||||
// of a 256GB node is 128GB of instance capacity the ARC would swallow.
|
||||
{"256GB node is capped at 16GiB", 262144, 16384},
|
||||
{"1TB node is still capped at 16GiB", 1048576, 16384},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := defaultARCMaxMiB(tt.totalMi); got != tt.want {
|
||||
t.Errorf("defaultARCMaxMiB(%d) = %d, want %d", tt.totalMi, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable /proc/meminfo must not produce a zero or negative ceiling,
|
||||
// which ZFS would either reject or read as "no limit".
|
||||
func TestDefaultARCMaxMiBNeverGoesBelowFloor(t *testing.T) {
|
||||
for _, total := range []int{0, 128, 512} {
|
||||
if got := defaultARCMaxMiB(total); got < arcMinMiB {
|
||||
t.Errorf("defaultARCMaxMiB(%d) = %d, below the floor of %d", total, got, arcMinMiB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZFSOptsFillsDefaults(t *testing.T) {
|
||||
got := resolveZFSOpts(DiskConfig{FS: FSZFSRAIDZ1, Disks: disks(3, 40)})
|
||||
if got.Ashift != 12 {
|
||||
t.Errorf("Ashift = %d, want 12", got.Ashift)
|
||||
}
|
||||
if got.Compress != "lz4" {
|
||||
t.Errorf("Compress = %q, want lz4", got.Compress)
|
||||
}
|
||||
if got.Checksum != "on" {
|
||||
t.Errorf("Checksum = %q, want on", got.Checksum)
|
||||
}
|
||||
if got.Copies != 1 {
|
||||
t.Errorf("Copies = %d, want 1", got.Copies)
|
||||
}
|
||||
if got.ARCMaxMiB <= 0 {
|
||||
t.Errorf("ARCMaxMiB = %d, want a positive ceiling", got.ARCMaxMiB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveZFSOptsKeepsOperatorChoices(t *testing.T) {
|
||||
in := DiskConfig{
|
||||
FS: FSZFSRAIDZ1,
|
||||
Disks: disks(3, 40),
|
||||
ZFS: ZFSOpts{Ashift: 13, Compress: "zstd", Checksum: "blake3", Copies: 2, ARCMaxMiB: 2048},
|
||||
}
|
||||
got := resolveZFSOpts(in)
|
||||
if got != in.ZFS {
|
||||
t.Errorf("resolveZFSOpts overrode explicit options: got %+v, want %+v", got, in.ZFS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportablePools(t *testing.T) {
|
||||
// Captured from `zpool import` on a machine with a pool from a previous
|
||||
// install: the config tree below each header must not be mistaken for one.
|
||||
const out = ` pool: rpool
|
||||
id: 8543219876543210987
|
||||
state: ONLINE
|
||||
action: The pool can be imported using its name or numeric identifier.
|
||||
config:
|
||||
|
||||
rpool ONLINE
|
||||
raidz1-0 ONLINE
|
||||
virtio-spinifex-disk1-part3 ONLINE
|
||||
virtio-spinifex-disk2-part3 ONLINE
|
||||
|
||||
pool: tank
|
||||
id: 1111111111111111111
|
||||
state: ONLINE
|
||||
config:
|
||||
|
||||
tank ONLINE
|
||||
`
|
||||
got := parseImportablePools(out)
|
||||
want := []string{"rpool", "tank"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseImportablePools() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("pool %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportablePoolsEmpty(t *testing.T) {
|
||||
if got := parseImportablePools("no pools available to import\n"); len(got) != 0 {
|
||||
t.Errorf("parseImportablePools() = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every dataset must be creatable in order: a child cannot be created before
|
||||
// its parent, and getting this wrong only shows up on real hardware.
|
||||
func TestDatasetsAreOrderedParentFirst(t *testing.T) {
|
||||
seen := map[string]bool{ZFSPoolName: true}
|
||||
for _, ds := range datasets {
|
||||
parent := ds.name[:strings.LastIndex(ds.name, "/")]
|
||||
if !seen[parent] {
|
||||
t.Errorf("dataset %s is created before its parent %s", ds.name, parent)
|
||||
}
|
||||
seen[ds.name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// The root dataset has to be the one the bootloader names, or the machine
|
||||
// mounts an empty filesystem and panics.
|
||||
func TestRootDatasetIsCreated(t *testing.T) {
|
||||
for _, ds := range datasets {
|
||||
if ds.name == ZFSRootDataset {
|
||||
if ds.mountpoint != "/" {
|
||||
t.Fatalf("%s mountpoint = %q, want /", ZFSRootDataset, ds.mountpoint)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is never created", ZFSRootDataset)
|
||||
}
|
||||
|
||||
// rsync --delete tries to rmdir a dataset mountpoint the live source does not
|
||||
// have, which fails on a busy mount and aborts the whole copy. Every dataset
|
||||
// with a real mountpoint must be protected.
|
||||
func TestDatasetProtectFilters(t *testing.T) {
|
||||
got := datasetProtectFilters(DiskConfig{FS: FSZFSRAIDZ1, Disks: disks(3, 40)})
|
||||
for _, ds := range datasets {
|
||||
if ds.mountpoint == "/" || ds.mountpoint == "none" {
|
||||
continue
|
||||
}
|
||||
want := "--filter=P " + ds.mountpoint
|
||||
if !slices.Contains(got, want) {
|
||||
t.Errorf("missing protect rule for %s (%s)", ds.name, ds.mountpoint)
|
||||
}
|
||||
}
|
||||
// The root dataset is the destination itself, and "none" datasets are never
|
||||
// mounted, so protecting either would be meaningless.
|
||||
for _, f := range got {
|
||||
if f == "--filter=P /" || f == "--filter=P none" {
|
||||
t.Errorf("unexpected protect rule %q", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetProtectFiltersSkipsExt4(t *testing.T) {
|
||||
if got := datasetProtectFilters(DiskConfig{FS: FSExt4, Disks: disks(1, 40)}); got != nil {
|
||||
t.Errorf("datasetProtectFilters() = %v on ext4, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Package netprobe identifies physical network interfaces by hardware rather
|
||||
// than kernel name, so an operator choosing between eno1, ens1f0np0 and
|
||||
// ens1f1np1 can tell the 1gbe Broadcom from the 25gbe Mellanox.
|
||||
package netprobe
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sysClassNet is the kernel's per-interface attribute tree. A variable, not a
|
||||
// constant, so tests can point the probe at a fixture instead of the running
|
||||
// machine's hardware.
|
||||
var sysClassNet = "/sys/class/net"
|
||||
|
||||
// The external helpers the probe leans on, indirected so tests can exercise it
|
||||
// without the host's real tooling.
|
||||
var (
|
||||
listInterfaces = net.Interfaces
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).Output()
|
||||
}
|
||||
)
|
||||
|
||||
// NIC describes one physical interface as presented in the installer's
|
||||
// selection table.
|
||||
type NIC struct {
|
||||
Name string
|
||||
|
||||
// Vendor and Model come from the udev hardware database, e.g. "Mellanox
|
||||
// Technologies" and "MT27710 Family [ConnectX-4 Lx]". Empty when the
|
||||
// database carries no entry for the card.
|
||||
Vendor string
|
||||
Model string
|
||||
|
||||
// Driver is the kernel module that claimed the port, e.g. "mlx5_core". It
|
||||
// comes straight from sysfs, so it still identifies a card the hardware
|
||||
// database misses.
|
||||
Driver string
|
||||
|
||||
// DeviceID is the PCI or USB "vendor:device" pair, the last-resort identity
|
||||
// for hardware neither the database nor a driver name pins down.
|
||||
DeviceID string
|
||||
|
||||
// AltName is the first alternative name systemd assigns, which is what
|
||||
// operators often see in switch and cabling documentation.
|
||||
AltName string
|
||||
|
||||
// Speed is the negotiated link speed rendered for display ("25 Gbps") and
|
||||
// SpeedMbps the raw figure behind it. Both are zero on a port with no
|
||||
// carrier, which is why Probe brings links up before reading.
|
||||
Speed string
|
||||
SpeedMbps int
|
||||
|
||||
// Carrier reports a cable with link. It orders the picker so the port the
|
||||
// operator has just plugged in is the one offered first.
|
||||
Carrier bool
|
||||
|
||||
// State is the link summary shown in the table: "online", "no cable" or
|
||||
// "down".
|
||||
State string
|
||||
|
||||
MTU int
|
||||
IsWiFi bool
|
||||
}
|
||||
|
||||
// Label renders the NIC's identity line: kernel name, negotiated speed and link
|
||||
// state. The hardware detail is deliberately absent — it goes on its own line
|
||||
// via Hardware, where there is room to read it.
|
||||
func (n NIC) Label() string {
|
||||
speed := n.Speed
|
||||
if speed == "" {
|
||||
speed = "—"
|
||||
}
|
||||
state := n.State
|
||||
if state == "" {
|
||||
state = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s", n.Name, speed, state)
|
||||
}
|
||||
|
||||
// Hardware renders the card's identity for the detail line, degrading from the
|
||||
// database name to the driver and device id rather than to "unknown". An
|
||||
// operator needs something to match against the card in their hand.
|
||||
func (n NIC) Hardware() string {
|
||||
if hw := strings.TrimSpace(n.Vendor + " " + n.Model); hw != "" {
|
||||
return hw
|
||||
}
|
||||
switch {
|
||||
case n.Driver != "" && n.DeviceID != "":
|
||||
return n.Driver + " [" + n.DeviceID + "]"
|
||||
case n.Driver != "":
|
||||
return n.Driver
|
||||
case n.DeviceID != "":
|
||||
return n.DeviceID
|
||||
}
|
||||
return "no hardware detail"
|
||||
}
|
||||
|
||||
// linkUpTimeout bounds how long Probe waits after bringing links up for speed
|
||||
// and carrier to settle. A 25gbe port negotiating against a cold switch can
|
||||
// take a second or two to report a speed.
|
||||
var linkUpTimeout = 2 * time.Second
|
||||
|
||||
// Probe enumerates non-loopback physical interfaces and enriches each from
|
||||
// sysfs and the udev hardware database, ordered so the ports worth assigning
|
||||
// come first. Links are brought up first: a down port reports no speed and no
|
||||
// carrier, which would leave every fast port unidentified at exactly the moment
|
||||
// the operator needs to pick one.
|
||||
//
|
||||
// Nothing the probe depends on needs a running systemd. The live installer
|
||||
// boots straight into spinifex-init with only udevd started, so networkctl —
|
||||
// which has to reach systemd-networkd — enriches gaps and is never the source.
|
||||
func Probe() ([]NIC, error) {
|
||||
ifaces, err := listInterfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names, physical []string
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
names = append(names, iface.Name)
|
||||
if hasDevice(iface.Name) {
|
||||
physical = append(physical, iface.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Bridges, bonds and veth pairs carry a MAC like any port but have no
|
||||
// device behind them, and offering them would let a plane be bound to
|
||||
// something that does not exist until after the install. Keep them only if
|
||||
// the filter found nothing, so an unusual platform still gets a list.
|
||||
if len(physical) > 0 {
|
||||
names = physical
|
||||
}
|
||||
|
||||
brought := false
|
||||
for _, name := range names {
|
||||
if _, err := commandOutput("ip", "link", "set", "dev", name, "up"); err != nil {
|
||||
slog.Debug("netprobe: could not bring link up", "iface", name, "err", err)
|
||||
continue
|
||||
}
|
||||
brought = true
|
||||
}
|
||||
if brought {
|
||||
time.Sleep(linkUpTimeout)
|
||||
}
|
||||
|
||||
nics := make([]NIC, 0, len(names))
|
||||
for _, name := range names {
|
||||
nics = append(nics, describe(name))
|
||||
}
|
||||
slices.SortFunc(nics, byUsefulness)
|
||||
return nics, nil
|
||||
}
|
||||
|
||||
// describe reads everything about one interface that needs no daemon, then lets
|
||||
// networkctl fill the gaps if it happens to be answering.
|
||||
func describe(name string) NIC {
|
||||
nic := NIC{
|
||||
Name: name,
|
||||
Driver: sysLinkBase(name, "device/driver"),
|
||||
DeviceID: deviceID(name),
|
||||
MTU: sysInt(name, "mtu"),
|
||||
IsWiFi: isWiFi(name),
|
||||
}
|
||||
|
||||
// The kernel reports -1 for a port that cannot state its speed; treat that
|
||||
// as unknown rather than letting it sort below every real figure.
|
||||
nic.SpeedMbps = max(sysInt(name, "speed"), 0)
|
||||
nic.Speed = formatSpeed(nic.SpeedMbps)
|
||||
nic.Carrier = sysAttr(name, "carrier") == "1"
|
||||
nic.State = linkState(name, nic.Carrier)
|
||||
|
||||
props := udevProperties(name)
|
||||
nic.Vendor = props["ID_VENDOR_FROM_DATABASE"]
|
||||
nic.Model = props["ID_MODEL_FROM_DATABASE"]
|
||||
|
||||
// udev's predictable names are the ones that appear in cabling notes; the
|
||||
// path-based name is the one networkctl lists first.
|
||||
for _, key := range []string{"ID_NET_NAME_PATH", "ID_NET_NAME_ONBOARD", "ID_NET_NAME_SLOT"} {
|
||||
if alt := props[key]; alt != "" && alt != name {
|
||||
nic.AltName = alt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := commandOutput("networkctl", "status", "--no-pager", name); err == nil {
|
||||
fill(&nic, ParseStatus(string(out)))
|
||||
} else {
|
||||
slog.Debug("netprobe: networkctl unavailable, using sysfs only", "iface", name, "err", err)
|
||||
}
|
||||
return nic
|
||||
}
|
||||
|
||||
// fill copies parsed hardware detail onto a NIC without overwriting anything
|
||||
// sysfs already established. State is never taken: the carrier bit is a truer
|
||||
// answer to "is this cable live" than networkd's configuration status.
|
||||
func fill(dst *NIC, src NIC) {
|
||||
if dst.Vendor == "" {
|
||||
dst.Vendor = src.Vendor
|
||||
}
|
||||
if dst.Model == "" {
|
||||
dst.Model = src.Model
|
||||
}
|
||||
if dst.AltName == "" {
|
||||
dst.AltName = src.AltName
|
||||
}
|
||||
if dst.Speed == "" {
|
||||
dst.Speed = src.Speed
|
||||
}
|
||||
if dst.MTU == 0 {
|
||||
dst.MTU = src.MTU
|
||||
}
|
||||
}
|
||||
|
||||
// byUsefulness orders the picker so the interfaces an operator would actually
|
||||
// assign come first: a live cable, then wired over wireless, then the fastest
|
||||
// port. The rest still appear, just below the plausible choices.
|
||||
func byUsefulness(a, b NIC) int {
|
||||
if a.Carrier != b.Carrier {
|
||||
return boolOrder(a.Carrier)
|
||||
}
|
||||
if a.IsWiFi != b.IsWiFi {
|
||||
return boolOrder(b.IsWiFi)
|
||||
}
|
||||
if a.SpeedMbps != b.SpeedMbps {
|
||||
return cmp.Compare(b.SpeedMbps, a.SpeedMbps)
|
||||
}
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// boolOrder sorts true ahead of false.
|
||||
func boolOrder(first bool) int {
|
||||
if first {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// formatSpeed renders the kernel's Mbit/s figure the way a datasheet does.
|
||||
func formatSpeed(mbps int) string {
|
||||
switch {
|
||||
case mbps <= 0:
|
||||
return ""
|
||||
case mbps%1000 == 0:
|
||||
return fmt.Sprintf("%d Gbps", mbps/1000)
|
||||
default:
|
||||
return fmt.Sprintf("%d Mbps", mbps)
|
||||
}
|
||||
}
|
||||
|
||||
// linkState collapses the kernel's flags into the three outcomes an operator
|
||||
// acts on: the port is live, the port is enabled but unplugged, or the port
|
||||
// never came up at all. operstate reports "down" for the latter two alike.
|
||||
func linkState(name string, carrier bool) string {
|
||||
switch {
|
||||
case carrier:
|
||||
return "online"
|
||||
case adminUp(name):
|
||||
return "no cable"
|
||||
default:
|
||||
return "down"
|
||||
}
|
||||
}
|
||||
|
||||
// adminUp reads the IFF_UP bit out of the interface flags.
|
||||
func adminUp(name string) bool {
|
||||
flags, err := strconv.ParseUint(strings.TrimPrefix(sysAttr(name, "flags"), "0x"), 16, 64)
|
||||
return err == nil && flags&0x1 != 0
|
||||
}
|
||||
|
||||
// deviceID reads the PCI or USB "vendor:device" pair. A NIC's device link
|
||||
// points at the PCI function directly; on USB it points at the interface, whose
|
||||
// parent carries the ids.
|
||||
func deviceID(name string) string {
|
||||
dev := filepath.Join(sysClassNet, name, "device")
|
||||
if id := idPair(dev, "vendor", "device"); id != "" {
|
||||
return id
|
||||
}
|
||||
return idPair(filepath.Join(dev, ".."), "idVendor", "idProduct")
|
||||
}
|
||||
|
||||
func idPair(dir, vendorAttr, productAttr string) string {
|
||||
vendor := readTrimmed(filepath.Join(dir, vendorAttr))
|
||||
product := readTrimmed(filepath.Join(dir, productAttr))
|
||||
if vendor == "" || product == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(vendor, "0x") + ":" + strings.TrimPrefix(product, "0x")
|
||||
}
|
||||
|
||||
// udevProperties reads the interface's udev database entry, which is where the
|
||||
// hardware-database vendor and model names live. udevd populates it during the
|
||||
// installer's device enumeration, so this works with no systemd running.
|
||||
func udevProperties(name string) map[string]string {
|
||||
out, err := commandOutput("udevadm", "info", "--query=property", "--path="+filepath.Join(sysClassNet, name))
|
||||
if err != nil {
|
||||
slog.Debug("netprobe: udevadm info failed", "iface", name, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
props := make(map[string]string)
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
if key, value, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
||||
props[key] = value
|
||||
}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// ParseStatus extracts the hardware fields from `networkctl status <iface>`
|
||||
// output. The format is a set of right-aligned "Key: value" lines, with
|
||||
// Alternative Names continuing across subsequent indented lines.
|
||||
func ParseStatus(out string) NIC {
|
||||
var nic NIC
|
||||
var inAltNames bool
|
||||
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
inAltNames = false
|
||||
continue
|
||||
}
|
||||
|
||||
key, value, found := strings.Cut(trimmed, ": ")
|
||||
if !found {
|
||||
// A continuation line under "Alternative Names:" — take the first
|
||||
// only, which is the one operators see in cabling notes.
|
||||
if inAltNames && nic.AltName == "" {
|
||||
nic.AltName = trimmed
|
||||
}
|
||||
continue
|
||||
}
|
||||
inAltNames = false
|
||||
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "Vendor":
|
||||
nic.Vendor = value
|
||||
case "Model":
|
||||
nic.Model = value
|
||||
case "Speed":
|
||||
nic.Speed = value
|
||||
case "Online state":
|
||||
// Rendered as "online" or "online (configured)".
|
||||
if f := strings.Fields(value); len(f) > 0 {
|
||||
nic.State = f[0]
|
||||
}
|
||||
case "MTU":
|
||||
// Rendered as "9000 (min: 68, max: 9978)".
|
||||
if f := strings.Fields(value); len(f) > 0 {
|
||||
if mtu, err := strconv.Atoi(f[0]); err == nil {
|
||||
nic.MTU = mtu
|
||||
}
|
||||
}
|
||||
case "Alternative Names":
|
||||
inAltNames = true
|
||||
if f := strings.Fields(value); len(f) > 0 && nic.AltName == "" {
|
||||
nic.AltName = f[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nic
|
||||
}
|
||||
|
||||
// hasDevice reports whether the interface is backed by real hardware, which
|
||||
// sysfs shows as a device link to the PCI, USB or platform node behind it.
|
||||
func hasDevice(name string) bool {
|
||||
_, err := os.Stat(filepath.Join(sysClassNet, name, "device"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isWiFi reports whether the interface has a wireless subdirectory in sysfs.
|
||||
func isWiFi(name string) bool {
|
||||
_, err := os.Stat(filepath.Join(sysClassNet, name, "wireless"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// sysAttr reads one sysfs attribute. A missing or unreadable attribute is not
|
||||
// an error worth surfacing: speed and carrier both fail on a port with no link,
|
||||
// which the state column already conveys.
|
||||
func sysAttr(name, attr string) string {
|
||||
return readTrimmed(filepath.Join(sysClassNet, name, attr))
|
||||
}
|
||||
|
||||
func sysInt(name, attr string) int {
|
||||
n, err := strconv.Atoi(sysAttr(name, attr))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// sysLinkBase resolves a sysfs symlink and returns its final element, which is
|
||||
// how the driver name is exposed.
|
||||
func sysLinkBase(name, attr string) string {
|
||||
target, err := os.Readlink(filepath.Join(sysClassNet, name, attr))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(target)
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package netprobe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Captured from hydrogen (HP DL325 Gen10, dual 25gbe Mellanox) in us-west-1-az1.
|
||||
const mellanox25g = `● 6: ens1f0np0
|
||||
Link File: /usr/lib/systemd/network/99-default.link
|
||||
Network File: /etc/systemd/network/12-spinifex-lan-nic.network
|
||||
State: enslaved (configured)
|
||||
Online state: online
|
||||
Type: ether
|
||||
Path: pci-0000:84:00.0
|
||||
Driver: mlx5_core
|
||||
Vendor: Mellanox Technologies
|
||||
Model: MT27710 Family [ConnectX-4 Lx]
|
||||
Alternative Names: enp132s0f0np0
|
||||
enx040973e5ce60
|
||||
Hardware Address: 04:09:73:e5:ce:60 (Hewlett Packard Enterprise)
|
||||
MTU: 9000 (min: 68, max: 9978)
|
||||
QDisc: mq
|
||||
Master: br-lan
|
||||
IPv6 Address Generation Mode: none
|
||||
Number of Queues (Tx/Rx): 384/48
|
||||
Auto negotiation: yes
|
||||
Speed: 25Gbps
|
||||
Duplex: full
|
||||
Activation Policy: up
|
||||
Required For Online: yes
|
||||
Connected To: MikroTik (MikroTik RouterOS 7.11.3 (stable) Sep/27/2023 13:09:44 CRS504-4XQ) on port bridge/qsfp28-3-3
|
||||
`
|
||||
|
||||
// A link with no carrier reports neither Speed nor an online state, which is
|
||||
// the case Probe brings links up to avoid.
|
||||
const linkDown = `● 3: eno6
|
||||
Link File: /usr/lib/systemd/network/99-default.link
|
||||
State: off
|
||||
Online state: off
|
||||
Type: ether
|
||||
Driver: bnxt_en
|
||||
Vendor: Broadcom Inc. and subsidiaries
|
||||
Model: NetXtreme BCM5720 Gigabit Ethernet PCIe
|
||||
Hardware Address: 04:09:73:e5:ce:5f
|
||||
MTU: 1500 (min: 60, max: 9000)
|
||||
`
|
||||
|
||||
func TestParseStatusMellanox(t *testing.T) {
|
||||
nic := ParseStatus(mellanox25g)
|
||||
|
||||
if nic.Vendor != "Mellanox Technologies" {
|
||||
t.Errorf("Vendor = %q, want %q", nic.Vendor, "Mellanox Technologies")
|
||||
}
|
||||
if nic.Model != "MT27710 Family [ConnectX-4 Lx]" {
|
||||
t.Errorf("Model = %q, want %q", nic.Model, "MT27710 Family [ConnectX-4 Lx]")
|
||||
}
|
||||
if nic.Speed != "25Gbps" {
|
||||
t.Errorf("Speed = %q, want %q", nic.Speed, "25Gbps")
|
||||
}
|
||||
if nic.State != "online" {
|
||||
t.Errorf("State = %q, want %q", nic.State, "online")
|
||||
}
|
||||
if nic.MTU != 9000 {
|
||||
t.Errorf("MTU = %d, want 9000", nic.MTU)
|
||||
}
|
||||
// Only the first alternative name is taken; enx040973e5ce60 is ignored.
|
||||
if nic.AltName != "enp132s0f0np0" {
|
||||
t.Errorf("AltName = %q, want %q", nic.AltName, "enp132s0f0np0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusLinkDown(t *testing.T) {
|
||||
nic := ParseStatus(linkDown)
|
||||
|
||||
if nic.Speed != "" {
|
||||
t.Errorf("Speed = %q, want empty for a down link", nic.Speed)
|
||||
}
|
||||
if nic.State != "off" {
|
||||
t.Errorf("State = %q, want %q", nic.State, "off")
|
||||
}
|
||||
if nic.Model != "NetXtreme BCM5720 Gigabit Ethernet PCIe" {
|
||||
t.Errorf("Model = %q, want the Broadcom model", nic.Model)
|
||||
}
|
||||
if nic.MTU != 1500 {
|
||||
t.Errorf("MTU = %d, want 1500", nic.MTU)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusEmpty(t *testing.T) {
|
||||
// A truncated or unparseable status must not panic or invent values.
|
||||
nic := ParseStatus("● 2: eth0\n Online state:\n Alternative Names:\n")
|
||||
if nic.State != "" || nic.AltName != "" {
|
||||
t.Errorf("expected empty fields from valueless keys, got State=%q AltName=%q", nic.State, nic.AltName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelFallsBackWhenSpeedUnknown(t *testing.T) {
|
||||
got := NIC{Name: "eth0", State: "no cable"}.Label()
|
||||
want := "eth0 — no cable"
|
||||
if got != want {
|
||||
t.Errorf("Label() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Hardware must never render "unknown": an operator standing at the console
|
||||
// needs something to match against the card in their hand, and sysfs always has
|
||||
// at least a driver or a device id.
|
||||
func TestHardwareDegradesRatherThanUnknown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nic NIC
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "database name wins",
|
||||
nic: NIC{Vendor: "Mellanox Technologies", Model: "MT27710", Driver: "mlx5_core", DeviceID: "15b3:1015"},
|
||||
want: "Mellanox Technologies MT27710",
|
||||
},
|
||||
{"vendor only", NIC{Vendor: "Intel Corporation"}, "Intel Corporation"},
|
||||
{"driver and id", NIC{Driver: "igb", DeviceID: "8086:1533"}, "igb [8086:1533]"},
|
||||
{"driver only", NIC{Driver: "virtio_net"}, "virtio_net"},
|
||||
{"id only", NIC{DeviceID: "0bda:8153"}, "0bda:8153"},
|
||||
{"nothing at all", NIC{Name: "eth0"}, "no hardware detail"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.nic.Hardware(); got != tt.want {
|
||||
t.Errorf("Hardware() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSpeed(t *testing.T) {
|
||||
tests := []struct {
|
||||
mbps int
|
||||
want string
|
||||
}{
|
||||
{25000, "25 Gbps"},
|
||||
{1000, "1 Gbps"},
|
||||
{100, "100 Mbps"},
|
||||
{2500, "2500 Mbps"},
|
||||
{0, ""},
|
||||
{-1, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := formatSpeed(tt.mbps); got != tt.want {
|
||||
t.Errorf("formatSpeed(%d) = %q, want %q", tt.mbps, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live cable outranks everything, then wired over wireless, then speed. This
|
||||
// is what puts the ports an operator would actually assign at indices 0..2,
|
||||
// where newModel pre-fills wan, lan and vpc from.
|
||||
func TestByUsefulnessOrdersPluggedPortsFirst(t *testing.T) {
|
||||
nics := []NIC{
|
||||
{Name: "eno1", SpeedMbps: 1000},
|
||||
{Name: "wlp2s0", Carrier: true, IsWiFi: true, SpeedMbps: 866},
|
||||
{Name: "ens1f1np1", SpeedMbps: 25000},
|
||||
{Name: "ens1f0np0", Carrier: true, SpeedMbps: 25000},
|
||||
{Name: "eno2", Carrier: true, SpeedMbps: 1000},
|
||||
}
|
||||
slices.SortFunc(nics, byUsefulness)
|
||||
|
||||
want := []string{"ens1f0np0", "eno2", "wlp2s0", "ens1f1np1", "eno1"}
|
||||
for i, name := range want {
|
||||
if nics[i].Name != name {
|
||||
t.Fatalf("order = %v, want %v", names(nics), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func names(nics []NIC) []string {
|
||||
out := make([]string, len(nics))
|
||||
for i, n := range nics {
|
||||
out[i] = n.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestProbeReadsSysfsWithoutSystemd(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysClassNet = root
|
||||
linkUpTimeout = 0
|
||||
t.Cleanup(func() {
|
||||
sysClassNet = "/sys/class/net"
|
||||
linkUpTimeout = 2 * time.Second
|
||||
})
|
||||
|
||||
// ens1f0np0: a 25gbe Mellanox with a live cable, identified by the hardware
|
||||
// database. eno1: a 1gbe port that is up but unplugged, and absent from the
|
||||
// database, so only its driver and PCI id identify it.
|
||||
writeIface(t, root, "ens1f0np0", map[string]string{
|
||||
"speed": "25000", "carrier": "1", "mtu": "9000", "flags": "0x1003",
|
||||
"device/vendor": "0x15b3", "device/device": "0x1015",
|
||||
}, "mlx5_core")
|
||||
writeIface(t, root, "eno1", map[string]string{
|
||||
"speed": "1000", "carrier": "0", "mtu": "1500", "flags": "0x1003",
|
||||
"device/vendor": "0x8086", "device/device": "0x1533",
|
||||
}, "igb")
|
||||
|
||||
listInterfaces = func() ([]net.Interface, error) {
|
||||
return []net.Interface{
|
||||
{Name: "lo", Flags: net.FlagLoopback},
|
||||
{Name: "eno1", HardwareAddr: net.HardwareAddr{4, 9, 0x73, 0xe5, 0xce, 0x5f}},
|
||||
{Name: "ens1f0np0", HardwareAddr: net.HardwareAddr{4, 9, 0x73, 0xe5, 0xce, 0x60}},
|
||||
{Name: "veth0"}, // no hardware address, so not a physical port
|
||||
{Name: "br-lan", HardwareAddr: net.HardwareAddr{2, 0, 0, 0, 0, 1}}, // a bridge, so no device behind it
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() { listInterfaces = net.Interfaces })
|
||||
|
||||
// Only udevadm answers. networkctl fails the way it does in the live
|
||||
// installer, where there is no systemd-networkd for it to reach.
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case name == "ip":
|
||||
return nil, nil
|
||||
case name == "udevadm" && strings.HasSuffix(args[len(args)-1], "ens1f0np0"):
|
||||
return []byte("ID_VENDOR_FROM_DATABASE=Mellanox Technologies\n" +
|
||||
"ID_MODEL_FROM_DATABASE=MT27710 Family [ConnectX-4 Lx]\n" +
|
||||
"ID_NET_NAME_PATH=enp132s0f0np0\n"), nil
|
||||
case name == "udevadm":
|
||||
return []byte("ID_NET_NAME_PATH=enp1s0f0\n"), nil
|
||||
default:
|
||||
return nil, errors.New("Failed to connect to bus: No such file or directory")
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).Output()
|
||||
}
|
||||
})
|
||||
|
||||
nics, err := Probe()
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if got := names(nics); !slices.Equal(got, []string{"ens1f0np0", "eno1"}) {
|
||||
t.Fatalf("probed %v, want the two physical ports with the plugged one first", got)
|
||||
}
|
||||
|
||||
mlx := nics[0]
|
||||
if mlx.Label() != "ens1f0np0 25 Gbps online" {
|
||||
t.Errorf("Label() = %q", mlx.Label())
|
||||
}
|
||||
if want := "Mellanox Technologies MT27710 Family [ConnectX-4 Lx]"; mlx.Hardware() != want {
|
||||
t.Errorf("Hardware() = %q, want %q", mlx.Hardware(), want)
|
||||
}
|
||||
if mlx.AltName != "enp132s0f0np0" || mlx.MTU != 9000 || !mlx.Carrier {
|
||||
t.Errorf("unexpected detail: %+v", mlx)
|
||||
}
|
||||
|
||||
// Up but unplugged, and unknown to the database: the driver and PCI id are
|
||||
// all the operator gets, which is still enough to identify the card.
|
||||
igb := nics[1]
|
||||
if igb.Label() != "eno1 1 Gbps no cable" {
|
||||
t.Errorf("Label() = %q", igb.Label())
|
||||
}
|
||||
if igb.Hardware() != "igb [8086:1533]" {
|
||||
t.Errorf("Hardware() = %q", igb.Hardware())
|
||||
}
|
||||
}
|
||||
|
||||
// writeIface lays out the sysfs attributes Probe reads for one interface,
|
||||
// including the driver symlink it resolves the module name from.
|
||||
func writeIface(t *testing.T, root, name string, attrs map[string]string, driver string) {
|
||||
t.Helper()
|
||||
for attr, value := range attrs {
|
||||
path := filepath.Join(root, name, attr)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(value+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink("../../../bus/pci/drivers/"+driver, filepath.Join(root, name, "device/driver")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -6,44 +6,38 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteLANBridgeUnit installs a non-critical oneshot service that activates
|
||||
// br-lan via networkctl *after* network-online.target. br-lan has
|
||||
// ActivationPolicy=manual in its networkd .network file, so networkd creates
|
||||
// the bridge at boot but does not bring it up automatically. This service
|
||||
// triggers the activation once the WAN path is confirmed online, ensuring a
|
||||
// missing LAN cable or DHCP timeout on the secondary bridge never stalls
|
||||
// WriteBridgeUnit installs a non-critical oneshot service that activates an
|
||||
// east-west bridge (br-lan, br-vpc) via networkctl *after* network-online.target.
|
||||
// Those bridges carry ActivationPolicy=manual in their networkd .network file,
|
||||
// so networkd creates them at boot but does not bring them up automatically.
|
||||
// This service triggers the activation once the WAN path is confirmed online,
|
||||
// ensuring a missing cable or DHCP timeout on a secondary bridge never stalls
|
||||
// the management interface or firstboot.
|
||||
func WriteLANBridgeUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex LAN bridge (non-critical)
|
||||
func WriteBridgeUnit(root, bridge string) error {
|
||||
name := "spinifex-" + strings.TrimPrefix(bridge, "br-") + "-bridge.service"
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Spinifex %s bridge (non-critical)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/networkctl up br-lan
|
||||
ExecStart=/usr/bin/networkctl up %s
|
||||
RemainAfterExit=yes
|
||||
# Failure is non-critical — cable unplugged or switch not ready at boot.
|
||||
SuccessExitStatus=0 1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
|
||||
`, bridge, bridge)
|
||||
unitPath := filepath.Join(root, "etc/systemd/system", name)
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, "spinifex-lan-bridge.service")
|
||||
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale symlink %s: %w", link, err)
|
||||
}
|
||||
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
|
||||
return EnableUnit(root, name)
|
||||
}
|
||||
|
||||
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
|
||||
@@ -74,6 +68,8 @@ ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinif
|
||||
# ovn-central startup cannot wedge multi-user.target and keep getty from
|
||||
# ever reaching the login prompt.
|
||||
TimeoutStartSec=300s
|
||||
# Install diagnostics land in the journal, which persists for the life of the
|
||||
# node — nothing this script runs may print secrets to stdout or stderr.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/netprobe"
|
||||
)
|
||||
|
||||
// foldedNIC marks a role with no interface of its own, which collapses onto
|
||||
// the plane above it (vpc <- lan <- wan).
|
||||
const foldedNIC = -1
|
||||
|
||||
// roleField identifies one editable element on the per-role screen. Which of
|
||||
// these are actually shown depends on the plane, the addressing mode, whether
|
||||
// the NIC is wireless, and whether Advanced is on.
|
||||
type roleField int
|
||||
|
||||
const (
|
||||
roleFieldNIC roleField = iota
|
||||
roleFieldMethod
|
||||
roleFieldIP
|
||||
roleFieldMask
|
||||
roleFieldGateway
|
||||
roleFieldDNS
|
||||
roleFieldVLAN
|
||||
roleFieldMTU
|
||||
roleFieldSSID
|
||||
roleFieldWiFiPass
|
||||
)
|
||||
|
||||
// roleForm is the editable state for one plane. The three forms are the whole
|
||||
// of the installer's network configuration.
|
||||
type roleForm struct {
|
||||
plane install.Plane
|
||||
|
||||
// nic indexes into model.nics, or foldedNIC when the role is folded.
|
||||
nic int
|
||||
dhcp bool
|
||||
focus roleField
|
||||
|
||||
ip textinput.Model
|
||||
mask textinput.Model
|
||||
gateway textinput.Model
|
||||
dns textinput.Model
|
||||
vlan textinput.Model
|
||||
mtu textinput.Model
|
||||
ssid textinput.Model
|
||||
wifiPass textinput.Model
|
||||
}
|
||||
|
||||
func newRoleForm(plane install.Plane, nic int) roleForm {
|
||||
text := func(placeholder string, limit int) textinput.Model {
|
||||
in := textinput.New()
|
||||
in.Prompt = ""
|
||||
in.Placeholder = placeholder
|
||||
if limit > 0 {
|
||||
in.CharLimit = limit
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
f := roleForm{
|
||||
plane: plane,
|
||||
nic: nic,
|
||||
dhcp: plane == install.PlaneWAN,
|
||||
ip: text(defaultIPPlaceholder(plane), 0),
|
||||
mask: text("255.255.255.0", 0),
|
||||
gateway: text("192.168.1.1", 0),
|
||||
dns: text("1.1.1.1, 8.8.8.8", 0),
|
||||
vlan: text("untagged", 4),
|
||||
mtu: text("1500", 5),
|
||||
ssid: text("Network SSID", 64),
|
||||
wifiPass: text("WiFi password", 128),
|
||||
}
|
||||
f.wifiPass.EchoMode = textinput.EchoPassword
|
||||
return f
|
||||
}
|
||||
|
||||
// defaultIPPlaceholder hints the addressing convention for each plane so an
|
||||
// operator filling in a rack does not have to remember it.
|
||||
func defaultIPPlaceholder(plane install.Plane) string {
|
||||
switch plane {
|
||||
case install.PlaneLAN:
|
||||
return "10.0.0.3"
|
||||
case install.PlaneVPC:
|
||||
return "10.1.0.3"
|
||||
default:
|
||||
return "192.168.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
func (f *roleForm) folded() bool { return f.nic == foldedNIC }
|
||||
|
||||
// canFold reports whether this role may be left unbound. wan always needs an
|
||||
// uplink, so only lan and vpc can fold.
|
||||
func (f *roleForm) canFold() bool { return f.plane != install.PlaneWAN }
|
||||
|
||||
// visibleFields returns the fields shown for this form, in tab order. Hiding
|
||||
// rather than disabling keeps the default screen narrow: a DHCP role shows two
|
||||
// lines, and VLAN/MTU only appear once Advanced is on.
|
||||
func (f *roleForm) visibleFields(isWiFi, advanced bool) []roleField {
|
||||
if f.folded() {
|
||||
return []roleField{roleFieldNIC}
|
||||
}
|
||||
fields := []roleField{roleFieldNIC, roleFieldMethod}
|
||||
if !f.dhcp {
|
||||
fields = append(fields, roleFieldIP, roleFieldMask)
|
||||
// Default route and resolvers are properties of the node, not of each
|
||||
// plane, and wan is the only one that carries either: it holds the
|
||||
// default route, so it is the only plane that can reach an upstream
|
||||
// resolver. On DHCP the lease supplies both.
|
||||
if f.plane == install.PlaneWAN {
|
||||
fields = append(fields, roleFieldGateway, roleFieldDNS)
|
||||
}
|
||||
}
|
||||
if advanced {
|
||||
fields = append(fields, roleFieldVLAN, roleFieldMTU)
|
||||
}
|
||||
if isWiFi {
|
||||
fields = append(fields, roleFieldSSID, roleFieldWiFiPass)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// input returns the textinput backing a field, or nil for the non-text fields.
|
||||
func (f *roleForm) input(field roleField) *textinput.Model {
|
||||
switch field {
|
||||
case roleFieldIP:
|
||||
return &f.ip
|
||||
case roleFieldMask:
|
||||
return &f.mask
|
||||
case roleFieldGateway:
|
||||
return &f.gateway
|
||||
case roleFieldDNS:
|
||||
return &f.dns
|
||||
case roleFieldVLAN:
|
||||
return &f.vlan
|
||||
case roleFieldMTU:
|
||||
return &f.mtu
|
||||
case roleFieldSSID:
|
||||
return &f.ssid
|
||||
case roleFieldWiFiPass:
|
||||
return &f.wifiPass
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// blurAll drops focus from every input so only the focused field has a cursor.
|
||||
func (f *roleForm) blurAll() {
|
||||
for _, field := range []roleField{
|
||||
roleFieldIP, roleFieldMask, roleFieldGateway, roleFieldDNS,
|
||||
roleFieldVLAN, roleFieldMTU, roleFieldSSID, roleFieldWiFiPass,
|
||||
} {
|
||||
f.input(field).Blur()
|
||||
}
|
||||
}
|
||||
|
||||
// focusCurrent gives the cursor to the focused field, if it is a text field.
|
||||
func (f *roleForm) focusCurrent() {
|
||||
f.blurAll()
|
||||
if in := f.input(f.focus); in != nil {
|
||||
in.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// toRole converts the form into the install package's role model.
|
||||
func (f *roleForm) toRole(nics []netprobe.NIC) install.NetworkRole {
|
||||
if f.folded() || f.nic >= len(nics) {
|
||||
return install.NetworkRole{}
|
||||
}
|
||||
role := install.NetworkRole{
|
||||
Interface: nics[f.nic].Name,
|
||||
DHCPMode: f.dhcp,
|
||||
VLAN: atoiOr(f.vlan.Value(), 0),
|
||||
MTU: atoiOr(f.mtu.Value(), 0),
|
||||
}
|
||||
if !f.dhcp {
|
||||
role.Address = strings.TrimSpace(f.ip.Value())
|
||||
role.Mask = strings.TrimSpace(f.mask.Value())
|
||||
role.DNS = parseDNS(f.dns.Value())
|
||||
if f.plane == install.PlaneWAN {
|
||||
role.Gateway = strings.TrimSpace(f.gateway.Value())
|
||||
}
|
||||
}
|
||||
if nics[f.nic].IsWiFi {
|
||||
role.WiFiSSID = strings.TrimSpace(f.ssid.Value())
|
||||
role.WiFiPass = f.wifiPass.Value()
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func atoiOr(s string, fallback int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── Roles overview screen ─────────────────────────────────────────────────────
|
||||
|
||||
// continueRow is the cursor position below the three role rows.
|
||||
const continueRow = 3
|
||||
|
||||
func (m model) handleRolesKey(key string) (tea.Model, tea.Cmd) {
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if m.roleCursor > 0 {
|
||||
m.roleCursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.roleCursor < continueRow {
|
||||
m.roleCursor++
|
||||
}
|
||||
case "a":
|
||||
// Advanced reveals VLAN and MTU on every role at once, rather than
|
||||
// per-role, so the operator flips it on for the whole node.
|
||||
m.advanced = !m.advanced
|
||||
m.validationErr = ""
|
||||
case "enter":
|
||||
if m.roleCursor == continueRow {
|
||||
if err := m.buildConfig().Validate(); err != nil {
|
||||
m.validationErr = capitalise(err.Error())
|
||||
return m, nil
|
||||
}
|
||||
m.validationErr = ""
|
||||
m.screen = screenIdentity
|
||||
m.hostnameInput.Focus()
|
||||
return m, nil
|
||||
}
|
||||
m.validationErr = ""
|
||||
m.screen = screenNetworkRole
|
||||
m.roles[m.roleCursor].focus = roleFieldNIC
|
||||
m.roles[m.roleCursor].blurAll()
|
||||
case "esc":
|
||||
m.screen = screenDiskConfirm
|
||||
m.eraseInput.Focus()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) viewNetworkRoles(w int) string {
|
||||
title := styleTitle.Render("Network Planes")
|
||||
subtitle := styleMuted.Render("A role left unbound folds onto the plane above it.")
|
||||
|
||||
adv := styleMuted.Render("Advanced: off")
|
||||
if m.advanced {
|
||||
adv = styleSuccess.Render("Advanced: on")
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-*s%-*s", colRole, "ROLE", colIface, "INTERFACE")
|
||||
if m.advanced {
|
||||
header += fmt.Sprintf("%-*s%-*s", colVLAN, "VLAN", colMTU, "MTU")
|
||||
}
|
||||
header += "ADDRESS"
|
||||
|
||||
lines := []string{title, subtitle, "", adv, "", styleMuted.Render(header)}
|
||||
|
||||
for i := range m.roles {
|
||||
row := m.renderRoleRow(i)
|
||||
if i == m.roleCursor {
|
||||
lines = append(lines, styleSelected.Render("> "+row))
|
||||
} else {
|
||||
lines = append(lines, " "+row)
|
||||
}
|
||||
if detail := m.roleHardware(i, bodyWidth(w)); detail != "" {
|
||||
lines = append(lines, detail)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "")
|
||||
cont := " Continue"
|
||||
if m.roleCursor == continueRow {
|
||||
cont = styleSelected.Render("> Continue")
|
||||
}
|
||||
lines = append(lines, cont)
|
||||
|
||||
if m.validationErr != "" {
|
||||
lines = append(lines, "", styleError.Render(m.validationErr))
|
||||
}
|
||||
lines = append(lines, styleHelp.Render("↑/↓ move • Enter edit • a advanced • Esc back"))
|
||||
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
||||
styleBox.Width(min(w-4, 78)).Render(body),
|
||||
)
|
||||
}
|
||||
|
||||
// renderRoleRow renders one line of the overview table. A folded role names
|
||||
// the plane it collapsed onto rather than showing a blank, so the operator can
|
||||
// always see where the traffic will actually go.
|
||||
func (m model) renderRoleRow(i int) string {
|
||||
f := &m.roles[i]
|
||||
name := fmt.Sprintf("%-5s", f.plane)
|
||||
|
||||
if f.folded() {
|
||||
_, landed := m.buildConfig().Resolve(f.plane)
|
||||
return fmt.Sprintf("%-*s%s", colRole, name, styleMuted.Render("folds onto "+string(landed)))
|
||||
}
|
||||
|
||||
iface := styleMuted.Render("—")
|
||||
if f.nic < len(m.nics) {
|
||||
iface = nicSummary(m.nics[f.nic])
|
||||
}
|
||||
|
||||
addr := "DHCP"
|
||||
if !f.dhcp {
|
||||
addr = formatCIDR(f.ip.Value(), f.mask.Value())
|
||||
if addr == "" {
|
||||
addr = styleWarning.Render("not set")
|
||||
}
|
||||
}
|
||||
|
||||
row := padCell(name, colRole) + padCell(iface, colIface)
|
||||
if m.advanced {
|
||||
row += padCell(valueOr(f.vlan.Value(), "—"), colVLAN) + padCell(valueOr(f.mtu.Value(), "—"), colMTU)
|
||||
}
|
||||
return row + addr
|
||||
}
|
||||
|
||||
// roleHardware is the vendor/model line beneath a role row, indented under the
|
||||
// interface column. It is what tells an operator that wan really is the 25gbe
|
||||
// Mellanox and not the onboard 1gbe.
|
||||
func (m model) roleHardware(i, width int) string {
|
||||
f := &m.roles[i]
|
||||
if f.folded() || f.nic >= len(m.nics) {
|
||||
return ""
|
||||
}
|
||||
indent := 2 + colRole
|
||||
return strings.Repeat(" ", indent) + styleMuted.Render(nicHardware(m.nics[f.nic], width-indent))
|
||||
}
|
||||
|
||||
// nicSummary renders name, speed and link state, colouring the state so a port
|
||||
// with no cable stands out rather than having to be read for.
|
||||
func nicSummary(n netprobe.NIC) string {
|
||||
state := styleWarning.Render(n.State)
|
||||
if n.Carrier {
|
||||
state = styleSuccess.Render(n.State)
|
||||
}
|
||||
speed := n.Speed
|
||||
if speed == "" {
|
||||
speed = "—"
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s", n.Name, speed, state)
|
||||
}
|
||||
|
||||
// nicHardware is the detail line: what the card is, plus the predictable name
|
||||
// it also answers to, which is the one written on cabling notes. The alt name
|
||||
// is dropped rather than truncated when the two will not fit together.
|
||||
func nicHardware(n netprobe.NIC, width int) string {
|
||||
hw := n.Hardware()
|
||||
const separator = " · "
|
||||
if n.AltName != "" && len([]rune(hw))+len(separator)+len([]rune(n.AltName)) <= width {
|
||||
return hw + separator + n.AltName
|
||||
}
|
||||
return truncate(hw, width)
|
||||
}
|
||||
|
||||
// bodyWidth is the text width inside the framed body. Detail lines are trimmed
|
||||
// to it rather than left to wrap, which would push the table out of alignment.
|
||||
func bodyWidth(w int) int {
|
||||
return min(w-4, 78) - 4
|
||||
}
|
||||
|
||||
// padCell pads a cell to width by display width, which the %-*s verb cannot do
|
||||
// once a cell carries the ANSI escapes of a style.
|
||||
func padCell(s string, width int) string {
|
||||
return lipgloss.PlaceHorizontal(width, lipgloss.Left, s)
|
||||
}
|
||||
|
||||
// Column widths for the overview table, shared by the header and the rows.
|
||||
const (
|
||||
colRole = 6
|
||||
colIface = 32
|
||||
colVLAN = 7
|
||||
colMTU = 7
|
||||
)
|
||||
|
||||
// focusMark gives the focused text field a visible caret without every field
|
||||
// rendering its own prompt.
|
||||
func focusMark(focused bool) string {
|
||||
if focused {
|
||||
return styleLabel.Render("> ")
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
// formatCIDR renders an address and mask the way an operator writes them,
|
||||
// falling back to the raw mask when it is not dotted-decimal.
|
||||
func formatCIDR(addr, mask string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
mask = strings.TrimSpace(mask)
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
if mask == "" {
|
||||
return addr
|
||||
}
|
||||
if ip := net.ParseIP(mask); ip != nil && ip.To4() != nil {
|
||||
if ones, bits := net.IPMask(ip.To4()).Size(); bits != 0 {
|
||||
return fmt.Sprintf("%s/%d", addr, ones)
|
||||
}
|
||||
}
|
||||
return addr + "/" + strings.TrimPrefix(mask, "/")
|
||||
}
|
||||
|
||||
func valueOr(v, fallback string) string {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ── Per-role editor screen ────────────────────────────────────────────────────
|
||||
|
||||
func (m model) handleRoleKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
f := &m.roles[m.roleCursor]
|
||||
fields := f.visibleFields(m.nicIsWiFi(f.nic), m.advanced)
|
||||
pos := fieldIndex(fields, f.focus)
|
||||
|
||||
switch key {
|
||||
case "esc":
|
||||
f.blurAll()
|
||||
m.screen = screenNetworkRoles
|
||||
m.validationErr = ""
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if err := m.validateRole(m.roleCursor); err != "" {
|
||||
m.validationErr = err
|
||||
return m, nil
|
||||
}
|
||||
f.blurAll()
|
||||
m.screen = screenNetworkRoles
|
||||
m.validationErr = ""
|
||||
return m, nil
|
||||
|
||||
case "tab", "down":
|
||||
pos = (pos + 1) % len(fields)
|
||||
f.focus = fields[pos]
|
||||
f.focusCurrent()
|
||||
return m, nil
|
||||
|
||||
case "shift+tab", "up":
|
||||
pos = (pos - 1 + len(fields)) % len(fields)
|
||||
f.focus = fields[pos]
|
||||
f.focusCurrent()
|
||||
return m, nil
|
||||
|
||||
case "left", "right":
|
||||
// Only the two selector fields consume arrows; text fields need them
|
||||
// for cursor movement.
|
||||
switch f.focus {
|
||||
case roleFieldNIC:
|
||||
return m.cycleNIC(m.roleCursor, key == "right"), nil
|
||||
case roleFieldMethod:
|
||||
f.dhcp = key == "left"
|
||||
f.focusCurrent()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if in := f.input(f.focus); in != nil {
|
||||
var cmd tea.Cmd
|
||||
*in, cmd = in.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// cycleNIC steps the interface selection, including the fold option for lan
|
||||
// and vpc. Wrapping keeps a single key usable on a node with one NIC.
|
||||
func (m model) cycleNIC(idx int, forward bool) model {
|
||||
f := &m.roles[idx]
|
||||
low := 0
|
||||
if f.canFold() {
|
||||
low = foldedNIC
|
||||
}
|
||||
high := len(m.nics) - 1
|
||||
|
||||
if forward {
|
||||
if f.nic >= high {
|
||||
f.nic = low
|
||||
} else {
|
||||
f.nic++
|
||||
}
|
||||
return m
|
||||
}
|
||||
if f.nic <= low {
|
||||
f.nic = high
|
||||
} else {
|
||||
f.nic--
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) nicIsWiFi(idx int) bool {
|
||||
return idx >= 0 && idx < len(m.nics) && m.nics[idx].IsWiFi
|
||||
}
|
||||
|
||||
func (m model) viewNetworkRole(w int) string {
|
||||
f := &m.roles[m.roleCursor]
|
||||
title := styleTitle.Render(fmt.Sprintf("Configure %s plane", strings.ToUpper(string(f.plane))))
|
||||
subtitle := styleMuted.Render(planeDescription(f.plane))
|
||||
|
||||
lines := []string{title, subtitle, ""}
|
||||
for _, field := range f.visibleFields(m.nicIsWiFi(f.nic), m.advanced) {
|
||||
lines = append(lines, m.renderRoleField(f, field, bodyWidth(w)), "")
|
||||
}
|
||||
|
||||
if m.validationErr != "" {
|
||||
lines = append(lines, styleError.Render(m.validationErr), "")
|
||||
}
|
||||
lines = append(lines, styleHelp.Render("Tab/↑↓ move • ←/→ change • Enter done • Esc cancel"))
|
||||
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
|
||||
styleBox.Width(min(w-4, 78)).Render(body),
|
||||
)
|
||||
}
|
||||
|
||||
// planeDescription explains what rides each plane, so the choice is not just a
|
||||
// three-letter name.
|
||||
func planeDescription(p install.Plane) string {
|
||||
switch p {
|
||||
case install.PlaneWAN:
|
||||
return "Public ingress and egress, EIPs and NAT (br-wan)."
|
||||
case install.PlaneLAN:
|
||||
return "Storage replication, NATS mesh and OVN control (br-lan)."
|
||||
default:
|
||||
return "Geneve tunnels carrying EC2 traffic between nodes (br-vpc)."
|
||||
}
|
||||
}
|
||||
|
||||
// roleLabelWidth is the field-label column on the editor screen, shared so a
|
||||
// wrapped value can indent to line up underneath it.
|
||||
const roleLabelWidth = 14
|
||||
|
||||
func (m model) renderRoleField(f *roleForm, field roleField, width int) string {
|
||||
focused := f.focus == field
|
||||
label := styleLabel.Render(fmt.Sprintf("%-*s", roleLabelWidth, roleFieldLabel(field)))
|
||||
|
||||
switch field {
|
||||
case roleFieldNIC:
|
||||
return label + m.renderNICChoice(f, focused, width)
|
||||
|
||||
case roleFieldMethod:
|
||||
return label + renderToggle([]string{"DHCP (automatic)", "Static"}, boolToIdx(!f.dhcp), focused)
|
||||
|
||||
default:
|
||||
if in := f.input(field); in != nil {
|
||||
return label + focusMark(focused) + in.View()
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
func roleFieldLabel(field roleField) string {
|
||||
switch field {
|
||||
case roleFieldNIC:
|
||||
return "Interface"
|
||||
case roleFieldMethod:
|
||||
return "IP method"
|
||||
case roleFieldIP:
|
||||
return "IP address"
|
||||
case roleFieldMask:
|
||||
return "Subnet mask"
|
||||
case roleFieldGateway:
|
||||
return "Gateway"
|
||||
case roleFieldDNS:
|
||||
return "DNS"
|
||||
case roleFieldVLAN:
|
||||
return "VLAN id"
|
||||
case roleFieldMTU:
|
||||
return "MTU"
|
||||
case roleFieldSSID:
|
||||
return "WiFi SSID"
|
||||
default:
|
||||
return "WiFi password"
|
||||
}
|
||||
}
|
||||
|
||||
// renderNICChoice shows the selected interface on the field line and its
|
||||
// hardware identity on a line of its own beneath. Vendor and model are what
|
||||
// make a port identifiable, and they do not fit alongside the selector.
|
||||
func (m model) renderNICChoice(f *roleForm, focused bool, width int) string {
|
||||
var text, detail string
|
||||
switch {
|
||||
case f.folded():
|
||||
_, landed := m.buildConfig().Resolve(f.plane)
|
||||
text = fmt.Sprintf("(fold onto %s)", landed)
|
||||
case f.nic < len(m.nics):
|
||||
text = nicSummary(m.nics[f.nic])
|
||||
detail = nicHardware(m.nics[f.nic], width-roleLabelWidth-2)
|
||||
default:
|
||||
text = "(none detected)"
|
||||
}
|
||||
|
||||
if focused {
|
||||
text = styleSelected.Render(" ← " + text + " → ")
|
||||
} else {
|
||||
text = styleLabel.Render("[ " + text + " ]")
|
||||
}
|
||||
if detail == "" {
|
||||
return text
|
||||
}
|
||||
return text + "\n" + strings.Repeat(" ", roleLabelWidth+2) + styleMuted.Render(detail)
|
||||
}
|
||||
|
||||
func renderToggle(options []string, selected int, focused bool) string {
|
||||
var parts []string
|
||||
for i, opt := range options {
|
||||
switch {
|
||||
case i == selected && focused:
|
||||
parts = append(parts, styleSelected.Render(" "+opt+" "))
|
||||
case i == selected:
|
||||
parts = append(parts, styleLabel.Render("["+opt+"]"))
|
||||
default:
|
||||
parts = append(parts, styleMuted.Render(opt))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func boolToIdx(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fieldIndex(fields []roleField, want roleField) int {
|
||||
for i, f := range fields {
|
||||
if f == want {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// validateRole checks one plane's entries. Cross-role rules (a shared NIC
|
||||
// without distinct VLAN ids, an unbound wan) belong to install.Config.Validate
|
||||
// and run when the operator continues.
|
||||
func (m model) validateRole(idx int) string {
|
||||
f := &m.roles[idx]
|
||||
if f.folded() {
|
||||
return ""
|
||||
}
|
||||
if len(m.nics) == 0 {
|
||||
return "No network interfaces detected"
|
||||
}
|
||||
|
||||
if !f.dhcp {
|
||||
if net.ParseIP(strings.TrimSpace(f.ip.Value())) == nil {
|
||||
return "Enter a valid IP address"
|
||||
}
|
||||
if !validSubnetMask(f.mask.Value()) {
|
||||
return "Enter a dotted-decimal subnet mask, e.g. 255.255.255.0 or 255.255.0.0"
|
||||
}
|
||||
if f.plane == install.PlaneWAN && net.ParseIP(strings.TrimSpace(f.gateway.Value())) == nil {
|
||||
return "Enter a valid gateway address"
|
||||
}
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(f.vlan.Value()); v != "" {
|
||||
id, err := strconv.Atoi(v)
|
||||
if err != nil || id < 1 || id > 4094 {
|
||||
return "VLAN id must be between 1 and 4094"
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(f.mtu.Value()); v != "" {
|
||||
mtu, err := strconv.Atoi(v)
|
||||
if err != nil || mtu < 68 || mtu > 9216 {
|
||||
return "MTU must be between 68 and 9216"
|
||||
}
|
||||
}
|
||||
if m.nicIsWiFi(f.nic) && strings.TrimSpace(f.ssid.Value()) == "" {
|
||||
return "Enter the WiFi network SSID"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func capitalise(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/netprobe"
|
||||
)
|
||||
|
||||
func nics(n int) []netprobe.NIC {
|
||||
out := make([]netprobe.NIC, 0, n)
|
||||
names := []string{"eno1", "ens1f0np0", "ens1f1np1", "ens2f0np0"}
|
||||
for i := range n {
|
||||
out = append(out, netprobe.NIC{Name: names[i], Speed: "25Gbps", Vendor: "Mellanox", State: "online"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The pre-fill is the whole of the simple path: an operator with a
|
||||
// conventional node should be able to press Continue without editing anything.
|
||||
func TestNewModelPrefillsRolesByNICCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
wantLAN, wantVPC int
|
||||
}{
|
||||
{"one NIC folds everything onto wan", 1, foldedNIC, foldedNIC},
|
||||
{"two NICs dedicate the second to lan, vpc folds", 2, 1, foldedNIC},
|
||||
{"three NICs give each plane its own", 3, 1, 2},
|
||||
{"four NICs still only bind three", 4, 1, 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(tt.count))
|
||||
|
||||
if got := m.roles[0].nic; got != 0 {
|
||||
t.Errorf("wan nic = %d, want 0", got)
|
||||
}
|
||||
if got := m.roles[1].nic; got != tt.wantLAN {
|
||||
t.Errorf("lan nic = %d, want %d", got, tt.wantLAN)
|
||||
}
|
||||
if got := m.roles[2].nic; got != tt.wantVPC {
|
||||
t.Errorf("vpc nic = %d, want %d", got, tt.wantVPC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hiding rather than disabling is what keeps the default screen narrow.
|
||||
func TestVisibleFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*roleForm)
|
||||
isWiFi bool
|
||||
advanced bool
|
||||
want []roleField
|
||||
}{
|
||||
{
|
||||
name: "wan on DHCP asks two questions",
|
||||
mutate: func(f *roleForm) { f.dhcp = true },
|
||||
want: []roleField{roleFieldNIC, roleFieldMethod},
|
||||
},
|
||||
{
|
||||
name: "wan static adds a gateway",
|
||||
mutate: func(f *roleForm) { f.dhcp = false },
|
||||
want: []roleField{roleFieldNIC, roleFieldMethod, roleFieldIP, roleFieldMask, roleFieldGateway, roleFieldDNS},
|
||||
},
|
||||
{
|
||||
name: "advanced reveals VLAN and MTU",
|
||||
mutate: func(f *roleForm) { f.dhcp = true },
|
||||
advanced: true,
|
||||
want: []roleField{roleFieldNIC, roleFieldMethod, roleFieldVLAN, roleFieldMTU},
|
||||
},
|
||||
{
|
||||
name: "wireless adds credentials",
|
||||
mutate: func(f *roleForm) { f.dhcp = true },
|
||||
isWiFi: true,
|
||||
want: []roleField{roleFieldNIC, roleFieldMethod, roleFieldSSID, roleFieldWiFiPass},
|
||||
},
|
||||
{
|
||||
name: "a folded role has nothing to configure",
|
||||
mutate: func(f *roleForm) { f.nic = foldedNIC },
|
||||
want: []roleField{roleFieldNIC},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newRoleForm(install.PlaneWAN, 0)
|
||||
tt.mutate(&f)
|
||||
got := f.visibleFields(tt.isWiFi, tt.advanced)
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("visibleFields() = %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("visibleFields() = %v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A non-wan role must be able to reach the folded state, and wan must not.
|
||||
func TestCycleNICFoldReachableOnlyOffWAN(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(2))
|
||||
|
||||
// lan: 1 -> 0 -> folded -> wraps to the last NIC.
|
||||
m.roles[1].nic = 1
|
||||
for _, want := range []int{0, foldedNIC, 1} {
|
||||
m = m.cycleNIC(1, false)
|
||||
if m.roles[1].nic != want {
|
||||
t.Fatalf("lan nic = %d, want %d", m.roles[1].nic, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wan cycles only through real interfaces.
|
||||
m.roles[0].nic = 0
|
||||
for range 4 {
|
||||
m = m.cycleNIC(0, false)
|
||||
if m.roles[0].nic == foldedNIC {
|
||||
t.Fatal("wan reached the folded state, but it always needs an uplink")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToRoleReadsVLANAndMTU(t *testing.T) {
|
||||
f := newRoleForm(install.PlaneLAN, 0)
|
||||
f.dhcp = false
|
||||
f.ip.SetValue("10.0.0.2")
|
||||
f.mask.SetValue("255.255.0.0")
|
||||
f.vlan.SetValue("10")
|
||||
f.mtu.SetValue("9000")
|
||||
|
||||
role := f.toRole(nics(1))
|
||||
|
||||
if role.VLAN != 10 || role.MTU != 9000 {
|
||||
t.Errorf("VLAN/MTU = %d/%d, want 10/9000", role.VLAN, role.MTU)
|
||||
}
|
||||
if role.Link() != "eno1.10" {
|
||||
t.Errorf("Link() = %q, want eno1.10", role.Link())
|
||||
}
|
||||
// Only wan carries a default route, even if a gateway was typed earlier.
|
||||
f.gateway.SetValue("10.0.0.1")
|
||||
if got := f.toRole(nics(1)).Gateway; got != "" {
|
||||
t.Errorf("lan Gateway = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToRoleFoldedIsEmpty(t *testing.T) {
|
||||
f := newRoleForm(install.PlaneVPC, foldedNIC)
|
||||
if !f.toRole(nics(2)).Folded() {
|
||||
t.Error("a folded form must produce a folded role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plane install.Plane
|
||||
mutate func(*roleForm)
|
||||
wantErr string
|
||||
}{
|
||||
{"valid static lan", install.PlaneLAN, func(f *roleForm) {
|
||||
f.dhcp = false
|
||||
f.ip.SetValue("10.0.0.2")
|
||||
f.mask.SetValue("255.255.0.0")
|
||||
}, ""},
|
||||
{"bad ip", install.PlaneLAN, func(f *roleForm) {
|
||||
f.dhcp = false
|
||||
f.ip.SetValue("not-an-ip")
|
||||
f.mask.SetValue("255.255.0.0")
|
||||
}, "valid IP"},
|
||||
{"wan needs a gateway", install.PlaneWAN, func(f *roleForm) {
|
||||
f.dhcp = false
|
||||
f.ip.SetValue("216.218.163.99")
|
||||
f.mask.SetValue("255.255.255.224")
|
||||
}, "gateway"},
|
||||
{"VLAN out of range", install.PlaneLAN, func(f *roleForm) {
|
||||
f.dhcp = true
|
||||
f.vlan.SetValue("9000")
|
||||
}, "VLAN id must be"},
|
||||
{"MTU out of range", install.PlaneLAN, func(f *roleForm) {
|
||||
f.dhcp = true
|
||||
f.mtu.SetValue("70000")
|
||||
}, "MTU must be"},
|
||||
{"folded roles skip validation", install.PlaneVPC, func(f *roleForm) {
|
||||
f.nic = foldedNIC
|
||||
f.ip.SetValue("nonsense")
|
||||
}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(3))
|
||||
m.roles[0] = newRoleForm(tt.plane, 0)
|
||||
tt.mutate(&m.roles[0])
|
||||
|
||||
got := m.validateRole(0)
|
||||
if tt.wantErr == "" {
|
||||
if got != "" {
|
||||
t.Fatalf("validateRole() = %q, want no error", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(got, tt.wantErr) {
|
||||
t.Fatalf("validateRole() = %q, want it to mention %q", got, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Continue must refuse a layout install.Config.Validate would reject, rather
|
||||
// than failing later during the install itself.
|
||||
func TestContinueBlocksOnSharedNICWithoutVLANs(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(2))
|
||||
m.screen = screenNetworkRoles
|
||||
m.roleCursor = continueRow
|
||||
m.roles[1].nic = 0 // lan onto the wan NIC, both untagged
|
||||
|
||||
next, _ := m.handleRolesKey("enter")
|
||||
got := next.(model)
|
||||
|
||||
if got.screen != screenNetworkRoles {
|
||||
t.Error("expected to stay on the roles screen when the layout is invalid")
|
||||
}
|
||||
if !strings.Contains(got.validationErr, "both bind eno1") {
|
||||
t.Errorf("validationErr = %q, want it to name the shared interface", got.validationErr)
|
||||
}
|
||||
|
||||
// Distinct VLAN ids make the same pairing legal.
|
||||
got.roles[0].vlan.SetValue("30")
|
||||
got.roles[1].vlan.SetValue("10")
|
||||
next, _ = got.handleRolesKey("enter")
|
||||
if next.(model).screen != screenIdentity {
|
||||
t.Error("distinct VLAN ids should allow two roles to share one NIC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvancedToggle(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(3))
|
||||
m.screen = screenNetworkRoles
|
||||
|
||||
next, _ := m.handleRolesKey("a")
|
||||
if !next.(model).advanced {
|
||||
t.Fatal("'a' should turn Advanced on")
|
||||
}
|
||||
next, _ = next.(model).handleRolesKey("a")
|
||||
if next.(model).advanced {
|
||||
t.Fatal("'a' should turn Advanced back off")
|
||||
}
|
||||
}
|
||||
|
||||
// The default route and the resolvers are node-level, and wan is the only plane
|
||||
// that carries either, so the east-west editors must not ask for them. Their
|
||||
// bridges are link-local and activate manually — a resolver pinned there is
|
||||
// unreachable, and a gateway would fight the real default route.
|
||||
func TestVisibleFieldsOmitsRoutingOffWAN(t *testing.T) {
|
||||
for _, plane := range []install.Plane{install.PlaneLAN, install.PlaneVPC} {
|
||||
t.Run(string(plane), func(t *testing.T) {
|
||||
f := newRoleForm(plane, 1)
|
||||
f.dhcp = false
|
||||
got := f.visibleFields(false, false)
|
||||
|
||||
want := []roleField{roleFieldNIC, roleFieldMethod, roleFieldIP, roleFieldMask}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("visibleFields() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("visibleFields() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The mask field must accept only what netgen can consume. It previously
|
||||
// advertised and accepted a prefix length, which passed the TUI and then failed
|
||||
// the install itself with "invalid mask: 24".
|
||||
func TestValidSubnetMaskRejectsPrefixLength(t *testing.T) {
|
||||
for _, bad := range []string{"24", "/24", "8", "", "255.255.0", "255.0.255.0", "::1"} {
|
||||
if validSubnetMask(bad) {
|
||||
t.Errorf("validSubnetMask(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"255.255.255.0", "255.255.0.0", "255.0.0.0", "255.255.255.252"} {
|
||||
if !validSubnetMask(good) {
|
||||
t.Errorf("validSubnetMask(%q) = false, want true", good)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hardware detail is the point of the NIC table: an operator has to be able
|
||||
// to tell which physical port each plane landed on. It gets a line of its own,
|
||||
// under the interface column, rather than being squeezed into the row.
|
||||
func TestRoleHardwareRendersOnItsOwnLine(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, []netprobe.NIC{{
|
||||
Name: "ens1f0np0", Vendor: "Mellanox Technologies", Model: "MT27710",
|
||||
AltName: "enp132s0f0np0", Speed: "25 Gbps", Carrier: true, State: "online",
|
||||
}})
|
||||
|
||||
view := m.viewNetworkRoles(100)
|
||||
if !strings.Contains(view, "ens1f0np0 25 Gbps online") {
|
||||
t.Errorf("expected the identity line in:\n%s", view)
|
||||
}
|
||||
detail := m.roleHardware(0, bodyWidth(100))
|
||||
if !strings.Contains(detail, "Mellanox Technologies MT27710") {
|
||||
t.Errorf("roleHardware = %q, want the vendor and model", detail)
|
||||
}
|
||||
if !strings.Contains(detail, "enp132s0f0np0") {
|
||||
t.Errorf("roleHardware = %q, want the alternative name", detail)
|
||||
}
|
||||
// Every line has to fit the box, or lipgloss wraps it and the columns shear.
|
||||
for line := range strings.SplitSeq(view, "\n") {
|
||||
if len([]rune(line)) > 100 {
|
||||
t.Errorf("line exceeds the terminal width: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A narrow box drops the alternative name before it truncates the model, since
|
||||
// half a model name identifies nothing.
|
||||
func TestNICHardwareDropsAltNameBeforeTruncating(t *testing.T) {
|
||||
n := netprobe.NIC{Vendor: "Broadcom Inc. and subsidiaries", Model: "NetXtreme BCM5720", AltName: "enp1s0f1"}
|
||||
|
||||
if got := nicHardware(n, 60); got != "Broadcom Inc. and subsidiaries NetXtreme BCM5720" {
|
||||
t.Errorf("nicHardware(60) = %q, want the name without the alt name", got)
|
||||
}
|
||||
if got := nicHardware(n, 20); got != "Broadcom Inc. and s…" {
|
||||
t.Errorf("nicHardware(20) = %q, want a marked truncation", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A role bound to a port with no hardware database entry still gets something
|
||||
// to identify the card by, never the bare word "unknown".
|
||||
func TestRoleHardwareFallsBackToDriver(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, []netprobe.NIC{{
|
||||
Name: "eno1", Driver: "bnxt_en", DeviceID: "14e4:165f", State: "no cable",
|
||||
}})
|
||||
|
||||
if got := m.roleHardware(0, bodyWidth(100)); !strings.Contains(got, "bnxt_en [14e4:165f]") {
|
||||
t.Errorf("roleHardware = %q, want the driver and device id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A folded role has no interface, so it must not render an orphan detail line
|
||||
// under the plane it collapsed onto.
|
||||
func TestRoleHardwareEmptyWhenFolded(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda"}}, nics(1))
|
||||
if got := m.roleHardware(1, bodyWidth(100)); got != "" {
|
||||
t.Errorf("roleHardware for a folded role = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// diskModel is a storage screen with n unselected disks and ext4 chosen, which
|
||||
// is where the wizard starts.
|
||||
func diskModel(n int) model {
|
||||
m := model{fsCursor: slices.Index(install.AllFSTypes, install.FSExt4)}
|
||||
for i := range n {
|
||||
m.disks = append(m.disks, install.Disk{
|
||||
Path: string(rune('a'+i)) + "-disk",
|
||||
Bytes: 200 << 30,
|
||||
LogicalBlockSize: 512,
|
||||
PhysicalBlockSize: 512,
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func rolePaths(cfg install.DiskConfig) map[install.DiskRole]string {
|
||||
out := map[install.DiskRole]string{}
|
||||
for _, rm := range cfg.Roles {
|
||||
out[rm.Role] = rm.Disk.Path
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A single-disk machine should need no storage input at all, so the disk that
|
||||
// newModel preselects has to arrive already holding the os role.
|
||||
func TestPreselectedDiskIsAValidConfiguration(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda", Bytes: 200 << 30, LogicalBlockSize: 512}}, nil)
|
||||
cfg := m.storage()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("the preselected disk must be installable as-is: %v", err)
|
||||
}
|
||||
if cfg.Primary().Path != "/dev/sda" {
|
||||
t.Errorf("Primary() = %q, want the preselected disk", cfg.Primary().Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Picking disks in order gives the layout an operator almost always wants, so
|
||||
// the role key is only needed for the exceptions.
|
||||
func TestSelectingDisksAssignsRolesInOrder(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
got := rolePaths(m.storage())
|
||||
for role, want := range map[install.DiskRole]string{
|
||||
install.RoleOS: "a-disk", install.RoleSpinifex: "b-disk", install.RolePredastore: "c-disk",
|
||||
} {
|
||||
if got[role] != want {
|
||||
t.Errorf("%s = %q, want %q", role, got[role], want)
|
||||
}
|
||||
}
|
||||
if err := m.storage().Validate(); err != nil {
|
||||
t.Errorf("the default assignment must be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deselecting has to drop the role with the disk, or every later role shifts
|
||||
// onto the wrong drive.
|
||||
func TestDeselectingADiskReleasesItsRole(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
m = m.toggleDisk(1) // drop the spinifex drive
|
||||
|
||||
got := rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "c-disk" {
|
||||
t.Fatalf("remaining roles moved: %v", got)
|
||||
}
|
||||
if _, ok := got[install.RoleSpinifex]; ok {
|
||||
t.Errorf("the spinifex role should be free again: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The two-drive predastore layout is only reachable by reassigning, so cycling
|
||||
// swaps rather than refusing a role another disk already holds.
|
||||
func TestCycleRoleSwapsWithTheDiskThatHoldsIt(t *testing.T) {
|
||||
m := diskModel(2)
|
||||
m = m.toggleDisk(0).toggleDisk(1)
|
||||
// b-disk: spinifex → predastore, which no disk holds, so no swap.
|
||||
m = m.cycleRole(1)
|
||||
|
||||
got := rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "b-disk" {
|
||||
t.Fatalf("roles = %v, want os on a-disk and predastore on b-disk", got)
|
||||
}
|
||||
|
||||
// Cycling past the end wraps onto os, which a-disk holds, so they trade.
|
||||
m = m.cycleRole(1)
|
||||
got = rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "b-disk" || got[install.RolePredastore] != "a-disk" {
|
||||
t.Fatalf("roles = %v, want the two disks to have swapped", got)
|
||||
}
|
||||
if err := m.storage().Validate(); err != nil {
|
||||
t.Errorf("a swap must always leave a valid assignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ZFS pairs mirrors by selection order and has no roles to assign.
|
||||
func TestStorageLeavesRolesUnsetOnZFS(t *testing.T) {
|
||||
m := diskModel(2)
|
||||
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
|
||||
m = m.toggleDisk(0).toggleDisk(1)
|
||||
|
||||
cfg := m.storage()
|
||||
if len(cfg.Roles) != 0 {
|
||||
t.Errorf("Roles = %v, want none on a pool", cfg.Roles)
|
||||
}
|
||||
if !slices.Equal(cfg.Paths(), []string{"a-disk", "b-disk"}) {
|
||||
t.Errorf("Paths() = %v, want the selection order preserved", cfg.Paths())
|
||||
}
|
||||
}
|
||||
|
||||
// A three-drive selection looks like an array and is not one, so the mapping
|
||||
// and the absence of redundancy both have to be on screen before the erase.
|
||||
func TestConfirmScreenStatesTheLayoutAndTheRisk(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
m.height = 40
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
got := m.viewDiskConfirm(100)
|
||||
|
||||
for _, want := range []string{"/var/lib/spinifex/predastore", "c-disk", "losing one loses"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("confirm screen is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,17 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/lbagent"
|
||||
"github.com/mulgadc/spinifex/spinifex/otelsetup"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Without this the agent falls back to slog's default text handler, which folds
|
||||
// attributes into the message so the console scraper recovers a level and nothing
|
||||
// else. No OTLP export: the agent's only egress is the gateway it is watching.
|
||||
otelsetup.SetDefaultJSONLogger(slog.LevelInfo)
|
||||
|
||||
var (
|
||||
lbID string
|
||||
gatewayURL string
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/rdsgw"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
|
||||
// The completion receipt rds-init writes once the master role is durably
|
||||
// applied. Deliberately outside PGDATA: clear_unfinished_datadir does an rm -rf
|
||||
// of PGDATA, so keeping the receipt out of it means no initdb-adjacent path can
|
||||
// touch it and the datadir stays a byte-for-byte stock one.
|
||||
const (
|
||||
receiptRelDir = ".spinifex-rds/bootstrap"
|
||||
receiptFile = "receipt.env"
|
||||
receiptPayload = "RDS_RECEIPT_PAYLOAD_ID"
|
||||
receiptDBID = "RDS_RECEIPT_DB_INSTANCE_IDENTIFIER"
|
||||
)
|
||||
|
||||
func (a *Agent) receiptPath() string {
|
||||
return filepath.Join(a.cfg.DataMount, receiptRelDir, receiptFile)
|
||||
}
|
||||
|
||||
// Confirms the initial bootstrap, which is what destroys the staged ciphertext.
|
||||
// A no-op on the attach path, and deliberately synchronous: it inherits the
|
||||
// cancellation semantics register, bootstrap and the data-mount wait already
|
||||
// share, and blocking the stateful loops behind it is correct because on a first
|
||||
// boot the engine is not bootstrapped until rds-init has finished anyway.
|
||||
//
|
||||
// It does not wait for the engine to be healthy. The receipt already proves the
|
||||
// master role was durably applied, and acknowledging early shortens the window
|
||||
// in which the ciphertext exists.
|
||||
func (a *Agent) acknowledgeBootstrap(ctx context.Context) error {
|
||||
pending := a.pending
|
||||
if pending == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Polled rather than waited on: rds-init runs after this agent registered,
|
||||
// so the file appears some time into the boot. There is no give-up deadline
|
||||
// here — the control plane's bootstrap timeout already fails the instance,
|
||||
// and a second one in the guest would only give the two a way to disagree.
|
||||
if err := retryObserved(ctx, "bootstrap receipt", func(context.Context) error {
|
||||
return a.checkBootstrapReceipt(pending)
|
||||
}, func(err error) {
|
||||
// An absent receipt is the expected state for the whole initdb window,
|
||||
// so reporting it would put a failure on every healthy create's beat and
|
||||
// then into the reason a timed-out create records.
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
a.hb.setBootstrapFailure("bootstrap receipt", err)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
|
||||
if err := retryObserved(ctx, "bootstrap acknowledgement", func(ctx context.Context) error {
|
||||
return terminalOnDenial(a.cp.AcknowledgeBootstrap(ctx, a.id, pending))
|
||||
}, func(err error) {
|
||||
a.hb.setBootstrapFailure("bootstrap acknowledgement", err)
|
||||
}); err != nil {
|
||||
if !errors.Is(err, errRetryTerminal) {
|
||||
return err
|
||||
}
|
||||
// The staged payload will never be delivered to this VM, and blocking the
|
||||
// command poller and the parameter guard on that would leave a serving
|
||||
// database no one can administer. The reason rides the heartbeat instead.
|
||||
slog.ErrorContext(ctx, "rds-agent: the initial bootstrap cannot be acknowledged",
|
||||
"payloadId", pending.payloadID, "err", err)
|
||||
return nil
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
|
||||
// AcknowledgedAt on the record is the durable audit trail, so the receipt is
|
||||
// removed rather than archived: keeping it would only grow the surface of
|
||||
// stale receipts riding along in data-volume snapshots. A removal failure is
|
||||
// harmless, since a later boot fetches attach and never consults one.
|
||||
if err := os.Remove(a.receiptPath()); err != nil && !os.IsNotExist(err) {
|
||||
slog.WarnContext(ctx, "rds-agent: could not remove the bootstrap receipt",
|
||||
"receipt", a.receiptPath(), "err", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "rds-agent: initial bootstrap acknowledged", "payloadId", pending.payloadID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The control plane resolved these from its own record — a superseded
|
||||
// generation, a payload it never staged, a data volume that is not this
|
||||
// instance's. Asking again cannot change any of them.
|
||||
func terminalOnDenial(err error) error {
|
||||
var apiErr *rdsgw.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
switch apiErr.Code {
|
||||
case awserrors.ErrorAccessDenied, awserrors.ErrorInvalidParameterValue:
|
||||
return fmt.Errorf("%w: %w", errRetryTerminal, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// A receipt naming another payload or another DB instance is treated as absent.
|
||||
// The receipt lives on the data volume, so it rides along in every snapshot of
|
||||
// it and a restored instance's volume carries the source instance's receipt.
|
||||
func (a *Agent) checkBootstrapReceipt(pending *pendingBootstrap) error {
|
||||
path := a.receiptPath()
|
||||
values, err := readReceipt(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := values[receiptPayload]; got != pending.payloadID {
|
||||
return fmt.Errorf("%s names bootstrap payload %q, not %q", path, got, pending.payloadID)
|
||||
}
|
||||
if got := values[receiptDBID]; got != a.id.DBInstanceIdentifier {
|
||||
return fmt.Errorf("%s names DB instance %q, not %q", path, got, a.id.DBInstanceIdentifier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KEY=value, the one format both readers can parse: rds-init in POSIX shell
|
||||
// with no guaranteed jq, and this.
|
||||
func readReceipt(path string) (map[string]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/rdsgw"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// Overridable via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// The gateway resolves the authoritative DB instance from the request's
|
||||
// credentials, so DBInstanceIdentifier is an assertion to be checked, not
|
||||
// trusted.
|
||||
type identity struct {
|
||||
DBInstanceIdentifier string
|
||||
AgentVersion string
|
||||
EngineVersion string
|
||||
}
|
||||
|
||||
// Every method is a SigV4-signed Query request, so the NATS bus stays
|
||||
// host-internal.
|
||||
type controlPlane interface {
|
||||
Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error)
|
||||
SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error)
|
||||
// Returns Mode=initialize with the master password for as long as a payload
|
||||
// staged for this VM generation is unacknowledged, and Mode=attach after.
|
||||
GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error)
|
||||
// Reports that PostgreSQL durably applied the staged password, which is what
|
||||
// lets the control plane destroy it.
|
||||
AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error
|
||||
PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error)
|
||||
}
|
||||
|
||||
// Bounds a register, heartbeat or bootstrap request. The long poll sets its own
|
||||
// deadline from the wait it asked the gateway to hold.
|
||||
const callTimeout = 15 * time.Second
|
||||
|
||||
// Keeps the poll's deadline past the requested wait, so a gateway answering at
|
||||
// the end of its window is not cut off by the client.
|
||||
const pollSlack = 10 * time.Second
|
||||
|
||||
type gatewayControlPlane struct {
|
||||
client *rdsgw.Client
|
||||
}
|
||||
|
||||
var _ controlPlane = (*gatewayControlPlane)(nil)
|
||||
|
||||
// Signs with the instance-role credentials the SDK chain resolves from IMDS,
|
||||
// against the pinned gateway CA.
|
||||
func newGatewayControlPlane(cfg config) (*gatewayControlPlane, error) {
|
||||
signer, err := gwsign.NewIMDS(context.Background(), cfg.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMDS signer: %w", err)
|
||||
}
|
||||
// The client timeout is the ceiling for the longest call, the long poll;
|
||||
// shorter calls carry their own deadline.
|
||||
client, err := rdsgw.New(cfg.GatewayURL, cfg.GatewayCA, signer, cfg.Region, cfg.PollWait+pollSlack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayControlPlane{client: client}, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
|
||||
params := url.Values{}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "AgentVersion", id.AgentVersion)
|
||||
setIfPresent(params, "EngineVersion", id.EngineVersion)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.RegisterDBInstanceOutput
|
||||
if err := g.client.Call(ctx, "RegisterDBInstance", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
|
||||
params := url.Values{"EngineHealth": {string(health)}}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "EngineVersion", id.EngineVersion)
|
||||
setIfPresent(params, "Message", message)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.SubmitDBStateChangeOutput
|
||||
if err := g.client.Call(ctx, "SubmitDBStateChange", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
|
||||
params := url.Values{}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.GetDBBootstrapConfigOutput
|
||||
if err := g.client.Call(ctx, "GetDBBootstrapConfig", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error {
|
||||
params := url.Values{
|
||||
"PayloadId": {pending.payloadID},
|
||||
"VMGeneration": {strconv.FormatInt(pending.vmGeneration, 10)},
|
||||
}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "DataVolumeId", pending.dataVolumeID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.AcknowledgeDBBootstrapOutput
|
||||
if err := g.client.Call(ctx, "AcknowledgeDBBootstrap", params, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !out.Acknowledged {
|
||||
return fmt.Errorf("the control plane did not acknowledge bootstrap payload %s", pending.payloadID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Declared here so the in-guest binary does not link the server side of the API.
|
||||
type pollDBCommandsOutput struct {
|
||||
Commands []handlers_rds.Command `xml:"Commands>member"`
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error) {
|
||||
params := url.Values{"WaitTimeSeconds": {strconv.FormatInt(int64(wait.Seconds()), 10)}}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
// Replies ride the poll rather than a separate action, so replying and
|
||||
// asking for the next command are one round trip.
|
||||
for i, reply := range replies {
|
||||
member := fmt.Sprintf("Replies.member.%d.", i+1)
|
||||
params.Set(member+"CommandId", reply.CommandID)
|
||||
params.Set(member+"Status", reply.Status)
|
||||
setIfPresent(params, member+"Message", reply.Message)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, wait+pollSlack)
|
||||
defer cancel()
|
||||
|
||||
var out pollDBCommandsOutput
|
||||
if err := g.client.Call(ctx, "PollDBCommands", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Commands, nil
|
||||
}
|
||||
|
||||
// Only sets a parameter that has a value, so the gateway can tell "no
|
||||
// assertion" from "asserted as blank".
|
||||
func setIfPresent(params url.Values, key, value string) {
|
||||
if value != "" {
|
||||
params.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
cfg config
|
||||
id identity
|
||||
cp controlPlane
|
||||
probe *engineProbe
|
||||
engine *postgresEngine
|
||||
handoffWriter func(string, *handlers_rds.GetDBBootstrapConfigOutput) error
|
||||
dataMountWaiter func(context.Context, string, string) error
|
||||
|
||||
// Set by bootstrap when the fetch replayed a staged payload; nil leaves the
|
||||
// acknowledgement step a no-op, which is every attach boot.
|
||||
pending *pendingBootstrap
|
||||
|
||||
hb *heartbeater
|
||||
cmd *commander
|
||||
guard *paramGuard
|
||||
}
|
||||
|
||||
// Assembles from already-built parts, so tests can pass fakes; New builds the
|
||||
// production control plane and delegates here.
|
||||
func newAgent(cfg config, cp controlPlane, probe *engineProbe) *Agent {
|
||||
if cfg.DataMount == "" {
|
||||
cfg.DataMount = defaultDataMount
|
||||
}
|
||||
if cfg.MountsFile == "" {
|
||||
cfg.MountsFile = defaultMountsFile
|
||||
}
|
||||
id := identity{
|
||||
DBInstanceIdentifier: cfg.DBInstanceIdentifier,
|
||||
AgentVersion: version,
|
||||
EngineVersion: cfg.EngineVersion,
|
||||
}
|
||||
a := &Agent{
|
||||
cfg: cfg, id: id, cp: cp, probe: probe,
|
||||
handoffWriter: writeHandoff, dataMountWaiter: waitForDataMount,
|
||||
}
|
||||
a.engine = newPostgresEngine(cfg, execCommandRunner, execSessionRunner, probe)
|
||||
a.hb = newHeartbeater(cp, probe, a.engine, handlers_rds.HeartbeatInterval)
|
||||
a.cmd = newCommander(cp, newCommandRegistry(a.engine, newGuestStorage(cfg, execCommandRunner)), cfg.PollWait)
|
||||
a.guard = newParamGuard(a.engine, probe, cp)
|
||||
return a
|
||||
}
|
||||
|
||||
// Does not wait for IMDS: the register loop rides out a datapath still coming up.
|
||||
func New(cfg config) (*Agent, error) {
|
||||
if cfg.GatewayURL == "" {
|
||||
return nil, fmt.Errorf("no gateway URL configured (RDS_GATEWAY_URL)")
|
||||
}
|
||||
cp, err := newGatewayControlPlane(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newAgent(cfg, cp, newEngineProbe(cfg, execProbeRunner)), nil
|
||||
}
|
||||
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
// Named before the first dial. When register cannot reach the gateway the
|
||||
// retry loop is all that survives, and the address it is dialing is what
|
||||
// separates a mgmt NIC that came up without an address from a control plane
|
||||
// that is genuinely down.
|
||||
slog.Info("rds-agent: starting",
|
||||
"gatewayURL", a.cfg.GatewayURL, "dbInstanceIdentifier", a.id.DBInstanceIdentifier,
|
||||
"agentVersion", a.id.AgentVersion)
|
||||
|
||||
// Registration comes first: its response is the agent's identity.
|
||||
if err := a.register(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.id, a.cmd.id, a.guard.id = a.id, a.id, a.id
|
||||
|
||||
// Beating before the bootstrap keeps a stuck boot visible as a live VM with
|
||||
// a down engine rather than as silence.
|
||||
go a.hb.Run(ctx)
|
||||
|
||||
if err := a.bootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The handoff must land before this wait: rds-datadir needs it to identify
|
||||
// and authorize the volume. Commands and the parameter guard wait because
|
||||
// their durable state belongs on that mount, never on the disposable boot disk.
|
||||
if err := a.dataMountWaiter(ctx, a.cfg.MountsFile, a.cfg.DataMount); err != nil {
|
||||
// A shutdown cancels this wait; main treats the wrapped context.Canceled
|
||||
// as a clean stop, exactly as it does for register and bootstrap above.
|
||||
return fmt.Errorf("wait for data mount %s: %w", a.cfg.DataMount, err)
|
||||
}
|
||||
|
||||
// The receipt it waits on lives on that mount, so this cannot run any earlier.
|
||||
// A cancelled wait is a clean stop here too.
|
||||
if err := a.acknowledgeBootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Directives are only meaningful against a bootstrapped, mounted engine.
|
||||
go a.cmd.Run(ctx)
|
||||
|
||||
// Started after the handoff, so the window it measures covers the engine's
|
||||
// own start rather than the time rds-init spent waiting for this agent.
|
||||
go a.guard.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
const dataMountPollInterval = time.Second
|
||||
|
||||
// waitForDataMount blocks the stateful agent loops until the kernel reports the
|
||||
// configured data mount. Registration, heartbeat, and bootstrap run before it.
|
||||
func waitForDataMount(ctx context.Context, mountsFile, target string) error {
|
||||
ticker := time.NewTicker(dataMountPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
mounted, err := mountTableContains(mountsFile, target)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "rds-agent: could not read mount table while waiting for data volume",
|
||||
"mountsFile", mountsFile, "dataMount", target, "err", err)
|
||||
} else if mounted {
|
||||
slog.InfoContext(ctx, "rds-agent: data volume mount is ready", "dataMount", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mountTableContains(path, target string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) >= 2 && unescapeMountField(fields[1]) == target {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Linux mount tables encode whitespace and backslashes as octal escapes.
|
||||
func unescapeMountField(field string) string {
|
||||
replacer := strings.NewReplacer(`\134`, `\`, `\040`, " ", `\011`, "\t", `\012`, "\n")
|
||||
return replacer.Replace(field)
|
||||
}
|
||||
|
||||
// Boot-retry bounds. Tight to start, since the usual cause is a control plane
|
||||
// still creating this instance's record, and capped so an outage does not turn
|
||||
// a booting fleet into a retry storm.
|
||||
const (
|
||||
retryMin = 1 * time.Second
|
||||
retryMax = 30 * time.Second
|
||||
|
||||
// With the production backoff, attempt five is about 15 seconds into the
|
||||
// failure and still precedes the first 30-second heartbeat.
|
||||
retryErrorAttempt = 5
|
||||
)
|
||||
|
||||
// Wrapping an error in this stops retryObserved. For the failures the control
|
||||
// plane decides from its own record, no retry can change the answer, and looping
|
||||
// on one only keeps the caller blocked.
|
||||
var errRetryTerminal = errors.New("terminal failure")
|
||||
|
||||
// Never gives up on its own: an agent that stopped trying would leave the
|
||||
// control plane with no signal at all.
|
||||
func retry(ctx context.Context, what string, fn func(context.Context) error) error {
|
||||
return retryObserved(ctx, what, fn, nil)
|
||||
}
|
||||
|
||||
// onFailure lets boot-critical callers publish the latest error while the retry
|
||||
// remains blocked. It must not block because it runs on the retry path itself.
|
||||
func retryObserved(ctx context.Context, what string, fn func(context.Context) error, onFailure func(error)) error {
|
||||
delay := retryMin
|
||||
for attempt := 1; ; attempt++ {
|
||||
err := fn(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s: %w", what, ctx.Err())
|
||||
}
|
||||
if onFailure != nil {
|
||||
onFailure(err)
|
||||
}
|
||||
if errors.Is(err, errRetryTerminal) {
|
||||
slog.ErrorContext(ctx, "rds-agent: "+what+" failed terminally, not retrying", "err", err)
|
||||
return fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
if attempt >= retryErrorAttempt {
|
||||
slog.ErrorContext(ctx, "rds-agent: "+what+" still failing, retrying",
|
||||
"attempt", attempt, "retryIn", delay, "err", err)
|
||||
} else {
|
||||
slog.WarnContext(ctx, "rds-agent: "+what+" failed, retrying",
|
||||
"attempt", attempt, "retryIn", delay, "err", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("%s: %w", what, ctx.Err())
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay = min(delay*2, retryMax)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/rdsgw"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeControlPlane is a scriptable controlPlane. Every call records what the
|
||||
// agent sent, so a test asserts on the request as well as the reaction.
|
||||
type fakeControlPlane struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registerOut *handlers_rds.RegisterDBInstanceOutput
|
||||
registerErrs []error
|
||||
registerReqs []identity
|
||||
|
||||
bootstrapOut *handlers_rds.GetDBBootstrapConfigOutput
|
||||
bootstrapErrs []error
|
||||
bootstrapReqs []identity
|
||||
// Scripted responses ahead of bootstrapOut, so a test can answer the first
|
||||
// fetch differently from the ones the agent retries with.
|
||||
bootstrapQueue []*handlers_rds.GetDBBootstrapConfigOutput
|
||||
|
||||
ackErrs []error
|
||||
ackReqs []pendingBootstrap
|
||||
|
||||
states []submittedState
|
||||
|
||||
pollReplies [][]handlers_rds.CommandReply
|
||||
pollQueue [][]handlers_rds.Command
|
||||
pollErr error
|
||||
polled chan struct{}
|
||||
}
|
||||
|
||||
type submittedState struct {
|
||||
id identity
|
||||
health handlers_rds.EngineHealth
|
||||
message string
|
||||
}
|
||||
|
||||
var _ controlPlane = (*fakeControlPlane)(nil)
|
||||
|
||||
func newFakeControlPlane() *fakeControlPlane {
|
||||
return &fakeControlPlane{
|
||||
registerOut: &handlers_rds.RegisterDBInstanceOutput{
|
||||
DBInstanceIdentifier: "db-resolved", HeartbeatIntervalSeconds: 30,
|
||||
},
|
||||
polled: make(chan struct{}, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// nextErr pops the next scripted error, so a test can fail a call n times and
|
||||
// then let it succeed.
|
||||
func nextErr(errs *[]error) error {
|
||||
if len(*errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := (*errs)[0]
|
||||
*errs = (*errs)[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) Register(_ context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.registerReqs = append(f.registerReqs, id)
|
||||
if err := nextErr(&f.registerErrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.registerOut, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) SubmitState(_ context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.states = append(f.states, submittedState{id: id, health: health, message: message})
|
||||
// Zero means "keep the cadence you have", so a test's fast interval is not
|
||||
// reset by every beat.
|
||||
return &handlers_rds.SubmitDBStateChangeOutput{Acknowledged: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) GetBootstrapConfig(_ context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.bootstrapReqs = append(f.bootstrapReqs, id)
|
||||
if err := nextErr(&f.bootstrapErrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(f.bootstrapQueue) > 0 {
|
||||
out := f.bootstrapQueue[0]
|
||||
f.bootstrapQueue = f.bootstrapQueue[1:]
|
||||
return out, nil
|
||||
}
|
||||
return f.bootstrapOut, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) AcknowledgeBootstrap(_ context.Context, _ identity, pending *pendingBootstrap) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ackReqs = append(f.ackReqs, *pending)
|
||||
return nextErr(&f.ackErrs)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) PollCommands(ctx context.Context, _ identity, replies []handlers_rds.CommandReply, _ time.Duration) ([]handlers_rds.Command, error) {
|
||||
f.mu.Lock()
|
||||
f.pollReplies = append(f.pollReplies, slices.Clone(replies))
|
||||
err := f.pollErr
|
||||
var out []handlers_rds.Command
|
||||
if len(f.pollQueue) > 0 {
|
||||
out, f.pollQueue = f.pollQueue[0], f.pollQueue[1:]
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
select {
|
||||
case f.polled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
// An idle window: block until the agent hangs up, as the gateway's long poll
|
||||
// does, rather than spinning the caller's loop.
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) ackRequests() []pendingBootstrap {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return slices.Clone(f.ackReqs)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) snapshotStates() []submittedState {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]submittedState(nil), f.states...)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) snapshotReplies() [][]handlers_rds.CommandReply {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([][]handlers_rds.CommandReply, len(f.pollReplies))
|
||||
for i := range f.pollReplies {
|
||||
out[i] = slices.Clone(f.pollReplies[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// testConfig points the agent's file output at a temp dir and its probe at a
|
||||
// name no real binary answers to.
|
||||
func testConfig(t *testing.T) config {
|
||||
t.Helper()
|
||||
return config{
|
||||
GatewayURL: "https://gw.test",
|
||||
HandoffDir: filepath.Join(t.TempDir(), "spinifex-rds"),
|
||||
EngineHost: defaultEngineHost,
|
||||
EnginePort: defaultEnginePort,
|
||||
PGIsReady: "pg_isready",
|
||||
PollWait: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// staticProbe is a probeRunner that always reports the same exit code.
|
||||
func staticProbe(code int) probeRunner {
|
||||
return func(context.Context, string, ...string) (int, error) { return code, nil }
|
||||
}
|
||||
|
||||
func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput {
|
||||
mode := handlers_rds.BootstrapModeAttach
|
||||
if password != nil {
|
||||
mode = handlers_rds.BootstrapModeInitialize
|
||||
}
|
||||
return &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: mode,
|
||||
DBInstanceIdentifier: "db-resolved",
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
MasterUserPassword: password,
|
||||
Port: 5432,
|
||||
DataVolumeID: "vol-data-01",
|
||||
DataVolumeSerial: "voldata01",
|
||||
VMGeneration: 1,
|
||||
PayloadID: testPayloadID,
|
||||
BootstrapPending: password != nil,
|
||||
}
|
||||
}
|
||||
|
||||
const testPayloadID = "bp-0123456789abcdef"
|
||||
|
||||
// Writes the completion receipt rds-init leaves behind, so the acknowledgement
|
||||
// step finds what a finished initialization would have produced.
|
||||
func writeReceipt(t *testing.T, a *Agent, payloadID, dbInstanceIdentifier string) {
|
||||
t.Helper()
|
||||
path := a.receiptPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir receipt dir: %v", err)
|
||||
}
|
||||
body := fmt.Sprintf("%s=%s\n%s=%s\n", receiptPayload, payloadID, receiptDBID, dbInstanceIdentifier)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runAgent runs the agent until its bootstrap has landed, then cancels. It
|
||||
// returns once Run has exited so no goroutine outlives the test.
|
||||
func runAgent(t *testing.T, a *Agent) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
_, err := os.Stat(filepath.Join(a.cfg.HandoffDir, handoffEnvFile))
|
||||
return err == nil
|
||||
}, "handoff file to be written")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
// The cancellation above is this helper's own, so Run reporting it is the
|
||||
// expected clean stop rather than a failure, exactly as main treats it.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run did not exit after cancellation")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls cond until it holds or the test times out.
|
||||
func waitFor(t *testing.T, cond func() bool, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func TestRun_RegistersThenDeliversBootstrap(t *testing.T) {
|
||||
password := "s3cret"
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
if err := runAgent(t, a); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(cp.registerReqs) != 1 {
|
||||
t.Fatalf("registered %d times, want 1", len(cp.registerReqs))
|
||||
}
|
||||
// The agent had no configured identifier, so the gateway's resolution is
|
||||
// what every later call must carry.
|
||||
if got := cp.bootstrapReqs[0].DBInstanceIdentifier; got != "db-resolved" {
|
||||
t.Errorf("bootstrap sent identifier %q, want the registered db-resolved", got)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
|
||||
if !strings.Contains(env, "RDS_MODE='initialize'") || !strings.Contains(env, "RDS_MASTER_PASSWORD='s3cret'") {
|
||||
t.Errorf("handoff env = %q, want initialize mode carrying the password", env)
|
||||
}
|
||||
// The probe follows the port the control plane assigned, not the default.
|
||||
if got := a.probe.port.Load(); got != 5432 {
|
||||
t.Errorf("probe port = %d, want 5432", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A reboot re-fetches in attach mode. The agent must write that payload as it
|
||||
// stands rather than refusing it or inventing a password.
|
||||
func TestRun_AttachModeNeedsNoPassword(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
|
||||
if !strings.Contains(env, "RDS_MODE='attach'") {
|
||||
t.Errorf("handoff env = %q, want attach mode", env)
|
||||
}
|
||||
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
|
||||
t.Errorf("handoff env = %q, want no password key at all in attach mode", env)
|
||||
}
|
||||
}
|
||||
|
||||
// The record may not exist yet when a freshly launched VM first calls, so both
|
||||
// boot-critical calls have to ride out failures instead of exiting.
|
||||
func TestRun_RetriesRegisterAndBootstrap(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerErrs = []error{errors.New("DBInstanceNotFound")}
|
||||
cp.bootstrapErrs = []error{errors.New("ServerInternal")}
|
||||
|
||||
cfg := testConfig(t)
|
||||
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(cp.registerReqs) != 2 {
|
||||
t.Errorf("register attempts = %d, want 2 (one failure, one success)", len(cp.registerReqs))
|
||||
}
|
||||
if len(cp.bootstrapReqs) != 2 {
|
||||
t.Errorf("bootstrap attempts = %d, want 2 (one failure, one success)", len(cp.bootstrapReqs))
|
||||
}
|
||||
}
|
||||
|
||||
// The fetch mutates nothing, so a handoff that cannot be written is retried
|
||||
// against the payload already in hand rather than costing a second fetch.
|
||||
func TestBootstrap_RetriesHandoffWithoutRefetching(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
password := "staged-secret"
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
|
||||
writes := 0
|
||||
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
writes++
|
||||
if got.MasterUserPassword == nil || *got.MasterUserPassword != password {
|
||||
t.Fatalf("handoff attempt %d lost the initialize password", writes)
|
||||
}
|
||||
if writes == 1 {
|
||||
return errors.New("temporary tmpfs write error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.bootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if len(cp.bootstrapReqs) != 1 {
|
||||
t.Errorf("bootstrap fetches = %d, want 1", len(cp.bootstrapReqs))
|
||||
}
|
||||
if writes != 2 {
|
||||
t.Errorf("handoff writes = %d, want 2", writes)
|
||||
}
|
||||
if a.pending == nil || a.pending.payloadID != testPayloadID {
|
||||
t.Errorf("pending bootstrap = %+v, want the staged payload %s", a.pending, testPayloadID)
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon that predates the encrypted payload answers initialize with nothing
|
||||
// to initialise with. Writing that handoff would let rds-init run initdb with an
|
||||
// empty master password, so it is retried until an upgraded node answers.
|
||||
func TestBootstrap_RetriesInitializeWithNoPassword(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
empty := ""
|
||||
password := "staged-secret"
|
||||
cp.bootstrapQueue = []*handlers_rds.GetDBBootstrapConfigOutput{{
|
||||
Mode: handlers_rds.BootstrapModeInitialize, DBInstanceIdentifier: "db-resolved",
|
||||
MasterUsername: "master", MasterUserPassword: &empty,
|
||||
}}
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
|
||||
var handed []*handlers_rds.GetDBBootstrapConfigOutput
|
||||
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
handed = append(handed, got)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.bootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if len(handed) != 1 {
|
||||
t.Fatalf("handoff writes = %d, want 1 once a usable payload arrived", len(handed))
|
||||
}
|
||||
if handed[0].MasterUserPassword == nil || *handed[0].MasterUserPassword != password {
|
||||
t.Error("the agent wrote a handoff for a fetch that carried no master password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeBootstrap_WaitsForTheReceiptThenRetiresIt(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1, dataVolumeID: "vol-data-01"}
|
||||
|
||||
// rds-init runs after the agent registers, so the receipt is absent when the
|
||||
// acknowledgement step first looks and appears some way into the boot.
|
||||
if err := a.checkBootstrapReceipt(a.pending); err == nil {
|
||||
t.Fatal("a missing receipt must not read as a completed bootstrap")
|
||||
}
|
||||
writeReceipt(t, a, testPayloadID, "db-resolved")
|
||||
|
||||
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap: %v", err)
|
||||
}
|
||||
if len(cp.ackRequests()) != 1 {
|
||||
t.Fatalf("acknowledgements = %d, want 1", len(cp.ackRequests()))
|
||||
}
|
||||
if got := cp.ackRequests()[0]; got != *a.pending {
|
||||
t.Errorf("acknowledged %+v, want %+v", got, *a.pending)
|
||||
}
|
||||
if _, err := os.Stat(a.receiptPath()); !os.IsNotExist(err) {
|
||||
t.Error("the receipt must be removed once the record holds the audit trail")
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt rides along in every snapshot of the data volume, so a restored
|
||||
// instance's volume carries the source instance's receipt. Acknowledging on one
|
||||
// would destroy the ciphertext this instance still needs.
|
||||
func TestAcknowledgeBootstrap_IgnoresAForeignReceipt(t *testing.T) {
|
||||
tests := map[string]struct{ payloadID, dbID string }{
|
||||
"another payload": {payloadID: "bp-somebody-else", dbID: "db-resolved"},
|
||||
"another instance": {payloadID: testPayloadID, dbID: "db-the-snapshot-source"},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
writeReceipt(t, a, tc.payloadID, tc.dbID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err == nil {
|
||||
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting on a real receipt")
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 0 {
|
||||
t.Errorf("acknowledgements = %d, want 0", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An attach boot has nothing staged, so it must not go looking for a receipt the
|
||||
// datadir will never grow.
|
||||
func TestAcknowledgeBootstrap_IsANoOpOnAttach(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
|
||||
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap: %v", err)
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 0 {
|
||||
t.Errorf("acknowledgements = %d, want 0 when nothing was staged", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A configured identifier is asserted on the wire so a mis-provisioned VM is
|
||||
// rejected by the gateway instead of adopting whatever it resolves to.
|
||||
func TestRun_SendsConfiguredIdentifier(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
cfg.DBInstanceIdentifier = "db-configured"
|
||||
cfg.EngineVersion = "18.1"
|
||||
if err := runAgent(t, newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
sent := cp.registerReqs[0]
|
||||
if sent.DBInstanceIdentifier != "db-configured" || sent.EngineVersion != "18.1" {
|
||||
t.Errorf("register sent %+v, want the configured identifier and engine version", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_WaitsForDataMountBeforePollingCommands(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(2)))
|
||||
|
||||
waiting := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
a.dataMountWaiter = func(ctx context.Context, mountsFile, target string) error {
|
||||
if mountsFile != defaultMountsFile || target != defaultDataMount {
|
||||
t.Errorf("mount waiter got %q/%q, want configured defaults", mountsFile, target)
|
||||
}
|
||||
close(waiting)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-waiting:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("agent did not reach the data-mount wait after writing the handoff")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.HandoffDir, handoffEnvFile)); err != nil {
|
||||
t.Fatalf("handoff was not present before mount wait: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cp.polled:
|
||||
t.Fatal("command poll started before the data volume was mounted")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case <-cp.polled:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("command poll did not start after the data mount became ready")
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTableContains(t *testing.T) {
|
||||
mounts := filepath.Join(t.TempDir(), "mounts")
|
||||
require.NoError(t, os.WriteFile(mounts, []byte(
|
||||
"/dev/vda1 / ext4 rw 0 0\n/dev/vdb /var/lib/postgresql ext4 rw 0 0\n"+
|
||||
"/dev/vdc /path\\040with\\040spaces xfs rw 0 0\nmalformed\n"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{target: "/var/lib/postgresql", want: true},
|
||||
{target: "/path with spaces", want: true},
|
||||
{target: "/missing", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.target, func(t *testing.T) {
|
||||
got, err := mountTableContains(mounts, tc.target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_HeartbeatIncludesBootstrapFailure(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapErrs = []error{errors.New("403 AccessDenied: instance profile is not authorised")}
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(2)))
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, state := range cp.snapshotStates() {
|
||||
if strings.Contains(state.message, "bootstrap fetch failed") &&
|
||||
strings.Contains(state.message, "403 AccessDenied") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "the bootstrap failure to reach a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRun_HeartbeatsEngineHealth(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
// Declining to set a cadence leaves the agent's own, which the test shortens
|
||||
// rather than waiting out the 30s production interval.
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
beat := cp.snapshotStates()[0]
|
||||
if beat.health != handlers_rds.EngineHealthHealthy {
|
||||
t.Errorf("heartbeat health = %q, want healthy", beat.health)
|
||||
}
|
||||
if beat.id.DBInstanceIdentifier != "db-resolved" {
|
||||
t.Errorf("heartbeat identifier = %q, want db-resolved", beat.id.DBInstanceIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// The cadence is the control plane's to set: it feeds the staleness window the
|
||||
// recovery reconciler judges an instance against, so the guest adopts it rather
|
||||
// than keeping its own.
|
||||
func TestRegister_AdoptsControlPlaneCadence(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 45
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
if err := runAgent(t, a); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if a.hb.interval != 45*time.Second {
|
||||
t.Errorf("heartbeat interval = %v, want the 45s the control plane returned", a.hb.interval)
|
||||
}
|
||||
}
|
||||
|
||||
// The agent being up says nothing about the engine. A down engine has to reach
|
||||
// the control plane as such, or auto-recovery has nothing to act on.
|
||||
func TestRun_HeartbeatReportsDownEngine(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
probe := newEngineProbe(cfg, staticProbe(2))
|
||||
// The engine has served before, so a silent one now is a failure rather
|
||||
// than a boot still in progress.
|
||||
probe.seenHealthy = true
|
||||
|
||||
a := newAgent(cfg, cp, probe)
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
beat := cp.snapshotStates()[0]
|
||||
if beat.health != handlers_rds.EngineHealthUnhealthy {
|
||||
t.Errorf("heartbeat health = %q, want unhealthy while the agent is up", beat.health)
|
||||
}
|
||||
if beat.message == "" {
|
||||
t.Error("unhealthy heartbeat carried no message explaining the probe result")
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror the agent decodes poll responses into has to stay aligned with the
|
||||
// gateway type that produces them; nothing else in the build ties the two.
|
||||
func TestPollDBCommandsOutput_MatchesGatewayShape(t *testing.T) {
|
||||
issued := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
|
||||
// Rendered exactly as the gateway's typed adapter renders it, so a change to
|
||||
// either side's tags fails here.
|
||||
payload := utils.GenerateIAMXMLPayload("PollDBCommands", &gateway_rds.PollDBCommandsOutput{
|
||||
Commands: []handlers_rds.Command{{
|
||||
CommandID: "cmd-1",
|
||||
Type: "reload-parameters",
|
||||
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
|
||||
IssuedAt: &issued,
|
||||
}},
|
||||
})
|
||||
body, err := utils.MarshalToXML(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal poll result: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := rdsgw.New(srv.URL, "", gwsign.NewStatic("AKIATEST", "secret"), "us-east-1", time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("rdsgw.New: %v", err)
|
||||
}
|
||||
|
||||
var got pollDBCommandsOutput
|
||||
if err := client.Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(got.Commands) != 1 {
|
||||
t.Fatalf("decoded %d commands, want 1", len(got.Commands))
|
||||
}
|
||||
cmd := got.Commands[0]
|
||||
if cmd.CommandID != "cmd-1" || cmd.Type != "reload-parameters" {
|
||||
t.Errorf("command = %+v, want cmd-1/reload-parameters", cmd)
|
||||
}
|
||||
if len(cmd.Parameters) != 1 || cmd.Parameters[0].Value != "8MB" {
|
||||
t.Errorf("Parameters = %+v, want the seeded member", cmd.Parameters)
|
||||
}
|
||||
if cmd.IssuedAt == nil || !cmd.IssuedAt.Equal(issued) {
|
||||
t.Errorf("IssuedAt = %v, want %v", cmd.IssuedAt, issued)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// AccessDenied is decided by the control plane's own record, so no retry can
|
||||
// change it. Looping on one would leave the command poller and the parameter
|
||||
// guard unstarted for the life of a VM whose engine is serving normally.
|
||||
func TestAcknowledgeBootstrap_StopsOnATerminalDenial(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.ackErrs = []error{&rdsgw.APIError{
|
||||
Action: "AcknowledgeDBBootstrap", StatusCode: 403,
|
||||
Code: awserrors.ErrorAccessDenied,
|
||||
Message: "bootstrap payload bp-x at generation 1 does not match the current state",
|
||||
}}
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
writeReceipt(t, a, testPayloadID, "db-resolved")
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap = %v, want nil so the stateful loops still start", err)
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 1 {
|
||||
t.Errorf("acknowledgements = %d, want 1 attempt and no retry", n)
|
||||
}
|
||||
if a.hb.bootstrapFailure.Load() == nil {
|
||||
t.Error("a terminal denial must still reach the operator through the heartbeat")
|
||||
}
|
||||
if _, err := os.Stat(a.receiptPath()); err != nil {
|
||||
t.Error("a denied acknowledgement must leave the receipt for a later attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// rds-init runs after this agent, so the receipt is absent for the whole initdb
|
||||
// window. Reporting that would put a failure on every healthy create's beat, and
|
||||
// then into the reason a create that timed out for some other cause records.
|
||||
func TestAcknowledgeBootstrap_DoesNotReportAnAbsentReceiptAsAFailure(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newAgent(cfg, cp, newEngineProbe(cfg, staticProbe(0)))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err == nil {
|
||||
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting for rds-init")
|
||||
}
|
||||
if got := a.hb.bootstrapFailure.Load(); got != nil {
|
||||
t.Errorf("bootstrap failure = %q, want none while the receipt has simply not been written", *got)
|
||||
}
|
||||
|
||||
// A receipt that exists but names another instance is a real mismatch and is
|
||||
// reported, which is what separates the two cases.
|
||||
writeReceipt(t, a, testPayloadID, "db-the-snapshot-source")
|
||||
mismatch, cancelMismatch := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancelMismatch()
|
||||
if err := a.acknowledgeBootstrap(mismatch); err == nil {
|
||||
t.Fatal("a foreign receipt must not be acknowledged")
|
||||
}
|
||||
if a.hb.bootstrapFailure.Load() == nil {
|
||||
t.Error("a receipt naming another instance must be reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The staged payload this boot has to acknowledge once PostgreSQL has applied
|
||||
// it. Nil on an attach boot, which makes the acknowledgement step a no-op.
|
||||
type pendingBootstrap struct {
|
||||
payloadID string
|
||||
vmGeneration int64
|
||||
dataVolumeID string
|
||||
}
|
||||
|
||||
// Fetches the boot material and writes the handoff rds-init is blocked waiting
|
||||
// for, then points the health probe at the assigned port.
|
||||
func (a *Agent) bootstrap(ctx context.Context) error {
|
||||
var cfg *handlers_rds.GetDBBootstrapConfigOutput
|
||||
if err := retryObserved(ctx, "bootstrap fetch", func(ctx context.Context) error {
|
||||
fetched, err := a.cp.GetBootstrapConfig(ctx, a.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fetched == nil {
|
||||
return fmt.Errorf("bootstrap fetch returned no config")
|
||||
}
|
||||
// A control plane that predates the encrypted payload answers initialize
|
||||
// with nothing to initialise with. Retrying re-dials the worker queue
|
||||
// group until an upgraded node answers, so a mixed-version fleet heals
|
||||
// within one boot rather than needing a reboot.
|
||||
if fetched.Mode == handlers_rds.BootstrapModeInitialize &&
|
||||
(fetched.MasterUserPassword == nil || *fetched.MasterUserPassword == "") {
|
||||
return fmt.Errorf("bootstrap fetch returned mode=%s with no master password", fetched.Mode)
|
||||
}
|
||||
cfg = fetched
|
||||
return nil
|
||||
}, func(err error) {
|
||||
a.hb.setBootstrapFailure("bootstrap fetch", err)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
|
||||
// The fetch mutates nothing, so a handoff that cannot be written is retried
|
||||
// against the same staged payload rather than against an attach response.
|
||||
if err := retryObserved(ctx, "bootstrap handoff", func(context.Context) error {
|
||||
return a.handoffWriter(a.cfg.HandoffDir, cfg)
|
||||
}, func(err error) {
|
||||
a.hb.setBootstrapFailure("bootstrap handoff", err)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
if cfg.Port > 0 {
|
||||
a.probe.setPort(int(cfg.Port))
|
||||
a.engine.setPort(int(cfg.Port))
|
||||
}
|
||||
if cfg.BootstrapPending && cfg.PayloadID != "" {
|
||||
a.pending = &pendingBootstrap{
|
||||
payloadID: cfg.PayloadID,
|
||||
vmGeneration: cfg.VMGeneration,
|
||||
dataVolumeID: cfg.DataVolumeID,
|
||||
}
|
||||
}
|
||||
// Mode is logged but never branched on: rds-init decides whether to initdb
|
||||
// from the state of the datadir.
|
||||
slog.Info("rds-agent: bootstrap config delivered",
|
||||
"mode", cfg.Mode, "port", cfg.Port, "parameters", len(cfg.Parameters),
|
||||
"bootstrapPending", cfg.BootstrapPending)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The handoff files rds-init reads. They live on tmpfs, so nothing survives a
|
||||
// reboot and the next boot re-fetches rather than reusing a stale password or a
|
||||
// re-minted cert.
|
||||
const (
|
||||
handoffEnvFile = "bootstrap.env"
|
||||
handoffParamsFile = "parameters.conf"
|
||||
handoffCertFile = "server.crt"
|
||||
handoffKeyFile = "server.key"
|
||||
|
||||
// Root-only: only rds-init reads these, and one holds the master password.
|
||||
handoffMode = 0o600
|
||||
handoffDirMode = 0o700
|
||||
)
|
||||
|
||||
// bootstrap.env is written last and renamed into place, so its appearance —
|
||||
// which rds-init waits on — means the whole handoff is complete.
|
||||
func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
if cfg.MasterUsername == "" {
|
||||
return fmt.Errorf("bootstrap config carries no master username")
|
||||
}
|
||||
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
|
||||
return fmt.Errorf("create handoff dir %s: %w", dir, err)
|
||||
}
|
||||
// MkdirAll leaves an existing directory's mode alone, and this one may have
|
||||
// been created more permissively earlier in the boot.
|
||||
if err := os.Chmod(dir, handoffDirMode); err != nil {
|
||||
return fmt.Errorf("secure handoff dir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := writeHandoffFile(dir, handoffParamsFile, renderParameters(cfg.Parameters)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cert and key are written as a pair; half of one would start the engine
|
||||
// with TLS configured against a key it does not have.
|
||||
if cfg.ServingCertificate != "" && cfg.ServingPrivateKey != "" {
|
||||
if err := writeHandoffFile(dir, handoffCertFile, cfg.ServingCertificate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHandoffFile(dir, handoffKeyFile, cfg.ServingPrivateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writeHandoffFile(dir, handoffEnvFile, renderBootstrapEnv(cfg))
|
||||
}
|
||||
|
||||
// Every value is single-quoted because rds-init sources this with `.`: an
|
||||
// unquoted password holding a space, `$` or `;` would be word-split or executed.
|
||||
func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Written by rds-agent. Regenerated on every boot; edits are lost.\n")
|
||||
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
|
||||
writeEnvLine(&b, "RDS_DB_INSTANCE_IDENTIFIER", cfg.DBInstanceIdentifier)
|
||||
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
|
||||
writeEnvLine(&b, "RDS_DATA_VOLUME_ID", cfg.DataVolumeID)
|
||||
writeEnvLine(&b, "RDS_DATA_VOLUME_SERIAL", cfg.DataVolumeSerial)
|
||||
writeEnvLine(&b, "RDS_VM_GENERATION", strconv.FormatInt(cfg.VMGeneration, 10))
|
||||
writeEnvLine(&b, "RDS_FORMAT_AUTHORIZED", strconv.FormatBool(cfg.FormatAuthorized))
|
||||
// Neither needs volume access, which is why the assertion is made here: the
|
||||
// agent runs before rds-datadir mounts the volume, so rds-init is the first
|
||||
// component that can compare it against the completion receipt on disk.
|
||||
writeEnvLine(&b, "RDS_BOOTSTRAP_PENDING", boolFlag(cfg.BootstrapPending))
|
||||
writeEnvLine(&b, "RDS_PAYLOAD_ID", cfg.PayloadID)
|
||||
// Present only in initialize mode: an attach fetch has no password to write
|
||||
// and rds-init must not find a stale one.
|
||||
if cfg.MasterUserPassword != nil {
|
||||
writeEnvLine(&b, "RDS_MASTER_PASSWORD", *cfg.MasterUserPassword)
|
||||
}
|
||||
if cfg.DBName != "" {
|
||||
writeEnvLine(&b, "RDS_DB_NAME", cfg.DBName)
|
||||
}
|
||||
if cfg.Port > 0 {
|
||||
writeEnvLine(&b, "RDS_PORT", strconv.FormatInt(cfg.Port, 10))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rds-init compares against 1 rather than sourcing a Go boolean, so the two
|
||||
// halves of the guard cannot drift on spelling.
|
||||
func boolFlag(v bool) string {
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func writeEnvLine(b *strings.Builder, key, value string) {
|
||||
b.WriteString(key)
|
||||
b.WriteString("=")
|
||||
b.WriteString(shellQuote(value))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Single quotes suppress every shell expansion; an embedded quote is closed,
|
||||
// escaped and reopened.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// postgresql.conf syntax. Values are quoted so a setting with a space or unit
|
||||
// suffix survives; the engine accepts quoted numerics and booleans too.
|
||||
func renderParameters(params []handlers_rds.Parameter) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Resolved parameter group, written by rds-agent.\n")
|
||||
for _, p := range params {
|
||||
if p.Name == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s = '%s'\n", p.Name, strings.ReplaceAll(p.Value, "'", "''"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Atomic: a temp file in the same directory, renamed over the target. The mode
|
||||
// is set at creation, so content is never briefly readable at the process umask.
|
||||
func writeHandoffFile(dir, name, content string) error {
|
||||
path := filepath.Join(dir, name)
|
||||
tmp := path + ".new"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, handoffMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", tmp, err)
|
||||
}
|
||||
// A failed write must not leave the temp file behind holding a password.
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("close %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("install %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||