mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 20:57:02 +00:00
Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 840b6e93c5 | |||
| 9bac0e2b45 | |||
| f9c5a628c5 | |||
| dd7a3e02a0 | |||
| 186f93a986 | |||
| 3bc4b63a19 | |||
| fd4f3bcb49 | |||
| c818255365 | |||
| e17742a43a | |||
| 8d7b0f0f47 | |||
| e03d15227d | |||
| 18b393fd62 | |||
| d62fe703bb | |||
| 442444a274 | |||
| ab737e6bb1 | |||
| 3537e36bd2 | |||
| 3647fdd5de | |||
| 986ca34e34 | |||
| 7b27d39fe9 | |||
| 6c126be813 | |||
| a292419356 | |||
| 3e83657a26 | |||
| c0d54e6131 | |||
| 881a66ab57 | |||
| deffa31c37 | |||
| 87608a3b52 | |||
| bf622f3745 | |||
| b89334d76f | |||
| 4380bf1de5 | |||
| 4cbf7f33c7 | |||
| 3b523498fc | |||
| c24636c734 | |||
| c8d42ac160 | |||
| a3fbf82b9b | |||
| 05830f3499 | |||
| e2fb37c0ac | |||
| 546053d430 | |||
| 1dc7353bc1 | |||
| fea03a5b22 | |||
| 573a42a617 | |||
| ea3e01bb20 | |||
| 455c97a2e7 | |||
| d7c2a202fc | |||
| 1afc7fa8aa | |||
| 939b6a99ce | |||
| 930afa9fcb | |||
| 0d88fd14b4 | |||
| 53ef0ac491 | |||
| 0984eb31fb | |||
| 6f44883e99 | |||
| d5a648bded | |||
| 52fad7cc71 | |||
| 5b70435bb4 | |||
| ba98509c5b | |||
| 613231fd18 | |||
| 19a047bccb | |||
| bba735497f | |||
| 29faaa78fd | |||
| fb6494da01 | |||
| 2265aee120 | |||
| 1eccfded6e | |||
| a608030330 | |||
| e3ea38ccf1 | |||
| b3a0a42c9a | |||
| a2b4b71840 | |||
| b0faa72428 | |||
| 5c367cac43 | |||
| a7d55e931d | |||
| 93cef3213b | |||
| 368f4efa40 | |||
| 60509e7921 | |||
| b65be5992d | |||
| 393f78a9cd | |||
| ea51cd4d7c | |||
| 3004221982 | |||
| 2b56887b89 | |||
| 2e397d6e97 | |||
| a46f02cc63 | |||
| 84565bc13b | |||
| 23d5b81cf0 | |||
| 01dd2a761a | |||
| befdb1ca10 | |||
| 97d8e4f119 | |||
| 8e11908dfb | |||
| a1a46c6402 | |||
| f58d5ee2e8 | |||
| 612c66c73c | |||
| 4be30b28f1 | |||
| f9d72d9487 | |||
| b435b858e4 | |||
| 0d8855d1aa | |||
| e4f0050a1a | |||
| 8d8f020ed3 | |||
| 6f5cb9dfaa | |||
| 63393fea72 | |||
| 7d639cf283 | |||
| 18853cef31 | |||
| c2aad92044 | |||
| ac5daa56ab | |||
| 2727f4f121 | |||
| 7ddcac53bb | |||
| 80f522e3fc | |||
| d65c4f3673 | |||
| f719ce760c | |||
| 31eb3bc672 | |||
| 8c8cd11936 | |||
| 828b5df4aa | |||
| e2c5397b5d | |||
| b6d01370dd | |||
| 06df1b799b | |||
| 4d3ba022ce | |||
| d9593c0a48 | |||
| 7b4a83ccfe | |||
| d8d3e2268d | |||
| de44fa3264 | |||
| eccc61a69e | |||
| 18aa67b0e9 | |||
| c767f2b6ee | |||
| a5fc2b517c | |||
| ab167a192c | |||
| 4522494c17 | |||
| 0b518d2f79 | |||
| e07f25ccb2 | |||
| be94beb52a | |||
| 0247f57d6c | |||
| 677055a478 | |||
| e8f1718ebd | |||
| dc9b8429ae | |||
| f7bcccb1fe | |||
| e53e7a5884 | |||
| 13c5b00066 | |||
| 80f4db05bc | |||
| bd8ac8efa1 | |||
| 4b4542a4c5 | |||
| d535718bcb | |||
| babde88de0 | |||
| ce9a03bc72 | |||
| 5b09cc9172 | |||
| 675243753e | |||
| a9a457489d | |||
| b033a10200 | |||
| 28324fd6c6 | |||
| 0ff37c313a | |||
| 687974995f | |||
| 44abc812b6 | |||
| 1fd4373b35 | |||
| e2c38ca7ef | |||
| 4d4f7f84f1 | |||
| db97c90a9a | |||
| 12408268be | |||
| da92c7a083 | |||
| bbd0ebebed | |||
| 91bab3b910 | |||
| 3cfa94d49a | |||
| eccf068a25 | |||
| a0dfd311b2 | |||
| 805dab275c | |||
| 07dce613eb | |||
| 4d429f231b | |||
| 6c68c236aa | |||
| d143757598 | |||
| 01cf08c22b | |||
| 16c2425184 | |||
| dfd2760af4 | |||
| 3423b2b08f | |||
| ee258fd248 | |||
| a5d942ec24 | |||
| 30815e33ea | |||
| 4abbab59af | |||
| 481df170f5 | |||
| 609b4121f0 | |||
| cde092cbfb | |||
| 7923545fc1 | |||
| 6bb854ee62 | |||
| 3e02fc7613 | |||
| ebc95260ac | |||
| 4c948512d1 | |||
| d028637920 | |||
| 580777d867 | |||
| e291d546c0 | |||
| 6a533eb639 | |||
| ce9b6e5b41 | |||
| e9404f1d70 | |||
| c4e5ceb5e6 | |||
| fd11077b3a | |||
| 11e0939652 | |||
| e30ec82f6a | |||
| 7052411306 | |||
| d1de76b630 | |||
| f919a257ef | |||
| 69c7a6ba4a | |||
| 6960ef6620 | |||
| a213af1289 | |||
| 82ce31626b | |||
| e90dfa4ea3 | |||
| 465231680c | |||
| 7ff125a343 | |||
| 3409d50ec4 | |||
| 423c6bee26 | |||
| 4804c77946 | |||
| 6dd1862476 | |||
| 3680e5b5bc | |||
| 1d0a53a4b8 | |||
| da0216767c | |||
| fb499a8c6a | |||
| 3f55b89cd1 | |||
| 9799ea410c | |||
| aa997b7b45 | |||
| 82dd056fa2 | |||
| a6a8d30349 | |||
| 266ef05c56 | |||
| 1a49f15283 | |||
| 81c73573bc | |||
| b94ed1285d | |||
| da5b7de2bb | |||
| 7e36922931 | |||
| 196b7c8111 | |||
| 5760b74bf6 | |||
| abb817c7d5 | |||
| d5d5d3defc | |||
| 286e11da03 | |||
| 8336a73e4d | |||
| 9133c48fdb | |||
| 4ae281bd1f | |||
| c9da4221b9 | |||
| fbbb9fb30b | |||
| c81abd1836 | |||
| 8c7e3dfa7b | |||
| 68156c7348 | |||
| 95afec6a76 | |||
| 97acfbbd80 | |||
| fc1e950518 | |||
| f410b1b868 | |||
| 63f47b202b | |||
| 347c2b2f80 | |||
| fae6b08acc | |||
| 23f7081a2a | |||
| 40a73a8bba | |||
| 35a00a14be | |||
| 62c1378b79 | |||
| d82ee71b54 | |||
| db2bf8cf0d | |||
| 0362cdf371 | |||
| 2da27c4e2a | |||
| 3bf0379daa | |||
| 6fd9b17f62 | |||
| 2d370081c3 | |||
| 8dddf9d55e | |||
| f0b650a83b | |||
| 526cd2d070 | |||
| 70414154c5 | |||
| e24fd42bdc | |||
| e54b79b7bf | |||
| d1c9f5c6ae | |||
| 9e450ac94b | |||
| 5fc4eb0289 | |||
| d43c0e4302 | |||
| 46d8c03293 | |||
| 08da638375 | |||
| 155f869d23 |
@@ -0,0 +1,59 @@
|
||||
name: Bug report
|
||||
description: Something is not working as it should.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
options:
|
||||
- EC2 / instances
|
||||
- EBS / volumes
|
||||
- S3 / object store
|
||||
- VPC / networking
|
||||
- IAM / authentication
|
||||
- EKS
|
||||
- API gateway
|
||||
- Web UI
|
||||
- CLI / daemon
|
||||
- Observability / telemetry
|
||||
- Build / CI / packaging
|
||||
- Documentation
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Spinifex version
|
||||
description: Output of `spinifex --version`, or the commit SHA you are on.
|
||||
placeholder: v1.0.0, or commit abc1234
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you saw, what you expected instead, and how to reproduce it.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and output
|
||||
description: Relevant log lines, error messages or command output.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Where you are running it, and how you deployed it.
|
||||
placeholder: |
|
||||
OS: Debian 13
|
||||
Kernel: 6.12
|
||||
Arch: amd64
|
||||
Deployment: single node, systemd
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Feature request
|
||||
description: Suggest something new.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would you like to see?
|
||||
description: What you are trying to do, and what stops you today.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Documentation
|
||||
description: Something in the docs is wrong, missing or unclear.
|
||||
labels: ["documentation"]
|
||||
body:
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Where
|
||||
description: Link to the page, or the path to the file in this repository.
|
||||
placeholder: https://docs.mulgadc.com/..., or README.md
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What is wrong
|
||||
description: What is inaccurate, missing or confusing, and what it should say instead.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://docs.mulgadc.com
|
||||
about: Guides, configuration reference and API compatibility notes.
|
||||
@@ -91,9 +91,12 @@ type SuiteReport struct {
|
||||
Label string // short name, e.g. "single"
|
||||
Total int
|
||||
FailCount int
|
||||
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)
|
||||
// Unresolved counts testcases go-junit-report could not find an outcome
|
||||
// for. They are not failures and the suite can be green with hundreds.
|
||||
Unresolved int
|
||||
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
|
||||
@@ -131,21 +134,22 @@ 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{
|
||||
// nonFailurePatterns match content a test emits AFTER the assertion that failed
|
||||
// — t.Cleanup, harness diagnostics, or a t.Logf on a passing branch — which
|
||||
// extractErrorLine's keep-the-last rule would otherwise pick over the assertion.
|
||||
// Go emits t.Logf and t.Errorf identically, so anchor every entry: a fragment
|
||||
// loose enough to match an assertion blanks out the line the report exists to
|
||||
// show. `\bas expected\b` unanchored also matches "did not fail as expected".
|
||||
var nonFailurePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^DB instance \S+ is gone$`),
|
||||
regexp.MustCompile(`^db diagnostics \S+:`),
|
||||
regexp.MustCompile(`^\S+: management bridge \S+:\d+ refused as expected$`),
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// isNonFailureLine reports whether a candidate error line was emitted after the
|
||||
// failure it would otherwise be mistaken for, or reports an expected outcome.
|
||||
func isNonFailureLine(content string) bool {
|
||||
for _, re := range nonFailurePatterns {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
@@ -242,12 +246,12 @@ func extractErrorLine(body string) string {
|
||||
// progress lines from the same file:line pattern before the
|
||||
// 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) {
|
||||
if content := strings.TrimSpace(m[1]); !isNonFailureLine(content) {
|
||||
goFileLine = content
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fileHintRe.MatchString(l) && !isTeardownLine(l) {
|
||||
if fileHintRe.MatchString(l) && !isNonFailureLine(l) {
|
||||
lastTestLine = l
|
||||
}
|
||||
}
|
||||
@@ -293,15 +297,23 @@ func extractFileHint(body string) string {
|
||||
}
|
||||
// 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 := ""
|
||||
hint, filtered := "", ""
|
||||
for _, raw := range strings.Split(body, "\n") {
|
||||
l := strings.TrimSpace(raw)
|
||||
if m := goFileLineRe.FindStringSubmatch(l); m != nil && isTeardownLine(strings.TrimSpace(m[1])) {
|
||||
all := fileHintRe.FindAllString(l, -1)
|
||||
if len(all) == 0 {
|
||||
continue
|
||||
}
|
||||
if all := fileHintRe.FindAllString(l, -1); len(all) > 0 {
|
||||
hint = all[len(all)-1]
|
||||
if m := goFileLineRe.FindStringSubmatch(l); m != nil && isNonFailureLine(strings.TrimSpace(m[1])) {
|
||||
filtered = all[len(all)-1]
|
||||
continue
|
||||
}
|
||||
hint = all[len(all)-1]
|
||||
}
|
||||
// A body whose only file:line was allowlisted still gets a pointer: coarse
|
||||
// beats none, and extractErrorLine has the same last-resort shape.
|
||||
if hint == "" {
|
||||
return filtered
|
||||
}
|
||||
return hint
|
||||
}
|
||||
@@ -366,7 +378,7 @@ func computeLeafSet(cases []junitTC) map[string]bool {
|
||||
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 {
|
||||
if tc.Failure == nil && !isRealError(tc.Error) {
|
||||
continue
|
||||
}
|
||||
// Mark every ancestor: the failure belongs to the deepest case that
|
||||
@@ -412,6 +424,18 @@ func parseStartTime(s string) time.Time {
|
||||
return t
|
||||
}
|
||||
|
||||
// noResultMessage is go-junit-report's marker for a testcase it saw start and
|
||||
// never saw finish. On cell-20 it accounted for 93 of the 120 testcases and 24
|
||||
// of the 25 reported failures, none of which had failed.
|
||||
const noResultMessage = "No test result found"
|
||||
|
||||
// isRealError separates a genuine <error> from the parser's no-result marker.
|
||||
// A parent must not be treated as rolled up by a child whose outcome is merely
|
||||
// unknown, or the parent's own failure is dropped along with it.
|
||||
func isRealError(e *junitFailure) bool {
|
||||
return e != nil && strings.TrimSpace(e.Message) != noResultMessage
|
||||
}
|
||||
|
||||
// suiteLabel turns "junit-single.xml" into "single".
|
||||
func suiteLabel(path string) string {
|
||||
base := filepath.Base(path)
|
||||
@@ -457,6 +481,17 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
|
||||
case tc.Failure != nil:
|
||||
body = tc.Failure.Body
|
||||
case tc.Error != nil:
|
||||
// go-junit-report emits this when a `=== RUN` line has no
|
||||
// matching result line. It means the outcome is unknown, not
|
||||
// that the test errored, and it attaches the test's own log
|
||||
// output — which then reads as an assertion.
|
||||
if !isRealError(tc.Error) {
|
||||
rep.Unresolved++
|
||||
if leaf {
|
||||
cumul += tc.Time
|
||||
}
|
||||
continue
|
||||
}
|
||||
body = tc.Error.Body
|
||||
default:
|
||||
if leaf {
|
||||
@@ -553,6 +588,18 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
|
||||
}
|
||||
|
||||
// Render writes the markdown report.
|
||||
// writeUnresolved names the testcases whose outcome the parser could not read.
|
||||
// Worth surfacing because a suite that leaves most of its subtests unresolved
|
||||
// is reporting on far less than its test count suggests.
|
||||
func writeUnresolved(b *strings.Builder, s SuiteReport) {
|
||||
if s.Unresolved == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "ℹ️ %d of %d testcases have no result line in the log, so `go-junit-report` "+
|
||||
"could not read their outcome. Not failures — but this suite reports on %d tests, not %d.\n\n",
|
||||
s.Unresolved, s.Total, s.Total-s.Unresolved, s.Total)
|
||||
}
|
||||
|
||||
func Render(r Report) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## %s\n\n", r.Title)
|
||||
@@ -579,9 +626,11 @@ func Render(r Report) string {
|
||||
for _, s := range r.Suites {
|
||||
if s.FailCount == 0 {
|
||||
fmt.Fprintf(&b, "### Suite `%s`: ✅ pass (%d tests)\n\n", s.Label, s.Total)
|
||||
writeUnresolved(&b, s)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "### Suite `%s`: %d failed, 1 root cause likely\n\n", s.Label, s.FailCount)
|
||||
writeUnresolved(&b, s)
|
||||
|
||||
if s.Root != nil {
|
||||
// XML order is completion order, so "earliest" only means earliest
|
||||
|
||||
@@ -141,6 +141,46 @@ func TestExtractErrorLine_SkipsCleanupLoggedAfterTheFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A subtest looping over two subjects calls t.Errorf for the first and t.Logf
|
||||
// for the second, so the success line is last in the body. Nightly run
|
||||
// 34383800404 reported that log line as the failure.
|
||||
func TestExtractErrorLine_SkipsSuccessLoggedAfterTheFailure(t *testing.T) {
|
||||
body := ` isolation_test.go:80: rds-e2e-iso-a-1788981456: management bridge 10.15.8.10:5432 could not be tested: want connection refused, got dial tcp 10.15.8.10:5432: i/o timeout
|
||||
isolation_test.go:84: rds-e2e-iso-b-1788981456: management bridge 10.15.8.11:5432 refused as expected`
|
||||
got := extractErrorLine(body)
|
||||
if !strings.Contains(got, "could not be tested") {
|
||||
t.Errorf("extractErrorLine = %q, want the assertion at isolation_test.go:80", got)
|
||||
}
|
||||
if hint := extractFileHint(body); hint != "isolation_test.go:80" {
|
||||
t.Errorf("extractFileHint = %q, want isolation_test.go:80", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// "as expected" reads the same in an affirmation and in its negation, so an
|
||||
// unanchored pattern suppresses the assertion it was meant to outrank and
|
||||
// promotes a harness progress line in its place.
|
||||
func TestExtractErrorLine_KeepsNegatedExpectationAssertions(t *testing.T) {
|
||||
body := ` isolation_test.go:60: waiting for the db instance to become available
|
||||
isolation_test.go:80: rds-e2e-iso-a: management bridge 10.15.8.10:5432 was not refused as expected: got a connection`
|
||||
got := extractErrorLine(body)
|
||||
if !strings.Contains(got, "was not refused as expected") {
|
||||
t.Errorf("extractErrorLine = %q, want the assertion at isolation_test.go:80", got)
|
||||
}
|
||||
if hint := extractFileHint(body); hint != "isolation_test.go:80" {
|
||||
t.Errorf("extractFileHint = %q, want isolation_test.go:80", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Every candidate allowlisted leaves extractFileHint with nothing to return,
|
||||
// and unlike extractErrorLine it has no last-resort branch. A coarse pointer
|
||||
// beats sending the reader to no file at all.
|
||||
func TestExtractFileHint_FallsBackWhenEveryCandidateFiltered(t *testing.T) {
|
||||
body := ` isolation_test.go:84: rds-e2e-iso-b: management bridge 10.15.8.11:5432 refused as expected`
|
||||
if hint := extractFileHint(body); hint != "isolation_test.go:84" {
|
||||
t.Errorf("extractFileHint = %q, want isolation_test.go:84", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFileHint_PicksLastMatch(t *testing.T) {
|
||||
body := "ec2helpers.go:50: setup\n vpc_test.go:227: Eventually: condition not met"
|
||||
got := extractFileHint(body)
|
||||
@@ -174,6 +214,55 @@ func TestParseFile_SkipsEmptyParentFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// go-junit-report emits <error message="No test result found"> for a `=== RUN`
|
||||
// with no matching result line, and attaches the test's own log output. On a
|
||||
// baremetal cell that was 93 of 120 testcases and 24 of 25 reported failures,
|
||||
// none of which had failed: "ALB internal: 20/20 successful" read as an error.
|
||||
func TestParseFile_NoResultFoundIsNotAFailure(t *testing.T) {
|
||||
xml := []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="3" failures="1" errors="2">
|
||||
<testcase name="TestLB/Internal_ALB" time="0.0"><error message="No test result found"><![CDATA[
|
||||
lb_test.go:88: ALB internal: 20/20 successful, 2 unique
|
||||
]]></error></testcase>
|
||||
<testcase name="TestLB/Internal_NLB" time="0.0"><error message="No test result found"></error></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 (no-result entries are not failures)", sr.FailCount)
|
||||
}
|
||||
if sr.Unresolved != 2 {
|
||||
t.Fatalf("Unresolved = %d, want 2", sr.Unresolved)
|
||||
}
|
||||
if sr.Root == nil || sr.Root.Name != "TestX/RealFailure" {
|
||||
t.Fatalf("expected the real failure as root, got %+v", sr.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// An <error> that is not the parser's no-result marker is a genuine error and
|
||||
// must still be reported.
|
||||
func TestParseFile_KeepsARealError(t *testing.T) {
|
||||
xml := []byte(`<?xml version="1.0"?>
|
||||
<testsuites><testsuite name="" tests="1" failures="0" errors="1">
|
||||
<testcase name="TestX" time="0.5"><error message="panic"><![CDATA[
|
||||
foo_test.go:10: panic: runtime error: index out of range
|
||||
]]></error></testcase>
|
||||
</testsuite></testsuites>`)
|
||||
sr, err := ParseFile("junit-x.xml", xml)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
}
|
||||
if sr.FailCount != 1 || sr.Unresolved != 0 {
|
||||
t.Fatalf("FailCount = %d, Unresolved = %d, want 1 and 0", sr.FailCount, sr.Unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
[
|
||||
{
|
||||
"cell": 1,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "dhcp",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 2,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "ubuntu-26.04",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 3,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "dhcp",
|
||||
"host": "ubuntu-26.04",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 8,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "dhcp",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 9,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "static",
|
||||
"host": "ubuntu-26.04",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 10,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "dhcp",
|
||||
"host": "ubuntu-26.04",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 17,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-pseudo",
|
||||
"extra": "tofu-examples",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 18,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "reboot-single",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 19,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "nat",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "nat-single",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 25,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single-nvme",
|
||||
"extra": "eks",
|
||||
"suite": "eks"
|
||||
},
|
||||
{
|
||||
"cell": 26,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "ecs",
|
||||
"suite": "ecs"
|
||||
},
|
||||
{
|
||||
"cell": 27,
|
||||
"install": "binary",
|
||||
"topo": "single",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "rds",
|
||||
"suite": "rds",
|
||||
"timeout_minutes": 120
|
||||
},
|
||||
{
|
||||
"cell": 29,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": "storagefault",
|
||||
"timeout_minutes": 100
|
||||
}
|
||||
]
|
||||
@@ -61,3 +61,37 @@ quiet_run() {
|
||||
return "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# Connection-layer failures from the libvirt tofu provider. The socket drops
|
||||
# when a hypervisor is carrying several environments at once, which is the
|
||||
# steady state here. A resource that failed to build says something else.
|
||||
CI_LIBVIRT_TRANSIENT='Unable to Connect to Libvirt|error connecting to libvirt|procedure interrupted|Cannot recv data|Cannot write data|End of file while reading data'
|
||||
|
||||
# retry_run LABEL LOG_FILE -- CMD ARGS …
|
||||
# quiet_run with a bounded retry, taken only when that attempt's own output
|
||||
# names a libvirt connection failure. Every other failure is reported on the
|
||||
# first attempt, so a real bug never becomes a slow flake.
|
||||
retry_run() {
|
||||
local label="$1" log="$2"; shift 2
|
||||
local _attempts="${CI_RETRY_ATTEMPTS:-3}" _delay="${CI_RETRY_DELAY:-30}" _i _rc=1 _mark
|
||||
for ((_i = 1; _i <= _attempts; _i++)); do
|
||||
_mark=$(wc -c < "$log" 2>/dev/null || echo 0)
|
||||
# rc must be read inside the else: an `if` whose condition fails and has no
|
||||
# else exits 0, so reading $? after `fi` reports success for a failed run.
|
||||
if "$@" >> "$log" 2>&1; then
|
||||
if [[ $_i -gt 1 ]]; then ok "$label (libvirt recovered on attempt $_i)"; else ok "$label"; fi
|
||||
return 0
|
||||
else
|
||||
_rc=$?
|
||||
fi
|
||||
tail -c "+$((_mark + 1))" "$log" | grep -Eq "$CI_LIBVIRT_TRANSIENT" || break
|
||||
[[ $_i -eq $_attempts ]] && break
|
||||
warn "$label: libvirt connection dropped (attempt $_i/$_attempts), retrying in ${_delay}s"
|
||||
sleep "$_delay"
|
||||
done
|
||||
bad "$label (rc=$_rc) — last 80 lines of $(basename "$log"):"
|
||||
group_open "tail $(basename "$log")"
|
||||
tail -n 80 "$log"
|
||||
group_close
|
||||
return "$_rc"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render one run-level table for the e2e nightly into the job summary.
|
||||
|
||||
Reading a nightly meant opening nineteen jobs, and the Actions UI does not
|
||||
distinguish a cell that failed from one that was cancelled out from under it —
|
||||
a distinction every investigation of this suite has needed.
|
||||
|
||||
Inputs (env):
|
||||
GITHUB_TOKEN Token with actions:read.
|
||||
GITHUB_REPOSITORY owner/repo.
|
||||
GITHUB_RUN_ID Run to summarise.
|
||||
JUNIT_ROOT Directory holding downloaded junit-cell-* artifacts.
|
||||
GITHUB_STEP_SUMMARY Appended to.
|
||||
|
||||
Test counts come from the junit artifacts; result, duration and runner come
|
||||
from the jobs API, so a cell that died before writing junit still gets a row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
|
||||
API = "https://api.github.com"
|
||||
|
||||
# A cell whose job produced no verdict is not a pass and not a failure. Keeping
|
||||
# them apart is the point of the table.
|
||||
VERDICT_ICON = {
|
||||
"success": "✅",
|
||||
"failure": "❌",
|
||||
"cancelled": "🚫",
|
||||
"skipped": "⏭️",
|
||||
"timed_out": "⏱️",
|
||||
}
|
||||
|
||||
|
||||
def fetch_jobs(repo: str, run_id: str, token: str) -> list[dict]:
|
||||
jobs: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
url = f"{API}/repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.load(resp)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
print(f"::warning::jobs API page {page} failed: {exc}", file=sys.stderr)
|
||||
break
|
||||
batch = payload.get("jobs", [])
|
||||
jobs.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
return jobs
|
||||
|
||||
|
||||
def duration_s(job: dict) -> int | None:
|
||||
started, completed = job.get("started_at"), job.get("completed_at")
|
||||
if not started or not completed:
|
||||
return None
|
||||
fmt = "%Y-%m-%dT%H:%M:%SZ"
|
||||
try:
|
||||
return int((datetime.strptime(completed, fmt) - datetime.strptime(started, fmt)).total_seconds())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def human(seconds: int | None) -> str:
|
||||
if seconds is None:
|
||||
return "—"
|
||||
if seconds < 0:
|
||||
return "0s"
|
||||
return f"{seconds // 60}m{seconds % 60:02d}s" if seconds >= 60 else f"{seconds}s"
|
||||
|
||||
|
||||
def junit_counts(root: str) -> dict[str, dict]:
|
||||
"""Map cell number to its aggregate junit counts and failing test names."""
|
||||
per_cell: dict[str, dict] = {}
|
||||
for path in glob.glob(os.path.join(root, "**", "junit-*.xml"), recursive=True):
|
||||
cell = cell_from_artifact_path(path)
|
||||
if cell is None:
|
||||
continue
|
||||
acc = per_cell.setdefault(cell, {"tests": 0, "failures": 0, "skipped": 0, "failing": []})
|
||||
try:
|
||||
root_el = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
print(f"::warning::unparseable junit {path}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
suites = root_el.iter("testsuite") if root_el.tag != "testsuite" else [root_el]
|
||||
for suite in suites:
|
||||
acc["tests"] += int(suite.get("tests") or 0)
|
||||
acc["failures"] += int(suite.get("failures") or 0) + int(suite.get("errors") or 0)
|
||||
acc["skipped"] += int(suite.get("skipped") or 0)
|
||||
for case in root_el.iter("testcase"):
|
||||
if case.find("failure") is not None or case.find("error") is not None:
|
||||
name = case.get("name") or "?"
|
||||
if name not in acc["failing"]:
|
||||
acc["failing"].append(name)
|
||||
return per_cell
|
||||
|
||||
|
||||
def cell_from_artifact_path(path: str) -> str | None:
|
||||
"""Recover the cell number from a junit-cell-<n>-<run_id> artifact directory."""
|
||||
for part in path.split(os.sep):
|
||||
if part.startswith("junit-cell-"):
|
||||
rest = part[len("junit-cell-"):]
|
||||
cell = rest.split("-", 1)[0]
|
||||
return cell if cell.isdigit() else None
|
||||
return None
|
||||
|
||||
|
||||
def cell_number(job_name: str) -> str | None:
|
||||
"""cell-19 (single/static/debian-13) -> 19."""
|
||||
if not job_name.startswith("cell-"):
|
||||
return None
|
||||
cell = job_name[len("cell-"):].split(" ", 1)[0]
|
||||
return cell if cell.isdigit() else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
|
||||
junit_root = os.environ.get("JUNIT_ROOT", "")
|
||||
|
||||
if not (repo and run_id and token and summary_path):
|
||||
print("::warning::e2e-run-summary: missing required environment", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
jobs = fetch_jobs(repo, run_id, token)
|
||||
counts = junit_counts(junit_root) if junit_root and os.path.isdir(junit_root) else {}
|
||||
|
||||
rows = []
|
||||
for job in jobs:
|
||||
cell = cell_number(job.get("name", ""))
|
||||
if cell is None:
|
||||
continue
|
||||
rows.append((int(cell), job))
|
||||
rows.sort(key=lambda r: r[0])
|
||||
|
||||
lines = [f"## e2e nightly — run summary ({len(rows)} cells)", ""]
|
||||
if not rows:
|
||||
lines.append("No cell jobs found for this run.")
|
||||
write(summary_path, lines)
|
||||
return 0
|
||||
|
||||
lines += [
|
||||
"| Cell | Topology | Result | Duration | Runner | Tests | Failed | Skipped |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
|
||||
tally: dict[str, int] = {}
|
||||
total_tests = total_failed = total_seconds = 0
|
||||
failing_detail: list[tuple[int, list[str]]] = []
|
||||
|
||||
for cell, job in rows:
|
||||
name = job.get("name", "")
|
||||
# A cell dispatched to a reusable workflow reports as
|
||||
# "cell-20 (baremetal/pxe/real-hw) / Bare-Metal Single-Node E2E".
|
||||
topo = name.split("(", 1)[1].split(")", 1)[0] if "(" in name else "—"
|
||||
verdict = job.get("conclusion") or job.get("status") or "unknown"
|
||||
tally[verdict] = tally.get(verdict, 0) + 1
|
||||
secs = duration_s(job)
|
||||
total_seconds += secs or 0
|
||||
c = counts.get(str(cell), {})
|
||||
total_tests += c.get("tests", 0)
|
||||
total_failed += c.get("failures", 0)
|
||||
if c.get("failing"):
|
||||
failing_detail.append((cell, c["failing"]))
|
||||
lines.append(
|
||||
f"| {cell} | {topo} | {VERDICT_ICON.get(verdict, '❔')} {verdict} | {human(secs)} "
|
||||
f"| {job.get('runner_name') or '—'} | {c.get('tests', '—')} "
|
||||
f"| {c.get('failures', '—')} | {c.get('skipped', '—')} |"
|
||||
)
|
||||
|
||||
verdicts = ", ".join(f"{n} {v}" for v, n in sorted(tally.items(), key=lambda kv: -kv[1]))
|
||||
lines += [
|
||||
"",
|
||||
f"**Cells:** {verdicts}",
|
||||
f"**Tests:** {total_tests} run, {total_failed} failed",
|
||||
f"**Cell wall clock:** {human(total_seconds)} across all cells",
|
||||
]
|
||||
|
||||
slowest = sorted(rows, key=lambda r: duration_s(r[1]) or 0, reverse=True)[:3]
|
||||
if slowest:
|
||||
lines.append(
|
||||
"**Slowest:** " + ", ".join(f"cell {c} ({human(duration_s(j))})" for c, j in slowest)
|
||||
)
|
||||
|
||||
if failing_detail:
|
||||
lines += ["", "<details><summary>Failing tests</summary>", ""]
|
||||
for cell, names in failing_detail:
|
||||
lines.append(f"- **cell {cell}** — " + ", ".join(f"`{n}`" for n in names))
|
||||
lines += ["", "</details>"]
|
||||
|
||||
write(summary_path, lines)
|
||||
return 0
|
||||
|
||||
|
||||
def write(path: str, lines: list[str]) -> None:
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Prepare a runner to build a guest image with build-system-image.sh.
|
||||
#
|
||||
# Installs libguestfs + qemu-utils if they are missing and makes a host kernel
|
||||
# readable, so the libguestfs appliance can boot. Nothing here touches a host
|
||||
# block device: virt-customize works inside an isolated userspace appliance, so
|
||||
# this is safe on a shared runner.
|
||||
#
|
||||
# Callers must still export LIBGUESTFS_BACKEND=direct themselves — it has to be
|
||||
# set in the environment the build runs in, which is not this subshell.
|
||||
set -euo pipefail
|
||||
|
||||
# Three nightly service cells share one ci-single runner and start together, so
|
||||
# concurrent apt-get runs are the normal case rather than the exception. Two of
|
||||
# them lost the lock outright before this guard existed. Skipping the install
|
||||
# when the tools are already present avoids most of the contention; the lock
|
||||
# timeout covers the rest, including apt runs started by anything else on the
|
||||
# box.
|
||||
if command -v virt-customize >/dev/null && command -v qemu-img >/dev/null; then
|
||||
echo "libguestfs-tools and qemu-utils already present"
|
||||
else
|
||||
APT_OPTS=(-o DPkg::Lock::Timeout=600)
|
||||
sudo apt-get "${APT_OPTS[@]}" update -qq
|
||||
sudo apt-get "${APT_OPTS[@]}" install -y --no-install-recommends \
|
||||
libguestfs-tools qemu-utils
|
||||
fi
|
||||
|
||||
# Debian/Ubuntu ship /boot/vmlinuz-* mode 0600 and the appliance needs to read a
|
||||
# host kernel. Tolerated on failure: a runner whose kernel is already readable
|
||||
# has no vmlinuz to chmod under some images.
|
||||
sudo chmod 0644 /boot/vmlinuz-* || true
|
||||
@@ -28,23 +28,84 @@
|
||||
# that runs identically on either, and because a quota left switched on would
|
||||
# cap every suite sharing the environment — so it must be proved to restore
|
||||
# itself wherever it runs, not only on one node.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota"
|
||||
# storagefault runs last in both lists and belongs to both topologies, for
|
||||
# different reasons. On one node it is RS(1,0), so freezing the only predastore
|
||||
# is a total outage and the guest-survival assertion is unambiguous. On three it
|
||||
# is RS(2,1) with degraded writes on, so two must be frozen to break the shard
|
||||
# floor — and only there is a cross-node restart possible at all, which is the
|
||||
# case that takes a volume over from a node holding writes the backend never
|
||||
# received. It is last because it SIGSTOPs a cluster-wide
|
||||
# service: anything sharing the environment would see the same outage and report
|
||||
# a failure that says nothing about itself.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
|
||||
|
||||
# Suites runnable against a multi-node environment.
|
||||
E2E_SUITES_MULTI="multinode lb cert quota"
|
||||
E2E_SUITES_MULTI="multinode lb cert quota storagefault"
|
||||
|
||||
# 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"
|
||||
# What every e2e-nightly permutation cell must prove, deliberately narrower
|
||||
# than the two sets above: those cells have a 35-minute budget and exist to show
|
||||
# that every install / network / host-OS combination boots and serves. The
|
||||
# service suites are not deferred to the PR gate — eks, ecs and rds each run
|
||||
# nightly in their own dedicated cell, because each needs a guest AMI built
|
||||
# first and a service failure should not mask whether a host OS boots. These
|
||||
# are subsets, and e2e_suites_assert_subset below enforces that.
|
||||
#
|
||||
# storagefault has a dedicated cell for two reasons. It SIGSTOPs a cluster-wide
|
||||
# service, so in a permutation cell every suite sharing that environment would
|
||||
# report the outage as its own failure. And a full pass is ~63 minutes, nearly
|
||||
# all of it deliberate waiting, which is too long to add to the PR gate — where
|
||||
# it has never run, since the gate's default suite list does not name it.
|
||||
#
|
||||
# Its cell is three-node: the RS(2,1) shard floor and the cross-node takeover
|
||||
# after a failed seal do not exist on one node, and they are the assertions
|
||||
# worth a nightly.
|
||||
E2E_SUITES_NIGHTLY_SINGLE="single cert iam"
|
||||
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
|
||||
|
||||
# How long a suite gets, for the same reason the lists above live here: the
|
||||
# timeout was written out separately in each workflow and drifted, so rds ran
|
||||
# 50m on the PR gate and 30m nightly and panicked there. A suite needing more
|
||||
# than the default declares it once, here.
|
||||
# e2e_suite_timeout <suite>
|
||||
e2e_suite_timeout() {
|
||||
case "$1" in
|
||||
# rds serialises its DB VMs behind a 3 GiB semaphore, so its wall clock is
|
||||
# what the queue costs, not what the tests do: single tests logged 11m, 22m
|
||||
# and 26m waiting for budget. 17 tests at that concurrency is over an hour
|
||||
# before anything goes wrong, and the first pass to run deep into the suite
|
||||
# still had three left at 60m. The loaded graceful-reboot case adds an
|
||||
# instance that holds its slot through a pgbench load as well as a create.
|
||||
rds) echo "105m" ;;
|
||||
# diskperf runs two 16 GiB fio profiles per repetition, each on a volume it
|
||||
# creates first. One pass is ~20 minutes on the bare-metal cell and a
|
||||
# baseline capture at SPINIFEX_DISKPERF_REPS=3 is three times that.
|
||||
diskperf) echo "180m" ;;
|
||||
# storagefault is mostly deliberate waiting: one test alone holds predastore
|
||||
# SIGSTOPped for seven minutes to prove a guest survives it. A full pass on
|
||||
# dev-prod is ~63 minutes, so the 30m default truncated it mid-suite.
|
||||
storagefault) echo "90m" ;;
|
||||
*) echo "30m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# One matrix leg per suite, carrying the job budget that suite needs. The outer
|
||||
# timeout must always be later than the `go test` one inside it: go test reports
|
||||
# where it stopped, a cancelled job reports only "The operation was canceled".
|
||||
# e2e_suite_matrix <suite-list> <floor-minutes> [allowance-minutes]
|
||||
e2e_suite_matrix() {
|
||||
local list="$1" floor="$2" allowance="${3:-15}" suite budget
|
||||
for suite in ${list}; do
|
||||
budget="$(e2e_suite_timeout "${suite}")"
|
||||
budget=$((${budget%m} + allowance))
|
||||
if [ "${budget}" -lt "${floor}" ]; then budget="${floor}"; fi
|
||||
printf '%s\t%s\n' "${suite}" "${budget}"
|
||||
done | jq -cRs 'split("\n")|map(select(length>0)|split("\t")|{suite:.[0],timeout:(.[1]|tonumber)})'
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -33,8 +33,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
name: E2E Bare-Metal
|
||||
|
||||
on:
|
||||
# Mirrors the suite selection of workflow_call so a baseline capture can be
|
||||
# driven by hand. A dispatch run sees only the inputs declared here, so
|
||||
# omitting these would silently fall back to the correctness set.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suites:
|
||||
description: "Space-separated e2e suites to run on the node. Default is the correctness set; a performance run names its own so its numbers come off an otherwise idle cell"
|
||||
required: false
|
||||
type: string
|
||||
default: "single cert lb"
|
||||
suite-env:
|
||||
description: "Extra environment for the suite invocation, e.g. SPINIFEX_DISKPERF_REPS=3"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
cell:
|
||||
description: "Nightly cell number this run reports as. Names the artifact bundle and the ES dimension, so two callers of this workflow stay distinguishable in the sink"
|
||||
required: false
|
||||
type: string
|
||||
default: "20"
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -14,6 +33,21 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
suites:
|
||||
description: "Space-separated e2e suites to run on the node. Default is the correctness set; a performance caller names its own so its numbers come off an otherwise idle cell"
|
||||
required: false
|
||||
type: string
|
||||
default: "single cert lb"
|
||||
suite-env:
|
||||
description: "Extra environment for the suite invocation, e.g. SPINIFEX_DISKPERF_REPS=3"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
cell:
|
||||
description: "Nightly cell number this run reports as. Names the artifact bundle and the ES dimension, so two callers of this workflow stay distinguishable in the sink"
|
||||
required: false
|
||||
type: string
|
||||
default: "20"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,25 +55,21 @@ 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:
|
||||
name: Bare-Metal Single-Node E2E
|
||||
runs-on: [ self-hosted, ci-baremetal ]
|
||||
timeout-minutes: 120
|
||||
runs-on: [self-hosted, ci-baremetal]
|
||||
timeout-minutes: 240
|
||||
concurrency:
|
||||
group: e2e-baremetal-cluster
|
||||
cancel-in-progress: false
|
||||
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 }}
|
||||
BM_CELL: ${{ inputs.cell || '20' }}
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ inputs.cell || '20' }}-${{ github.run_id }}
|
||||
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs || format('ci.run_id={0},ci.workflow={1},ci.cell={2},ci.matrix=baremetal/pxe/real-hw', github.run_id, github.workflow, inputs.cell || '20') }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
@@ -74,6 +104,10 @@ jobs:
|
||||
ssh-keygen -y -f "${RUNNER_TEMP}/bm-ci" > "${RUNNER_TEMP}/bm-ci.pub"
|
||||
|
||||
- name: Build Artifacts
|
||||
env:
|
||||
# The node runs pre-built binaries, so a suite absent here cannot be
|
||||
# selected later. Same list as the run step for that reason.
|
||||
BM_SUITES: ${{ inputs.suites || 'single cert lb' }}
|
||||
run: |
|
||||
mulga/scripts/baremetal/bm-build-artifacts.sh \
|
||||
"$GITHUB_WORKSPACE" \
|
||||
@@ -119,6 +153,21 @@ jobs:
|
||||
- name: Install go-junit-report on runner
|
||||
run: go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
|
||||
# AMI. Built here because libguestfs needs source and network, which the
|
||||
# runner has and the PXE-booted node does not; bm-bootstrap.sh imports it.
|
||||
- name: Build eks-node AMI (libguestfs, runner)
|
||||
if: contains(inputs.suites, 'eks')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
# spinifex is the workspace root here, unlike the nightly where it is
|
||||
# checked out into a subdirectory.
|
||||
"${GITHUB_WORKSPACE}/.github/scripts/ensure-libguestfs.sh"
|
||||
make build-eks-node-image
|
||||
|
||||
- name: Run Bare-Metal E2E
|
||||
env:
|
||||
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
|
||||
@@ -126,9 +175,25 @@ jobs:
|
||||
BM_EPDU_ENV: ${{ runner.temp }}/epdu.env
|
||||
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
|
||||
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
|
||||
BM_SUITES: ${{ inputs.suites || 'single cert lb' }}
|
||||
BM_SUITE_ENV: ${{ inputs.suite-env }}
|
||||
# Empty unless the eks build above ran, and bm-bootstrap.sh skips the
|
||||
# import when unset, so the other suites are unaffected.
|
||||
BM_EKS_IMAGE: ${{ contains(inputs.suites, 'eks') && '/tmp/eks-node-image-build/eks-node-alpine.raw' || '' }}
|
||||
BM_EKS_MANIFEST: ${{ contains(inputs.suites, 'eks') && format('{0}/scripts/images/eks-node/manifest.conf', github.workspace) || '' }}
|
||||
run: |
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
set -o pipefail
|
||||
. "${GITHUB_WORKSPACE}/.github/scripts/suite-sets.sh"
|
||||
BM_TEST_TIMEOUT=""
|
||||
for suite in ${BM_SUITES}; do
|
||||
tmo="$(e2e_suite_timeout "${suite}")"
|
||||
if [ -z "${BM_TEST_TIMEOUT}" ] || [ "${tmo%m}" -gt "${BM_TEST_TIMEOUT%m}" ]; then
|
||||
BM_TEST_TIMEOUT="${tmo}"
|
||||
fi
|
||||
done
|
||||
export BM_TEST_TIMEOUT
|
||||
echo "suites=${BM_SUITES} test-timeout=${BM_TEST_TIMEOUT}"
|
||||
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
|
||||
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
|
||||
--initrd "${BM_ISO_CACHE}/initrd.img" \
|
||||
@@ -139,7 +204,15 @@ jobs:
|
||||
- name: Generate JUnit report
|
||||
if: always()
|
||||
run: |
|
||||
LOG="${ARTIFACT_DIR}/test-baremetal.log"
|
||||
# suite-output.log, not test-baremetal.log. The harness streams the
|
||||
# full suite output to the first and a grep-filtered summary to its
|
||||
# stdout, and that filter anchors --- PASS at column 0, so every
|
||||
# indented subtest result is dropped. Parsed from the filtered log,
|
||||
# go-junit-report saw a === RUN with no result and emitted
|
||||
# <error message="No test result found"> — 17 of 39 testcases on a
|
||||
# green cell-28, 93 of 120 on cell-20.
|
||||
LOG="${ARTIFACT_DIR}/suite-output.log"
|
||||
[ -s "$LOG" ] || LOG="${ARTIFACT_DIR}/test-baremetal.log"
|
||||
[ -f "$LOG" ] || exit 0
|
||||
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-baremetal.xml" || true
|
||||
|
||||
@@ -151,7 +224,7 @@ jobs:
|
||||
uses: ./.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: "20"
|
||||
cell: ${{ env.BM_CELL }}
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
@@ -159,12 +232,12 @@ jobs:
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis (cell 20)"
|
||||
title: "E2E failure analysis (cell ${{ env.BM_CELL }})"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-20-${{ github.run_id }}
|
||||
name: e2e-artifacts-cell-${{ env.BM_CELL }}-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
name: E2E DDIL
|
||||
|
||||
# This workflow is manually triggered while the DDIL scenarios are being
|
||||
# validated.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -10,29 +8,31 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Shared with e2e-nightly.yml and e2e-suites.yml: every one of them provisions
|
||||
# many cells on the same runner pools and the same hypervisors, so two in
|
||||
# flight compete for their disks and their libvirt guests. Queued rather than
|
||||
# cancelled — a run in progress is someone's result, not a stale one. e2e.yml
|
||||
# is outside the group: one cluster per run is cheap enough to overlap.
|
||||
concurrency:
|
||||
group: e2e-hypervisors
|
||||
cancel-in-progress: false
|
||||
|
||||
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 }}
|
||||
# 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' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
ddil:
|
||||
name: DDIL Real Multi-Node
|
||||
runs-on: [ self-hosted, ci-multi ]
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
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
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "VMs provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
ok "teardown complete"
|
||||
|
||||
|
||||
@@ -2,86 +2,135 @@ name: E2E Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
|
||||
# 14:00 UTC is midnight AEST (01:00 AEDT — cron has no DST), when the
|
||||
# hypervisors are idle. Every day, not weeknights: a weekend gap is two runs
|
||||
# of evidence lost on a suite whose remaining failures are a rate, not a bug.
|
||||
# :17 rather than :00 because GitHub queues scheduled runs behind every other
|
||||
# top-of-hour cron and drops them under load, which is why 2026-09-14 never
|
||||
# fired and why every run in that window started 3h15m-3h45m late.
|
||||
- cron: "17 14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-19 tofu matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection"
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection"
|
||||
required: false
|
||||
|
||||
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.
|
||||
# Every cell builds its cluster on the same five hypervisors, so two runs in
|
||||
# flight at once compete for their disks and their libvirt guests. Queue the
|
||||
# second rather than cancelling it: a dispatch is usually a verification run
|
||||
# and discarding it loses the result someone is waiting on.
|
||||
#
|
||||
# The group is shared with e2e-suites.yml and e2e-ddil.yml, which draw on the
|
||||
# same runner pools. e2e.yml is deliberately outside it: a single-cluster push
|
||||
# run is cheap enough to overlap, and grouping it stalled every push behind a
|
||||
# multi-hour nightly.
|
||||
concurrency:
|
||||
group: e2e-hypervisors
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
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' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
select:
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
cells: ${{ steps.pick.outputs.cells }}
|
||||
any: ${{ steps.pick.outputs.any }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/e2e-cells.json
|
||||
.github/scripts/suite-sets.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: pick
|
||||
env:
|
||||
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
|
||||
SETUP_ALLOWANCE_MIN: 10
|
||||
run: |
|
||||
if [ -z "$CELLS" ]; then
|
||||
sel=$(jq -c . .github/e2e-cells.json)
|
||||
else
|
||||
sel=$(jq -c --arg c "$CELLS" \
|
||||
'[.[] | . as $e | select(($c | split(",")) | index($e.cell|tostring))]' \
|
||||
.github/e2e-cells.json)
|
||||
fi
|
||||
echo "cells=$sel" >> "$GITHUB_OUTPUT"
|
||||
[ "$(jq 'length' <<<"$sel")" -gt 0 ] && any=true || any=false
|
||||
echo "any=$any" >> "$GITHUB_OUTPUT"
|
||||
echo "selected: $(jq -c '[.[].cell]' <<<"$sel")"
|
||||
|
||||
# The inner timeout must always fire before the outer one. go test
|
||||
# reports where it stopped and the suite's own failure; a job timeout
|
||||
# reports only "The operation was canceled". cell-27 ran a 50m rds
|
||||
# suite inside a 50m job and could never finish either way.
|
||||
. .github/scripts/suite-sets.sh
|
||||
# Cells with no suite are filtered in jq, not with an empty-field test:
|
||||
# tab is IFS whitespace, so read collapses a run of them and an empty
|
||||
# middle column silently shifts every later one left.
|
||||
bad=0
|
||||
while IFS=$'\t' read -r cell suite budget; do
|
||||
want="$(e2e_suite_timeout "$suite")"; want="${want%m}"
|
||||
if [ "$budget" -lt $((want + SETUP_ALLOWANCE_MIN)) ]; then
|
||||
echo "::error::cell-$cell gives suite '$suite' ${want}m inside a ${budget}m job. Raise timeout_minutes in .github/e2e-cells.json to at least $((want + SETUP_ALLOWANCE_MIN)), or lower the suite budget in suite-sets.sh." >&2
|
||||
bad=1
|
||||
fi
|
||||
done < <(jq -r '.[]|select((.suite//"")!="")|[.cell,.suite,(.timeout_minutes//50)]|@tsv' <<<"$sel")
|
||||
|
||||
# A permutation cell names no suite and runs the nightly subset for its
|
||||
# topology, so its bound is the longest of those. max() and not sum():
|
||||
# whichever suite hangs must panic on its own timeout first, and a sum
|
||||
# would demand a budget the set has never come close to using.
|
||||
while IFS=$'\t' read -r cell topo budget; do
|
||||
case "$topo" in
|
||||
*multi) set -- ${E2E_SUITES_NIGHTLY_MULTI} ;;
|
||||
*) set -- ${E2E_SUITES_NIGHTLY_SINGLE} ;;
|
||||
esac
|
||||
want=0
|
||||
for suite in "$@"; do
|
||||
t="$(e2e_suite_timeout "$suite")"; t="${t%m}"
|
||||
[ "$t" -gt "$want" ] && want="$t"
|
||||
done
|
||||
if [ "$budget" -lt $((want + SETUP_ALLOWANCE_MIN)) ]; then
|
||||
echo "::error::cell-$cell runs the $topo nightly subset, whose longest suite asks ${want}m, inside a ${budget}m job. Raise timeout_minutes in .github/e2e-cells.json to at least $((want + SETUP_ALLOWANCE_MIN))." >&2
|
||||
bad=1
|
||||
fi
|
||||
done < <(jq -r '.[]|select((.suite//"")=="")|[.cell,.topo,(.timeout_minutes//50)]|@tsv' <<<"$sel")
|
||||
[ "$bad" -eq 0 ]
|
||||
|
||||
matrix:
|
||||
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
|
||||
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
needs: select
|
||||
if: needs.select.outputs.any == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
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-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-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: ${{ matrix.extra == 'tofu-examples' && 45 || 35 }}
|
||||
include: ${{ fromJSON(needs.select.outputs.cells) }}
|
||||
runs-on: [self-hosted, "${{ matrix.runner }}"]
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 50 }}
|
||||
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.
|
||||
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
|
||||
- name: Gate on Dispatch Input
|
||||
id: gate
|
||||
env:
|
||||
CELL: ${{ matrix.cell }}
|
||||
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
|
||||
run: |
|
||||
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
|
||||
echo "selected=true" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} selected"
|
||||
else
|
||||
echo "selected=false" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
|
||||
fi
|
||||
|
||||
- name: Clean Workspace
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
run: |
|
||||
sudo umount -R -l /var/cache/spinifex-iso-builder/rootfs 2>/dev/null || true
|
||||
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
@@ -89,7 +138,6 @@ jobs:
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -97,31 +145,47 @@ jobs:
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
# Nine cells share one run id and every cell names its hosts
|
||||
# Twelve 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
|
||||
env:
|
||||
MATRIX_SUITE: ${{ matrix.suite }}
|
||||
run: |
|
||||
# The script's default set is the nightly baseline. A service cell
|
||||
# runs one suite outside it, and asks for that binary by name.
|
||||
./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" \
|
||||
--extra-suites "${MATRIX_SUITE}"
|
||||
|
||||
# Every runner agent runs on one host, while their nodes sit on several, so
|
||||
# this samples the runner's host and not the node's hypervisor. Kept for
|
||||
# runner-side contention; the node's own capacity is sampled after.
|
||||
- name: Sample runner-host load (before)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
{
|
||||
echo "=== before: $(date -u +%Y-%m-%dT%H:%M:%SZ) runner=${RUNNER_NAME} host=$(hostname) ==="
|
||||
echo "loadavg: $(cat /proc/loadavg)"
|
||||
grep -E '^(MemTotal|MemAvailable|SwapFree):' /proc/meminfo || true
|
||||
echo "cpu(steal is field 8): $(grep '^cpu ' /proc/stat || true)"
|
||||
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
|
||||
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
|
||||
|
||||
- name: Provision VMs
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
@@ -136,14 +200,15 @@ jobs:
|
||||
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
|
||||
esac
|
||||
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
# shellcheck disable=SC2086
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_NET: ${{ matrix.net }}
|
||||
@@ -166,7 +231,7 @@ jobs:
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run Single Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
|
||||
if: matrix.topo == 'single' && matrix.extra == '' && matrix.suite == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -194,7 +259,7 @@ jobs:
|
||||
done; exit \$FAIL"
|
||||
|
||||
- name: Run NAT Uplink E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'nat-single'
|
||||
if: matrix.extra == 'nat-single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
# Skips cleanly (exit 0) when the tested branch predates the natuplink
|
||||
# suite so scheduled runs against dev stay green until the merge lands.
|
||||
@@ -216,7 +281,7 @@ jobs:
|
||||
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
|
||||
|
||||
- name: Run Multi Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
|
||||
if: matrix.topo == 'real-multi' && matrix.suite == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -251,8 +316,221 @@ jobs:
|
||||
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
|
||||
done; exit \$FAIL"
|
||||
|
||||
# The dedicated suite cells only. Resolved once here rather than in each
|
||||
# step below, which would repeat the same three lines five times.
|
||||
- name: Resolve VM SSH details
|
||||
id: vm
|
||||
if: matrix.suite != '' && matrix.topo == 'single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
echo "wan_ip=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_key=${SSH_KEY/#\~/$HOME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
|
||||
# AMI, so that AMI must be in the VM's image store before the suite runs.
|
||||
# build-system-image.sh customizes with libguestfs (an isolated userspace
|
||||
# appliance touching no host block device), so it is safe on the shared
|
||||
# runner. The build needs source + network (runner only) and the import
|
||||
# needs predastore/NATS (VM only), hence the split.
|
||||
- name: Build eks-node AMI (libguestfs, runner)
|
||||
if: matrix.suite == 'eks'
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
|
||||
make build-eks-node-image
|
||||
|
||||
- name: Import eks-node AMI on VM
|
||||
if: matrix.suite == 'eks'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/eks-node/manifest.conf)"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
/tmp/eks-node-image-build/eks-node-alpine.raw \
|
||||
"$VM":/tmp/eks-node-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/eks-node-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
|
||||
|
||||
# The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
|
||||
# `aws eks get-token` -> kubectl -> apiserver -> token-webhook -> gateway
|
||||
# broker -> STS verify path. The e2e binaries run ON the VM, so kubectl
|
||||
# must be on the VM's PATH or harness.NewKubectl skips the subtest and the
|
||||
# cell reports green having never exercised the data plane. Pinned to the
|
||||
# k3s server version (setup.sh K3S_VERSION) and checksum-verified.
|
||||
- name: Install kubectl on VM
|
||||
if: matrix.suite == 'eks'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
ssh "${SSH_OPTS[@]}" tf-user@${{ steps.vm.outputs.wan_ip }} 'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
KVER=v1.32.5
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
*) echo "unsupported arch $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
cd /tmp
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl"
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl.sha256"
|
||||
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
|
||||
sudo install -m 0755 kubectl /usr/local/bin/kubectl
|
||||
kubectl version --client
|
||||
EOF
|
||||
|
||||
# RegisterContainerInstance boots the container instance from the ecs-node
|
||||
# AMI, and resolveECSNodeAMI matches on the managed-by tag rather than the
|
||||
# name, so the tag is not optional. The image directory is named for
|
||||
# IMAGE_NAME in the manifest (ecs-agent), not for the make target.
|
||||
- name: Build ecs-node AMI (libguestfs, runner)
|
||||
if: matrix.suite == 'ecs'
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
|
||||
make build-ecs-node-image
|
||||
|
||||
- name: Import ecs-node AMI on VM
|
||||
if: matrix.suite == 'ecs'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/ecs-agent/manifest.conf)"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
/tmp/ecs-agent-image-build/ecs-agent-alpine.raw \
|
||||
"$VM":/tmp/ecs-agent-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/ecs-agent-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
|
||||
|
||||
# CreateDBInstance boots the DB VM from an RDS AMI, so both engines' images
|
||||
# must be in the store before the suite runs. The suite's psql and mariadb
|
||||
# clients need no step here: they run inside a client VM that installs them
|
||||
# from its own user-data, because a private-only endpoint is unreachable
|
||||
# from the runner and from the cluster host alike.
|
||||
- name: Build RDS AMIs (libguestfs, runner)
|
||||
if: matrix.suite == 'rds'
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
|
||||
for IMAGE in rds-postgres rds-mariadb; do
|
||||
make "build-${IMAGE}-image"
|
||||
done
|
||||
|
||||
- name: Import RDS AMIs on VM
|
||||
if: matrix.suite == 'rds'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
for IMAGE in rds-postgres rds-mariadb; do
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
"spinifex/scripts/images/${IMAGE}/manifest.conf")"
|
||||
# SYSTEM_TAG carries four tags, not one: managed-by marks it a system
|
||||
# image, engine/engine-version are what an EngineVersion request
|
||||
# resolves against, and the contract tag gates the data-volume format
|
||||
# grant. --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
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
|
||||
"$VM":"/tmp/${IMAGE}-alpine.raw"
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/${IMAGE}-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}" >/dev/null 2>&1
|
||||
done
|
||||
|
||||
- name: Run Service Suite E2E
|
||||
if: matrix.suite != '' && matrix.topo == 'single'
|
||||
env:
|
||||
SUITE: ${{ matrix.suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same guard as the permutation steps, against the full single-node
|
||||
# set: a cell naming a suite this topology cannot fully exercise fails
|
||||
# here rather than skipping half its assertions.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly cell-${{ matrix.cell }}" "${SUITE}" "${E2E_SUITES_SINGLE}"
|
||||
TMO="$(e2e_suite_timeout "${SUITE}")"
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
|
||||
tf-user@${{ steps.vm.outputs.wan_ip }} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
|
||||
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 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./${SUITE}.test -test.v -test.timeout=${TMO} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
|
||||
if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \
|
||||
exit \$rc"
|
||||
|
||||
# The multi-node twin of the step above. Separate rather than merged
|
||||
# because the suite needs every node's address to reach the whole cluster,
|
||||
# which the single-node path has no way to supply.
|
||||
- name: Run Multi Suite E2E
|
||||
if: matrix.suite != '' && matrix.topo == 'real-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
SUITE: ${{ matrix.suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly cell-${{ matrix.cell }}" "${SUITE}" "${E2E_SUITES_MULTI}"
|
||||
TMO="$(e2e_suite_timeout "${SUITE}")"
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
|
||||
NODES_CSV=""
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
|
||||
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
|
||||
done
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
|
||||
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 \
|
||||
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
|
||||
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./${SUITE}.test -test.v -test.timeout=${TMO} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
|
||||
if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \
|
||||
exit \$rc"
|
||||
|
||||
- name: Import RDS PostgreSQL AMI
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
if: matrix.extra == 'tofu-examples'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -264,7 +542,7 @@ jobs:
|
||||
"spx admin images import --name spinifex-rds-postgres"
|
||||
|
||||
- name: Run Tofu Workbooks
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
if: matrix.extra == 'tofu-examples'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
# The driver emits go test -v markers per workbook; teeing to
|
||||
# test-tofu.log feeds the same go-junit-report -> e2e-analyze -> upload
|
||||
@@ -285,7 +563,7 @@ jobs:
|
||||
exit $rc
|
||||
|
||||
- name: Run Reboot Resilience E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
|
||||
if: matrix.extra == 'reboot-single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
# reboot.test runs from the runner (NOT via ssh-and-run) so it survives the
|
||||
# systemctl-reboot-induced ssh disconnect mid-run.
|
||||
@@ -320,8 +598,46 @@ jobs:
|
||||
if [ $rc -ne 0 ]; then echo "reboot: FAILED" >> "${ARTIFACT_DIR}/suite-status.txt"; fi
|
||||
exit $rc
|
||||
|
||||
- name: Sample runner-host load (after)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
{
|
||||
echo "=== after: $(date -u +%Y-%m-%dT%H:%M:%SZ) runner=${RUNNER_NAME} host=$(hostname) ==="
|
||||
echo "loadavg: $(cat /proc/loadavg)"
|
||||
grep -E '^(MemTotal|MemAvailable|SwapFree):' /proc/meminfo || true
|
||||
echo "cpu(steal is field 8): $(grep '^cpu ' /proc/stat || true)"
|
||||
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
|
||||
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
|
||||
|
||||
# The node, not the runner's host, is what the guests are scheduled on, so
|
||||
# its load is the figure that explains a starved guest. Never fails the
|
||||
# cell: a node already gone is the case this is trying to describe.
|
||||
- name: Sample node load (after)
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
INVENTORY=$(tofu output -json inventory 2>/dev/null)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip' 2>/dev/null)
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path' 2>/dev/null)
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
[ -n "$NODE1_IP" ] && [ "$NODE1_IP" != "null" ] || exit 0
|
||||
[ -n "$SSH_KEY" ] && [ "$SSH_KEY" != "null" ] || exit 0
|
||||
{
|
||||
echo "=== node after: $(date -u +%Y-%m-%dT%H:%M:%SZ) ip=${NODE1_IP} ==="
|
||||
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
-i "$SSH_KEY" "tf-user@${NODE1_IP}" \
|
||||
'echo "host: $(hostname)"; echo "loadavg: $(cat /proc/loadavg)";
|
||||
grep -E "^(MemTotal|MemAvailable|SwapFree):" /proc/meminfo;
|
||||
echo "cpu(steal is field 8): $(grep "^cpu " /proc/stat)";
|
||||
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"' 2>&1
|
||||
} >> "${ARTIFACT_DIR}/node-load.txt"
|
||||
exit 0
|
||||
|
||||
- name: Collect e2e artifacts
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set +e
|
||||
@@ -344,6 +660,11 @@ jobs:
|
||||
> "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null
|
||||
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \
|
||||
> "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null
|
||||
# The unit filter above drops kernel and OOM messages, which are
|
||||
# exactly what explains one node behaving unlike its siblings.
|
||||
# Gzipped on the far side; the whole journal is far larger.
|
||||
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl --no-pager --output=short-iso --since "90 min ago" | gzip -6' \
|
||||
> "${ARTIFACT_DIR}/full-journal-node${idx}.log.gz" 2>/dev/null
|
||||
# ovn-controller and ovs-vswitchd log to files, never to journald:
|
||||
# without these a cross-chassis black-hole cannot be attributed to
|
||||
# IPsec versus SB port binding.
|
||||
@@ -356,14 +677,74 @@ jobs:
|
||||
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || true
|
||||
echo "collected: $(ls "${ARTIFACT_DIR}" 2>/dev/null | wc -l) entries"
|
||||
|
||||
# Whether br-mgmt is a per-host island or one L2 segment spanning the
|
||||
# cluster decides whether a system instance can reach the gateway address
|
||||
# it was baked with. Nothing in a failing run records which one was built,
|
||||
# and the environment is destroyed before it can be asked.
|
||||
- name: Collect management-fabric topology
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
OUT="${ARTIFACT_DIR}/mgmt-fabric.txt"
|
||||
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path'); SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY")
|
||||
|
||||
declare -a WAN=() MGMT=()
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
WAN[$i]=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
|
||||
done
|
||||
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
idx=$((i + 1))
|
||||
{
|
||||
echo "=== node${idx} (${WAN[$i]}) ==="
|
||||
ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
|
||||
'sudo ovs-vsctl list-ports br-mgmt 2>&1 | sed "s/^/ports: /"; \
|
||||
ip -4 addr show br-mgmt 2>&1 | sed "s/^/addr: /"; \
|
||||
sudo ovs-vsctl get Bridge br-mgmt fail_mode 2>&1 | sed "s/^/fail: /"'
|
||||
echo
|
||||
} >> "$OUT" 2>&1
|
||||
MGMT[$i]=$(ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
|
||||
"ip -4 -o addr show br-mgmt 2>/dev/null | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null)
|
||||
done
|
||||
|
||||
# An island cluster reaches only its own address and that is correct;
|
||||
# a spanned one must reach every peer. Either result is a finding, and
|
||||
# a mixed result is the failure this matrix exists to catch.
|
||||
{
|
||||
echo "=== br-mgmt reachability (row = prober, column = target) ==="
|
||||
printf '%-10s' "from\\to"
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do printf '%-18s' "node$((i+1)):${MGMT[$i]:-none}"; done
|
||||
echo
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
printf '%-10s' "node$((i+1))"
|
||||
for j in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
if [ -z "${MGMT[$j]}" ]; then printf '%-18s' "-"; continue; fi
|
||||
if ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
|
||||
"ping -c1 -W1 ${MGMT[$j]} >/dev/null 2>&1"; then
|
||||
printf '%-18s' ok
|
||||
else
|
||||
printf '%-18s' FAIL
|
||||
fi
|
||||
done
|
||||
echo
|
||||
done
|
||||
} >> "$OUT" 2>&1
|
||||
|
||||
cat "$OUT"
|
||||
|
||||
- name: Install go-junit-report
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Convert test logs to JUnit XML
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
|
||||
@@ -374,7 +755,7 @@ jobs:
|
||||
# Tolerated on failure: the sink being unreachable must not turn a
|
||||
# green test run red.
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
with:
|
||||
@@ -382,7 +763,7 @@ jobs:
|
||||
cell: ${{ matrix.cell }}
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
@@ -390,18 +771,29 @@ jobs:
|
||||
title: "E2E failure analysis (cell ${{ matrix.cell }})"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# The XML on its own, so the roll-up can read every cell's counts without
|
||||
# pulling nineteen journals it has no use for.
|
||||
- name: Upload junit only
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Gated like every other step, not just always(): a cell the dispatch
|
||||
# deselected never checked out or ran tofu init, so destroying would fail
|
||||
# on missing plugins and report the cell red for correctly doing nothing.
|
||||
- name: Teardown
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
@@ -412,13 +804,15 @@ jobs:
|
||||
case "$MATRIX_HOST" in
|
||||
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
|
||||
esac
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
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
|
||||
# shellcheck disable=SC2086
|
||||
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always() && steps.gate.outputs.selected == 'true'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tofu-log-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
@@ -428,7 +822,7 @@ jobs:
|
||||
|
||||
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
|
||||
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
|
||||
# matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
|
||||
# matrix cell ("cell 20"): every nightly cron, or dispatch with 20 selected
|
||||
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
|
||||
baremetal:
|
||||
name: cell-20 (baremetal/pxe/real-hw)
|
||||
@@ -448,6 +842,34 @@ jobs:
|
||||
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=20,ci.matrix=baremetal/pxe/real-hw
|
||||
secrets: inherit
|
||||
|
||||
# The guest disk-performance gate. Same bare-metal node as cell 20 and
|
||||
# therefore queued behind it by the shared concurrency group, which is the
|
||||
# point: perf numbers off a cell still running other suites are noise, and
|
||||
# the availability assertions measure host responsiveness under a single
|
||||
# tenant's load, which a co-tenant correctness suite would confound.
|
||||
#
|
||||
# It reimages the cluster first, like every other caller of that workflow, so
|
||||
# it is a fresh node rather than whatever cell-20 left behind.
|
||||
diskperf:
|
||||
name: cell-28 (baremetal/diskperf)
|
||||
needs: baremetal
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',28,')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
cell: "28"
|
||||
suites: diskperf
|
||||
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=28,ci.matrix=baremetal/pxe/diskperf
|
||||
secrets: inherit
|
||||
|
||||
# ISO cells. Unlike 1-19 these need no tofu: the harnesses run QEMU directly
|
||||
# on the runner. The nodes are therefore L1, which keeps the instances they
|
||||
# launch at L2 and their benchmark numbers comparable between the two cells.
|
||||
@@ -634,6 +1056,22 @@ jobs:
|
||||
EOF
|
||||
./test/test-workload.sh --env /tmp/cell21.env --benchmark
|
||||
|
||||
# The node's own journal, which nothing else preserves: the serial log
|
||||
# stops at login and the harness owns the only node there is. Pulled
|
||||
# before teardown and never fatal, so a passing night keeps it too.
|
||||
- name: Collect node journal
|
||||
if: always() && steps.smoke.outputs.node_ip != ''
|
||||
env:
|
||||
NODE_IP: ${{ steps.smoke.outputs.node_ip }}
|
||||
run: |
|
||||
mkdir -p /tmp/cell21-artifacts-$GITHUB_RUN_ID
|
||||
timeout 180 ssh -o BatchMode=yes -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 \
|
||||
-i "$HOME/.ssh/spinifex-key" "spinifex@${NODE_IP}" \
|
||||
'sudo journalctl -b --no-pager --output=short-iso | gzip -6' \
|
||||
> "/tmp/cell21-artifacts-$GITHUB_RUN_ID/node-journal.log.gz" ||
|
||||
echo "::warning::could not pull the node journal from ${NODE_IP}"
|
||||
|
||||
- name: Collect logs
|
||||
if: always()
|
||||
run: |
|
||||
@@ -697,7 +1135,7 @@ jobs:
|
||||
# them together would need 32 GiB on one box.
|
||||
needs: [iso-single]
|
||||
if: >-
|
||||
always() && (
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',22,') )
|
||||
@@ -852,13 +1290,14 @@ jobs:
|
||||
# bottlebrush, so "a different label" would not have separated them. needs: on
|
||||
# a matrix job waits for every leg, so this starts on a quiet machine.
|
||||
#
|
||||
# always() because a failed cell must not silently skip the benchmark and
|
||||
# leave a green-looking night with no number in it.
|
||||
# !cancelled() rather than always(): a failed cell must not silently skip the
|
||||
# benchmark and leave a green-looking night with no number in it, but always()
|
||||
# also overrides cancellation and keeps the run starting jobs after a cancel.
|
||||
performance:
|
||||
name: cell-23 (predastore/throughput)
|
||||
needs: [matrix, baremetal, iso-single, iso-multi]
|
||||
if: >-
|
||||
always() && (
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',23,') )
|
||||
@@ -970,12 +1409,15 @@ jobs:
|
||||
name: cell-24 (predastore/fault-injection)
|
||||
needs: [matrix, baremetal, iso-single, iso-multi, performance]
|
||||
if: >-
|
||||
always() && (
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',24,') )
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 30
|
||||
# The thirteen scenarios run serially and are ~35 minutes of work on their
|
||||
# own, measured from the per-scenario predastore matrix, before checkout
|
||||
# and build. 30 could not have passed whatever the disk did.
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
# Freezes a follower. STRESS_HOST=leader is a known failure: a gate keeps
|
||||
# the frozen node first in its meta read order and pays the full client
|
||||
@@ -988,12 +1430,20 @@ jobs:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
# STRESS_WORK_ROOT is set as well as TMPDIR because the harness ignores
|
||||
# TMPDIR for the scenarios that need the most disk: large-object and
|
||||
# multipart-upload default to $HOME/.cache/predastore-e2e, on the theory
|
||||
# that TMPDIR is commonly tmpfs. Here it is a 938G nvme, so pointing them
|
||||
# at it is both correct and the only path with room.
|
||||
- name: Configure work directories
|
||||
run: |
|
||||
E2E_ROOT=/mnt/nvme/e2e
|
||||
mkdir -p "$E2E_ROOT/work" 2>/dev/null ||
|
||||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
|
||||
for d in work stress-work; do
|
||||
mkdir -p "$E2E_ROOT/$d" 2>/dev/null ||
|
||||
{ sudo mkdir -p "$E2E_ROOT/$d" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/$d"; }
|
||||
done
|
||||
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
|
||||
echo "STRESS_WORK_ROOT=$E2E_ROOT/stress-work" >> "$GITHUB_ENV"
|
||||
echo "STRESS_RESULTS_ROOT=$E2E_ROOT/predastore-stress-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Spinifex
|
||||
@@ -1021,15 +1471,25 @@ jobs:
|
||||
- name: Preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$TMPDIR"
|
||||
find "$TMPDIR" -maxdepth 1 -name 'predastore-e2e-stress.*' \
|
||||
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
|
||||
df -h "$TMPDIR"
|
||||
avail_gb=$(df -BG --output=avail "$TMPDIR" | tail -1 | tr -dc '0-9')
|
||||
if [ "$avail_gb" -lt 10 ]; then
|
||||
echo "::error::$TMPDIR has ${avail_gb} GiB free; the run needs headroom above predastore's 5% watermark."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$TMPDIR" "$STRESS_WORK_ROOT"
|
||||
for d in "$TMPDIR" "$STRESS_WORK_ROOT" "$HOME/.cache/predastore-e2e"; do
|
||||
[ -d "$d" ] || continue
|
||||
find "$d" -maxdepth 1 -name 'predastore-e2e-stress.*' \
|
||||
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
|
||||
done
|
||||
# Both paths, because they can be different filesystems and the one
|
||||
# holding the large objects is not the one TMPDIR names. large-object
|
||||
# needs 2GiB and 4GiB sharded with parity, multipart 1GiBx8.
|
||||
rc=0
|
||||
for d in "$TMPDIR" "$STRESS_WORK_ROOT"; do
|
||||
df -h "$d"
|
||||
avail_gb=$(df -BG --output=avail "$d" | tail -1 | tr -dc '0-9')
|
||||
if [ "$avail_gb" -lt 40 ]; then
|
||||
echo "::error::$d has ${avail_gb} GiB free; the run needs headroom above predastore's 5% watermark and room for the 1GiBx8 multipart case."
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
exit "$rc"
|
||||
|
||||
# Leftovers from a run killed before its cleanup trap: a surviving node
|
||||
# holds its ports and fails this run somewhere unrelated.
|
||||
@@ -1059,3 +1519,40 @@ jobs:
|
||||
run: |
|
||||
pkill -KILL -f '/bin/s3d -config' || true
|
||||
rm -rf "$STRESS_RESULTS_ROOT"
|
||||
|
||||
# Reading a nightly meant opening every cell job, and the Actions UI does not
|
||||
# separate a cell that failed from one cancelled out from under it. needs:
|
||||
# every cell so this runs last; always() so a red run is the one it describes.
|
||||
summary:
|
||||
name: run summary
|
||||
needs:
|
||||
[
|
||||
select,
|
||||
matrix,
|
||||
baremetal,
|
||||
diskperf,
|
||||
iso-single,
|
||||
iso-multi,
|
||||
performance,
|
||||
stress,
|
||||
]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: .github/scripts/e2e-run-summary.py
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Collect junit
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: junit-cell-*-${{ github.run_id }}
|
||||
path: junit
|
||||
continue-on-error: true
|
||||
|
||||
- name: Write run summary
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JUNIT_ROOT: junit
|
||||
run: python3 .github/scripts/e2e-run-summary.py
|
||||
|
||||
@@ -43,18 +43,22 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Shared with e2e-nightly.yml and e2e-ddil.yml: every one of them provisions
|
||||
# many cells on the same runner pools and the same hypervisors, so two in
|
||||
# flight compete for their disks and their libvirt guests. Queued rather than
|
||||
# cancelled — a run in progress is someone's result, not a stale one. e2e.yml
|
||||
# is outside the group: one cluster per run is cheap enough to overlap.
|
||||
concurrency:
|
||||
group: e2e-hypervisors
|
||||
cancel-in-progress: false
|
||||
|
||||
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 }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
# 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' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
@@ -73,12 +77,6 @@ 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
|
||||
@@ -143,10 +141,9 @@ jobs:
|
||||
LIST="${REQUESTED:-${E2E_SUITES_SINGLE}}"
|
||||
e2e_suites_assert_subset "single_suites" "${LIST}" "${E2E_SUITES_SINGLE}"
|
||||
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 .)
|
||||
# 45 is the floor this job has always had, so resolving each leg's
|
||||
# budget can only raise it — storagefault and rds ask for more.
|
||||
JSON=$(e2e_suite_matrix "${LIST}" 45)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reclaim docker build cache
|
||||
@@ -182,7 +179,7 @@ jobs:
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "VM provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
@@ -311,14 +308,16 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
single_suite:
|
||||
name: Single env · suite
|
||||
name: Single env · ${{ matrix.suite }}
|
||||
needs: single_provision
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 45
|
||||
# Per leg, from suite-sets.sh: one number for every suite would have to be
|
||||
# storagefault's, and every other leg would then hang for 105 minutes.
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: ${{ fromJSON(needs.single_provision.outputs.matrix) }}
|
||||
include: ${{ fromJSON(needs.single_provision.outputs.matrix) }}
|
||||
env:
|
||||
WAN_IP: ${{ needs.single_provision.outputs.wan_ip }}
|
||||
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
|
||||
@@ -343,6 +342,8 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
SUITE="${{ matrix.suite }}"
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
TMO="$(e2e_suite_timeout "${SUITE}")"
|
||||
VM_HOST="tf-user@${WAN_IP}"
|
||||
TEST_RUN_FLAG=""
|
||||
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
|
||||
@@ -360,7 +361,7 @@ jobs:
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
|
||||
./${SUITE}.test -test.v -test.timeout=${TMO} ${TEST_RUN_FLAG} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
|
||||
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
|
||||
exit \${PIPESTATUS[0]}"
|
||||
@@ -516,7 +517,7 @@ jobs:
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
|
||||
ok "teardown complete"
|
||||
|
||||
@@ -627,7 +628,7 @@ jobs:
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "3-node cluster provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
@@ -669,10 +670,9 @@ jobs:
|
||||
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 .)
|
||||
# 60 is the floor this job has always had; multi-node legs carry a
|
||||
# heavier setup than single-node ones, which is why it is higher.
|
||||
JSON=$(e2e_suite_matrix "${LIST}" 60)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build multinode binaries on runner
|
||||
@@ -722,14 +722,15 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
multi_suite:
|
||||
name: Multi env · suite
|
||||
name: Multi env · ${{ matrix.suite }}
|
||||
needs: multi_provision
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 60
|
||||
# See the note on single_suite: the budget is the suite's, not the job's.
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
|
||||
include: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
|
||||
env:
|
||||
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
|
||||
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
|
||||
@@ -755,6 +756,8 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
SUITE="${{ matrix.suite }}"
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
TMO="$(e2e_suite_timeout "${SUITE}")"
|
||||
VM_HOST="tf-user@${NODE1_IP}"
|
||||
TEST_RUN_FLAG=""
|
||||
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
|
||||
@@ -773,7 +776,7 @@ jobs:
|
||||
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
|
||||
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
|
||||
./${SUITE}.test -test.v -test.timeout=${TMO} ${TEST_RUN_FLAG} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
|
||||
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
|
||||
exit \${PIPESTATUS[0]}"
|
||||
@@ -899,7 +902,7 @@ jobs:
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
|
||||
ok "teardown complete"
|
||||
|
||||
|
||||
+69
-12
@@ -7,6 +7,7 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "!**/*_test.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
@@ -26,20 +27,67 @@ 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 e2e-partialblock' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
# 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' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
# The inner timeout must fire before the outer one. `go test` reports where it
|
||||
# stopped and the suite's own failure; a job cancelled on timeout reports only
|
||||
# "The operation was canceled" and takes the artifacts with it.
|
||||
#
|
||||
# A job budget cannot be a literal here because the suite set is a dispatch
|
||||
# input: storagefault asks for 90m and diskperf for 180m, both eligible on one
|
||||
# node, so any fixed number is either wrong for them or wasteful for everyone
|
||||
# else. Resolving it from suite-sets.sh keeps the one source of truth and
|
||||
# leaves the default push set at exactly the 60m it has always had.
|
||||
plan:
|
||||
name: Resolve Job Budgets
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
|
||||
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Resolve Budgets
|
||||
id: budgets
|
||||
env:
|
||||
# Provision, install and teardown around the suites, plus the headroom
|
||||
# a multi-suite job needs for whatever ran before the one that hangs.
|
||||
SETUP_ALLOWANCE_MIN: 15
|
||||
# What the job has always had. Kept as a floor so resolving the budget
|
||||
# can only ever raise it, never quietly shorten an existing run.
|
||||
FLOOR_MIN: 60
|
||||
run: |
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
# Only max() is a sound bound: the suites share one job, so a sum would
|
||||
# demand hours for a set that has never taken them. Max is what makes
|
||||
# the hanging suite panic on its own timeout first, which is the point.
|
||||
budget() {
|
||||
local want="${FLOOR_MIN}" s t
|
||||
for s in $(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "$1"); do
|
||||
t="$(e2e_suite_timeout "${s}")"
|
||||
t=$(( ${t%m} + SETUP_ALLOWANCE_MIN ))
|
||||
[ "${t}" -gt "${want}" ] && want="${t}"
|
||||
done
|
||||
echo "${want}"
|
||||
}
|
||||
single=$(budget "${E2E_SUITES_SINGLE_RE}")
|
||||
multi=$(budget "${E2E_SUITES_MULTI_RE}")
|
||||
echo "single_timeout=${single}" >> "$GITHUB_OUTPUT"
|
||||
echo "multi_timeout=${multi}" >> "$GITHUB_OUTPUT"
|
||||
echo "suites='${SUITES}' -> single ${single}m, multi ${multi}m"
|
||||
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
@@ -51,8 +99,9 @@ jobs:
|
||||
# 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')
|
||||
needs: plan
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.single_timeout) }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
|
||||
@@ -152,7 +201,7 @@ jobs:
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "VM provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
@@ -357,6 +406,10 @@ jobs:
|
||||
# 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' ' ')
|
||||
# Resolve each suite's timeout here, where suite-sets.sh is readable, and
|
||||
# carry the pairs into the remote loop below — the VM has no checkout.
|
||||
SUITE_TMOS=""
|
||||
for s in ${SUITES_RUN}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
@@ -388,7 +441,7 @@ jobs:
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
case \$SUITE in rds) TMO=50m ;; *) TMO=30m ;; esac; \
|
||||
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
|
||||
@@ -520,7 +573,7 @@ jobs:
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
|
||||
ok "teardown complete"
|
||||
|
||||
@@ -549,8 +602,9 @@ jobs:
|
||||
# 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')
|
||||
needs: plan
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.multi_timeout) }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
|
||||
@@ -646,7 +700,7 @@ jobs:
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "3-node cluster provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
@@ -772,6 +826,8 @@ jobs:
|
||||
# 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' ' ')
|
||||
SUITE_TMOS=""
|
||||
for s in ${SUITES_RUN}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
@@ -803,13 +859,14 @@ jobs:
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
|
||||
SPINIFEX_NODES='${NODES_CSV}' \
|
||||
SPINIFEX_NODE_IPS='${NODES_CSV}' \
|
||||
SPINIFEX_SSH_USER=tf-user \
|
||||
SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./\$SUITE.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
|
||||
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
|
||||
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
|
||||
rc=\${PIPESTATUS[0]}; \
|
||||
@@ -972,7 +1029,7 @@ jobs:
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
|
||||
ok "teardown complete"
|
||||
|
||||
|
||||
@@ -56,15 +56,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# OTLP export to the central Elastic sink (wattle is the sink itself — see
|
||||
# Part A.2). Repo variable overrides; carried for correlation even though
|
||||
# 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' }}
|
||||
# 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' }}
|
||||
@@ -78,8 +70,6 @@ jobs:
|
||||
env:
|
||||
ARTIFACT_DIR: /tmp/spinifex-gpu-e2e-artifacts-${{ github.run_id }}
|
||||
steps:
|
||||
# Manual-work mutex gate — see setup note 4 above. Checked BEFORE any
|
||||
# SSH to wattle so a paused run touches the box zero times.
|
||||
- name: Manual-work mutex gate
|
||||
id: gate
|
||||
env:
|
||||
@@ -122,8 +112,6 @@ jobs:
|
||||
echo "${{ secrets.WATTLE_SSH_KEY }}" > "${RUNNER_TEMP}/wattle-ci"
|
||||
echo "ssh_opts=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${RUNNER_TEMP}/wattle-ci" >> "$GITHUB_ENV"
|
||||
|
||||
# Healthcheck: confirm wattle's persistent AWS-compat control plane is
|
||||
# actually up before spending time building/shipping the test binary.
|
||||
- name: Healthcheck — describe-regions
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
run: |
|
||||
@@ -131,18 +119,6 @@ jobs:
|
||||
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" \
|
||||
'AWS_PROFILE=spinifex aws ec2 describe-regions --output text'
|
||||
|
||||
# Ensure the base EC2 GPU AMI is present — the raw-EC2 GPU passthrough
|
||||
# test is the always-on baseline, so CI self-provisions it rather than
|
||||
# depending on a manual import. `--name` selects the catalog entry (pulls
|
||||
# the prebuilt image from iso.mulgadc.com); `--ami-name` is REQUIRED to
|
||||
# name the AMI ubuntu-26.04-nvidia-gpu-x86_64 — without it the import
|
||||
# falls back to the generic ami-<distro>-<version>-<arch>, which the
|
||||
# tests/e2e/gpu filter would never match. Idempotent — skipped once
|
||||
# wattle's persistent loop-fs already has the named AMI (the 4.4G source
|
||||
# qcow2 is cached in the images dir, so a re-import wouldn't re-download).
|
||||
# `spx admin images import` writes local spinifex storage, so it needs
|
||||
# the `spinifex` group active (sg); aws-CLI calls hit the gateway over
|
||||
# HTTPS and don't.
|
||||
- name: Ensure base GPU AMI present (import-if-absent)
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
run: |
|
||||
@@ -171,11 +147,6 @@ jobs:
|
||||
'
|
||||
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
|
||||
|
||||
# Preflight — the base GPU AMI is REQUIRED: the setup step above just
|
||||
# ensured it, so a miss here means the import silently failed. Hard-fail
|
||||
# rather than run a false-green all-skipped suite. ECS/EKS node AMIs are
|
||||
# opt-in tiers (heavier — node AMIs + cluster spin-up); their tests
|
||||
# self-skip when absent, so only warn.
|
||||
- name: Preflight — verify GPU AMIs
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
run: |
|
||||
@@ -205,10 +176,6 @@ jobs:
|
||||
'
|
||||
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
|
||||
|
||||
# Same build command CI's other e2e workflows use to cross-compile a
|
||||
# suite into a shippable binary (tests/e2e/Makefile e2e-build target,
|
||||
# SUITES_BUILD scoped to just gpu). Building on the runner keeps go
|
||||
# toolchain / module cache off wattle entirely.
|
||||
- name: Build GPU e2e test binary on runner
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
working-directory: tests/e2e
|
||||
@@ -231,12 +198,6 @@ jobs:
|
||||
ssh ${{ env.ssh_opts }} "$VM" "mkdir -p \$HOME/spinifex-gpu-e2e-bin"
|
||||
scp ${{ env.ssh_opts }} tests/e2e/_bin/gpu.test "$VM":spinifex-gpu-e2e-bin/gpu.test
|
||||
|
||||
# Same invocation shape e2e.yml uses to run a pre-built suite binary
|
||||
# (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, 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'
|
||||
@@ -287,8 +248,6 @@ jobs:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "GPU E2E results"
|
||||
|
||||
# Tolerated on failure: the sink being unreachable must not turn a
|
||||
# green test run red.
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
continue-on-error: true
|
||||
@@ -313,14 +272,6 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Cleanup is NEVER a host teardown: it never runs setup-ovn.sh,
|
||||
# dev-reset, or cluster-reset. It only reconciles EC2-API-level state —
|
||||
# terminate any GPU-typed instance still pending/running (the Go
|
||||
# suite's own t.Cleanup already does this on normal pass/fail exit;
|
||||
# this is the backstop for a hard `-test.timeout` kill or a workflow
|
||||
# cancellation, which skip deferred Go cleanups). Termination triggers
|
||||
# gpuManager.Release() on wattle, returning the A1000 to the pool for
|
||||
# the next queued run or manual test.
|
||||
- name: Cleanup — terminate leftover GPU instances, release GPU
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
run: |
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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:
|
||||
@@ -37,3 +33,25 @@ jobs:
|
||||
|
||||
- name: Run image script tests + shellcheck
|
||||
run: make test-images
|
||||
|
||||
eks_mkosi_image:
|
||||
name: Build mkosi EKS image
|
||||
runs-on: [self-hosted, ci-single]
|
||||
concurrency:
|
||||
group: eks-mkosi-image-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
# Self-hosted: the module + build cache already persists on local
|
||||
# disk, and cache-save would re-upload hundreds of MB for nothing.
|
||||
cache: false
|
||||
|
||||
- name: Build the image
|
||||
run: make build-eks-node-mkosi-image
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
name: Predastore 3-Node Performance
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
perf_preset:
|
||||
description: "Warp workload preset: smoke (30s) or compare (2m)"
|
||||
required: false
|
||||
default: compare
|
||||
type: choice
|
||||
options:
|
||||
- smoke
|
||||
- compare
|
||||
cluster_ref:
|
||||
description: "Git ref to build the cluster from, e.g. v1.18.0. Empty uses this branch."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
# Shared with e2e.yml and e2e-nightly.yml: this job provisions on the same
|
||||
# hypervisors they do, so a run in flight there and here would compete for
|
||||
# disks and libvirt guests. Queued, not cancelled, same as those workflows.
|
||||
concurrency:
|
||||
group: e2e-hypervisors
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
|
||||
jobs:
|
||||
perf_3node:
|
||||
name: Predastore Throughput (3-node)
|
||||
# ci-multi is a systemd runner on the LAN, not the ephemeral pool. A pool
|
||||
# VM sits behind the geneve overlay at MTU 1408, which would put the
|
||||
# runner's own network path inside the thing being measured.
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-perf3node-${{ github.run_id }}.log
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=perf-3node
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
set +e
|
||||
UID_SELF=$(id -u)
|
||||
WS="${GITHUB_WORKSPACE}"
|
||||
reap() {
|
||||
local sig="$1"
|
||||
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
|
||||
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
|
||||
case "$cwd" in
|
||||
"$WS"|"$WS"/*) echo "reap $sig pid=$pid cwd=$cwd"; kill "-$sig" "$pid" 2>/dev/null ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
reap TERM
|
||||
sleep 3
|
||||
reap KILL
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
true
|
||||
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
# ci-multi and ci-single resolve to the same physical host, so the
|
||||
# nvme path cell-23 uses for its throughput run is already there.
|
||||
# Pointing TMPDIR at it keeps warp's payloads off a small /tmp tmpfs.
|
||||
- name: Configure work directories
|
||||
run: |
|
||||
E2E_ROOT=/mnt/nvme/e2e
|
||||
mkdir -p "$E2E_ROOT/work" 2>/dev/null ||
|
||||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
|
||||
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
|
||||
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-3node-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Re-enable Go toolchain auto-download
|
||||
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
|
||||
|
||||
# Same-named branch when one exists, dev otherwise. Matches how the
|
||||
# e2e path picks siblings, so this measures the same predastore
|
||||
# commit a cross-repo change would be built and tested against.
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ env.GIT_BRANCH }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
id: predastore
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
# The cluster's ref is a knob and the harness's is not. Measuring an old
|
||||
# release with a new harness is the only way its number can be set beside
|
||||
# a current one; rebuilding both would change the ruler and the thing.
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
CLUSTER_REF: ${{ inputs.cluster_ref }}
|
||||
run: |
|
||||
./build-install-artifacts.sh "${CLUSTER_REF:-$GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
--skip-e2e-build
|
||||
|
||||
# The libvirt provider dials the hypervisors by bare hostname. A pool VM
|
||||
# sits inside a VPC and cannot resolve them; a LAN runner already can, so
|
||||
# this is self-gating on the lookup rather than on the runner's labels.
|
||||
- name: Map hypervisor names
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if getent hosts banksia >/dev/null; then
|
||||
echo "hypervisor names already resolve"
|
||||
else
|
||||
sudo tee -a /etc/hosts < network/hosts >/dev/null
|
||||
echo "added $(grep -c '^[0-9]' network/hosts) entries to /etc/hosts"
|
||||
fi
|
||||
# Pinned rather than StrictHostKeyChecking=no: these hops reach the
|
||||
# hypervisors themselves, so an unverified key is the one place in
|
||||
# this job where accepting anything would matter.
|
||||
if ssh-keygen -F banksia >/dev/null 2>&1; then
|
||||
echo "hypervisor host keys already known"
|
||||
else
|
||||
install -d -m 700 ~/.ssh
|
||||
cat network/known_hosts >> ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/known_hosts
|
||||
echo "pinned $(grep -c '^[a-z]' network/known_hosts) host keys"
|
||||
fi
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
|
||||
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
ok "3-node cluster provisioned"
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Resolve cluster SSH details
|
||||
id: cluster
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
|
||||
REGION=$(echo "$INVENTORY" | jq -r '.region')
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
NODES_CSV=""
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
|
||||
if [ -z "$NODES_CSV" ]; then NODES_CSV="$ip"; else NODES_CSV="$NODES_CSV,$ip"; fi
|
||||
done
|
||||
echo "node1_ip=${NODE1_IP}" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_user=${SSH_USER}" >> "$GITHUB_OUTPUT"
|
||||
echo "region=${REGION}" >> "$GITHUB_OUTPUT"
|
||||
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# PERF_EXTERNAL_HOSTS wants host:port, and the gate listens on 8443 on
|
||||
# every node. run-info.txt from this run is then comparable with the
|
||||
# nightly's loopback one, since the harness itself never changes.
|
||||
- name: Resolve PERF_EXTERNAL_HOSTS
|
||||
id: perf_hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
|
||||
HOSTS=""
|
||||
for ip in "${NODE_LIST[@]}"; do
|
||||
[ -z "$ip" ] && continue
|
||||
if [ -z "$HOSTS" ]; then HOSTS="$ip:8443"; else HOSTS="$HOSTS,$ip:8443"; fi
|
||||
done
|
||||
echo "hosts=${HOSTS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A slow bootstrap should fail here, legibly, rather than inside warp
|
||||
# with a wall of connection-refused errors. Bounded at 5 minutes per
|
||||
# gate so a genuinely dead node still fails the job in finite time.
|
||||
- name: Wait for gates to answer on :8443
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IFS=',' read -ra GATES <<< "${{ steps.perf_hosts.outputs.hosts }}"
|
||||
for gate in "${GATES[@]}"; do
|
||||
echo "waiting for https://$gate"
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
if curl -k -sS --max-time 5 -o /dev/null "https://$gate/"; then
|
||||
up=1
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
[ "$up" -eq 1 ] || { echo "::error::gate $gate never answered HTTPS on :8443"; exit 1; }
|
||||
echo "gate $gate is up"
|
||||
done
|
||||
|
||||
# Warp reads whole objects, so no workload here touches a ranged read and
|
||||
# the throughput figures stay healthy while ranged GETs are broken. This
|
||||
# probe is the only thing in CI that looks at them.
|
||||
- name: Ranged GET probe
|
||||
env:
|
||||
GATE: ${{ steps.cluster.outputs.node1_ip }}
|
||||
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
|
||||
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
|
||||
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
|
||||
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
|
||||
AK=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
|
||||
SK=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
|
||||
echo "::add-mask::$SK"
|
||||
B="ranged-probe-${GITHUB_RUN_ID}"
|
||||
AWSX=(--region "$CLUSTER_REGION" --endpoint-url "https://${GATE}:8443" --no-verify-ssl)
|
||||
export AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK"
|
||||
aws s3 mb "s3://$B" "${AWSX[@]}"
|
||||
trap 'aws s3 rb "s3://$B" --force "${AWSX[@]}" >/dev/null 2>&1 || true' EXIT
|
||||
# Two sizes: read amplification makes a boundary-crossing range cost
|
||||
# the whole object, so the fault shows as time tracking object size.
|
||||
for mb in 64 256; do
|
||||
head -c $((mb*1024*1024)) /dev/urandom > "${TMPDIR}/probe-${mb}.bin"
|
||||
aws s3 cp "${TMPDIR}/probe-${mb}.bin" "s3://$B/probe-${mb}.bin" --only-show-errors "${AWSX[@]}"
|
||||
rm -f "${TMPDIR}/probe-${mb}.bin"
|
||||
done
|
||||
t() {
|
||||
curl -sk --aws-sigv4 "aws:amz:${CLUSTER_REGION}:s3" -u "$AK:$SK" \
|
||||
-H "Range: bytes=0-$1" -o /dev/null -w '%{time_total}' \
|
||||
--url "https://${GATE}:8443/${B}/probe-${2}.bin"
|
||||
}
|
||||
# 4194304 is the last byte inside the first 4 MiB block; one more
|
||||
# crosses into the second and leaves the single-shard fast path.
|
||||
IN64=$(t 4194303 64); OUT64=$(t 4194304 64)
|
||||
IN256=$(t 4194303 256); OUT256=$(t 4194304 256)
|
||||
printf '%-10s in-block=%ss crossing=%ss\n' "64MiB" "$IN64" "$OUT64"
|
||||
printf '%-10s in-block=%ss crossing=%ss\n' "256MiB" "$IN256" "$OUT256"
|
||||
# The 4x object-size ratio is the signal, and it is compared against
|
||||
# the crossing reads alone so a slow runner cancels out of both sides.
|
||||
awk -v a="$OUT64" -v b="$OUT256" 'BEGIN{
|
||||
r = (a > 0) ? b/a : 0
|
||||
printf "crossing-range cost ratio 256MiB/64MiB = %.2f (object size ratio is 4.00)\n", r
|
||||
if (r > 2.0) {
|
||||
print "::error::a boundary-crossing ranged GET scales with object size, so it is reading the whole object"
|
||||
exit 1
|
||||
}
|
||||
}'
|
||||
echo "ranged GET does not scale with object size"
|
||||
|
||||
# The benchmark works in $TMPDIR and predastore refuses writes below a 5%
|
||||
# free-space watermark, so this asserts the same headroom cell-23 does.
|
||||
# tmpfs is rejected outright: that would measure RAM, not the gates.
|
||||
- name: Preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$TMPDIR"
|
||||
fstype=$(df --output=fstype "$TMPDIR" | tail -1 | tr -d ' ')
|
||||
case "$fstype" in
|
||||
tmpfs|ramfs)
|
||||
echo "::error::$TMPDIR is $fstype — a storage benchmark there measures RAM"
|
||||
exit 1 ;;
|
||||
esac
|
||||
find "$TMPDIR" -maxdepth 1 -name 'predastore-e2e-performance.*' \
|
||||
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
|
||||
df -h "$TMPDIR"
|
||||
avail_gb=$(df -BG --output=avail "$TMPDIR" | tail -1 | tr -dc '0-9')
|
||||
pct_used=$(df --output=pcent "$TMPDIR" | tail -1 | tr -dc '0-9')
|
||||
if [ "$pct_used" -ge 90 ] || [ "$avail_gb" -lt 20 ]; then
|
||||
echo "::error::$TMPDIR ($fstype) is ${pct_used}% full with ${avail_gb} GiB free."
|
||||
echo "::error::The benchmark needs headroom above predastore's 5% watermark."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install warp
|
||||
working-directory: predastore
|
||||
run: make warp-install
|
||||
|
||||
# PERF_EXTERNAL_HOSTS skips profile selection, port offsetting, and
|
||||
# cluster start/stop, and measures these three gates directly. Same
|
||||
# workloads and analysis as the nightly loopback run, so the numbers
|
||||
# from the two are directly comparable.
|
||||
#
|
||||
# The cluster's credentials are read and used inside this one step, the
|
||||
# way e2e-ddil.yml does it: a step-level env: block is echoed verbatim
|
||||
# into the run log, and a shell variable is not.
|
||||
- name: Run 3-node throughput benchmark
|
||||
working-directory: predastore
|
||||
env:
|
||||
PERF_EXTERNAL_HOSTS: ${{ steps.perf_hosts.outputs.hosts }}
|
||||
PERF_CONFIGS: spinifex-3node
|
||||
PERF_PRESET: ${{ inputs.perf_preset || 'compare' }}
|
||||
# What is deployed, not what the harness was checked out at. With a
|
||||
# cluster_ref those differ, and run-info.txt records the cluster.
|
||||
PERF_EXTERNAL_SHA: ${{ inputs.cluster_ref || steps.predastore.outputs.commit }}
|
||||
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
|
||||
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
|
||||
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
|
||||
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# spx admin init mints a random pair at bootstrap and writes it to
|
||||
# node1's ~/.aws/credentials. The benchmark's built-in key is the one
|
||||
# the loopback configs trust, which this cluster has never heard of.
|
||||
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
|
||||
PERF_ACCESS_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
|
||||
PERF_SECRET_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
|
||||
if [ -z "$PERF_ACCESS_KEY" ] || [ -z "$PERF_SECRET_KEY" ]; then
|
||||
echo "::error::could not read spinifex AWS credentials from ${NODE1_IP}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::$PERF_SECRET_KEY"
|
||||
mkdir -p "$PERF_RESULTS_ROOT"
|
||||
PERF_ACCESS_KEY="$PERF_ACCESS_KEY" \
|
||||
PERF_SECRET_KEY="$PERF_SECRET_KEY" \
|
||||
PERF_REGION="$CLUSTER_REGION" \
|
||||
make e2e-performance 2>&1 | tee "$PERF_RESULTS_ROOT/e2e-performance.log"
|
||||
|
||||
# Report-only, matching cell-23: the baseline comparison and any
|
||||
# regression gate are future work once run-to-run spread is known from
|
||||
# real bare-metal runs.
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: predastore-perf-3node-${{ github.run_id }}
|
||||
path: ${{ env.PERF_RESULTS_ROOT }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 90
|
||||
|
||||
- name: Clean up results
|
||||
if: always()
|
||||
run: rm -rf "${PERF_RESULTS_ROOT}"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set +e
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
UID_SELF=$(id -u)
|
||||
WS="${GITHUB_WORKSPACE}"
|
||||
reap() {
|
||||
local sig="$1"
|
||||
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
|
||||
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
|
||||
case "$cwd" in
|
||||
"$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
reap TERM
|
||||
sleep 2
|
||||
reap KILL
|
||||
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
|
||||
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
|
||||
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
|
||||
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
|
||||
ok "teardown complete"
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: tofu-log-perf3node-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -60,7 +60,7 @@ concurrency:
|
||||
jobs:
|
||||
matrix:
|
||||
name: Resolve image matrix
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
runs-on: [self-hosted, ci-single]
|
||||
outputs:
|
||||
images: ${{ steps.gen.outputs.images }}
|
||||
steps:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
publish:
|
||||
name: Build + publish ${{ matrix.name }}
|
||||
needs: matrix
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
runs-on: [self-hosted, ci-single]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# ci-single is one runner and each GPU build needs ~16G of scratch disk;
|
||||
@@ -131,9 +131,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
# Self-hosted runner: the Go module + build cache already persists on
|
||||
# local disk between runs. setup-go's cache-save ("Post Setup Go")
|
||||
# otherwise re-uploads hundreds of MB to the Actions cache backend,
|
||||
# taking longer than the build itself.
|
||||
# local disk between runs.
|
||||
cache: false
|
||||
|
||||
- name: Setup Go Workspace
|
||||
@@ -160,7 +158,6 @@ jobs:
|
||||
- name: Build + publish ${{ matrix.name }} to R2
|
||||
working-directory: spinifex
|
||||
env:
|
||||
# Map the repo's R2 secrets onto the names publish-system-image.sh reads.
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
|
||||
|
||||
@@ -148,6 +148,13 @@ jobs:
|
||||
verify:
|
||||
needs: release
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
# Shared with the e2e workflows: this job provisions on the same runner
|
||||
# pools and the same hypervisors, so two in flight compete for their disks
|
||||
# and their libvirt guests. Job-level, so the build jobs are not queued
|
||||
# behind a nightly that has nothing to do with them.
|
||||
concurrency:
|
||||
group: e2e-hypervisors
|
||||
cancel-in-progress: false
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
|
||||
@@ -174,6 +181,13 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
# Only the shared shell helpers: this job builds nothing from source, but
|
||||
# it drives the same libvirt provider the e2e cells retry around.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
path: spinifex
|
||||
|
||||
- name: Download Release Assets
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -183,11 +197,12 @@ jobs:
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -200,8 +215,9 @@ jobs:
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
baremetal:
|
||||
|
||||
@@ -89,8 +89,11 @@ jobs:
|
||||
# Build artifacts follow source, so the key rolls per run and falls back to
|
||||
# this job's newest entry. Keyed per job because -race, -covermode=atomic
|
||||
# and plain builds produce disjoint entries and would evict each other.
|
||||
- name: Cache Go build
|
||||
uses: actions/cache@v6
|
||||
# Restore runs everywhere; the matching save is gated to dev and main, so
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -131,91 +134,26 @@ 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
|
||||
# Compiled output stays valid whatever the tests said, and skipping the
|
||||
# save on a red run would leave the next one rebuilding it, so this is
|
||||
# gated on the branch rather than on success.
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
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: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
# setup-go's own cache only writes on a key miss, so it froze a snapshot at
|
||||
# the last go.sum change and never refreshed it. Both caches are managed
|
||||
# below instead.
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
cache: false
|
||||
|
||||
# Module contents follow the five go.sum files, so a content key is exact.
|
||||
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
|
||||
# dependencies out of the key and re-downloading on every run.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
|
||||
restore-keys: gomod-
|
||||
|
||||
# Build artifacts follow source, so the key rolls per run and falls back to
|
||||
# this job's newest entry. Keyed per job because -race, -covermode=atomic
|
||||
# and plain builds produce disjoint entries and would evict each other.
|
||||
- name: Cache Go build
|
||||
uses: actions/cache@v6
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
race_detection:
|
||||
name: Race Detection
|
||||
@@ -284,8 +222,11 @@ jobs:
|
||||
# Build artifacts follow source, so the key rolls per run and falls back to
|
||||
# this job's newest entry. Keyed per job because -race, -covermode=atomic
|
||||
# and plain builds produce disjoint entries and would evict each other.
|
||||
- name: Cache Go build
|
||||
uses: actions/cache@v6
|
||||
# Restore runs everywhere; the matching save is gated to dev and main, so
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -297,8 +238,26 @@ jobs:
|
||||
- name: Run Tests with Race Detector
|
||||
run: make -C spinifex test-race GOTESTFLAGS=-count=1
|
||||
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
|
||||
lint_and_security:
|
||||
name: Lint and Security
|
||||
name: Lint + Security + Integration
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
@@ -306,6 +265,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
@@ -364,8 +324,11 @@ jobs:
|
||||
# Build artifacts follow source, so the key rolls per run and falls back to
|
||||
# this job's newest entry. Keyed per job because -race, -covermode=atomic
|
||||
# and plain builds produce disjoint entries and would evict each other.
|
||||
- name: Cache Go build
|
||||
uses: actions/cache@v6
|
||||
# Restore runs everywhere; the matching save is gated to dev and main, so
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -382,3 +345,25 @@ jobs:
|
||||
|
||||
- name: Govulncheck
|
||||
run: make -C spinifex govulncheck
|
||||
|
||||
- name: Run Integration Tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
|
||||
@@ -41,3 +41,4 @@ Thumbs.db
|
||||
|
||||
# Test reports
|
||||
*-report.txt
|
||||
/rds-agent
|
||||
|
||||
@@ -107,6 +107,7 @@ linters:
|
||||
- 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
|
||||
- G704 # SSRF taint analysis — false positives on resolver-derived endpoint URLs
|
||||
- G705 # XSS taint analysis — false positives on internal error responses
|
||||
|
||||
govet:
|
||||
|
||||
@@ -79,7 +79,7 @@ build-ecs-agent: ## Build the ecs-agent (ships in the ECS-AMI guest; not in host
|
||||
|
||||
build-loadgen: ## Build spx-loadgen (drives the stress harness's measured load; not shipped to nodes)
|
||||
@echo -e "\n....Building spx-loadgen"
|
||||
GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/spx-loadgen ./cmd/spx-loadgen
|
||||
go build -ldflags "-s -w" -o ./bin/spx-loadgen ./cmd/spx-loadgen
|
||||
|
||||
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-node)
|
||||
ifndef IMAGE
|
||||
@@ -103,6 +103,12 @@ import-eks-node-image: ## Build + register the eks-node AMI (requires a running
|
||||
publish-eks-node-image: ## Build + publish the eks-node AMI to Cloudflare R2 (needs R2_ENDPOINT + AWS_* env)
|
||||
./scripts/publish-system-image.sh scripts/images/eks-node/manifest.conf --build
|
||||
|
||||
# The Ubuntu/systemd EKS image, as opposed to build-eks-node-image's Alpine one.
|
||||
# Composition lives in mkosi-build.sh's image_profiles table, so the image name
|
||||
# is the whole interface here.
|
||||
build-eks-node-mkosi-image: ## Build the Ubuntu mkosi EKS node image (server+agent; role at first boot)
|
||||
./scripts/mkosi-build.sh --image spinifex-eks-node
|
||||
|
||||
build-ecs-node-image: ## Build the spinifex-ecs-node AMI (Alpine + containerd + ecs-agent; IMPORT=1 to register)
|
||||
$(MAKE) build-system-image IMAGE=ecs-agent
|
||||
|
||||
@@ -163,15 +169,19 @@ test-build-scripts:
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
# default `go test ./spinifex/...`. Runs with mocked AWS clients — no
|
||||
# infrastructure required, safe to run in CI without a cluster.
|
||||
#
|
||||
# diskperf/ is here for the same reason: its baseline comparison and probe
|
||||
# accounting decide whether the disk-performance gate passes, and that
|
||||
# arithmetic has to be covered off-cluster or the gate is trusted untested.
|
||||
test-harness:
|
||||
@echo -e "\n....Running e2e harness unit tests...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... ./tests/e2e/diskperf/... $(_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
|
||||
AWS_MODEL_OPERATION_COVERAGE_DIR ?= $(CURDIR)/docs/coverage
|
||||
# -count=1 is load-bearing: the conformance report is written by the test binary,
|
||||
# so a cached pass skips the run, leaves no report, and the cat below fails the
|
||||
# target. It also keeps the conformance gate honest — a cached result would mean
|
||||
@@ -181,14 +191,13 @@ test-integration:
|
||||
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -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)"
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@mkdir -p $(dir $(AWS_MODEL_OPERATION_COVERAGE_REPORT))
|
||||
@go run ./cmd/aws-model-coverage > $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR)
|
||||
|
||||
aws-model-coverage: generate-aws-model-coverage
|
||||
@cat $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
@go run ./cmd/aws-model-coverage
|
||||
|
||||
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
|
||||
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.27+-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://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>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -79,7 +80,7 @@ Spinifex speaks the AWS API surface. The AWS SDKs, AWS CLI, and Terraform all wo
|
||||
| **ECR** | Container Registry | Available |
|
||||
| **ECS** | Container Service | Available |
|
||||
| **RDS** | Databases | Available |
|
||||
| **Bedrock** | AI Deployment | Q3 2026 |
|
||||
| **Bedrock** | AI Deployment | Q4 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).
|
||||
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not open a public issue for a security vulnerability.
|
||||
|
||||
Report it privately, either way works:
|
||||
|
||||
- [Open a private security advisory](https://github.com/mulgadc/spinifex/security/advisories/new) on this repository.
|
||||
- Email <security@mulgadc.com>.
|
||||
|
||||
Please include enough for us to reproduce it: the version or commit, the configuration involved, and the steps that trigger it. If you have a proof of concept, include that too.
|
||||
|
||||
## What Happens Next
|
||||
|
||||
We aim to acknowledge a report within three business days. We will confirm whether we can reproduce it, agree a fix and a disclosure timeline with you, and credit you in the advisory unless you would rather we did not.
|
||||
|
||||
## Scope
|
||||
|
||||
Spinifex is one part of the [Mulga](https://mulgadc.com) stack. If a vulnerability spans more than one component, report it once against whichever repository you found it in and we will handle the rest.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Fixes land on the latest release and on `main`. Older releases are not patched.
|
||||
@@ -1,4 +1,4 @@
|
||||
# spinifex-unit-version: 1
|
||||
# spinifex-unit-version: 2
|
||||
[Unit]
|
||||
Description=Spinifex Web UI
|
||||
PartOf=spinifex.target
|
||||
@@ -24,6 +24,7 @@ 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/
|
||||
Environment=SPINIFEX_UI_OCHRE_ENABLED=false
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
outputDir := flag.String("out", "", "write one publishable page per service into this directory")
|
||||
flag.Parse()
|
||||
|
||||
coverages, err := compareAll()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if *outputDir == "" {
|
||||
fmt.Print(awsmodel.RenderCoverageSummary(coverages))
|
||||
return
|
||||
}
|
||||
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
dispatch := gateway.AWSOperationInventory()
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
@@ -21,23 +47,19 @@ func main() {
|
||||
Stubbed: inventory.Stubbed,
|
||||
Unsupported: inventory.Unsupported,
|
||||
}
|
||||
// Predastore serves the S3 REST surface, and names the operations each
|
||||
// of its routes answers, so the comparison is the same one.
|
||||
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.",
|
||||
}
|
||||
modelInventory = awsmodel.DispatchInventory{Registered: s3api.Operations()}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: no gateway inventory for %s\n", service)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("no gateway inventory for %s", service)
|
||||
}
|
||||
|
||||
coverage, err := awsmodel.CompareOperations(service, modelInventory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, err
|
||||
}
|
||||
coverages = append(coverages, coverage)
|
||||
}
|
||||
|
||||
fmt.Print(awsmodel.RenderCoverageMarkdown(coverages))
|
||||
return coverages, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// EC2 IMDS client and ec2rolecreds provider, cached with aws.CredentialsCache,
|
||||
// which refetches once the real expiry passes. A failed refresh is an error
|
||||
// here, not an extended expiry. Safe for concurrent use.
|
||||
type IMDSProvider struct {
|
||||
cache *aws.CredentialsCache
|
||||
}
|
||||
|
||||
@@ -88,6 +88,63 @@ func TestRetrieve_RefetchesWhenExpired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// imdsStubFailAfterFirst serves creds on the first credential request and 500s
|
||||
// on every one after it, so a test can drive a refresh that fails against a
|
||||
// credential the cache has already stored.
|
||||
func imdsStubFailAfterFirst(t *testing.T, creds map[string]string) *httptest.Server {
|
||||
t.Helper()
|
||||
var hits atomic.Int32
|
||||
mux := http.NewServeMux()
|
||||
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("v2-token"))
|
||||
})
|
||||
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
|
||||
}
|
||||
if hits.Add(1) > 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(creds)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// A refresh that fails must surface as an error. Left to itself the SDK's
|
||||
// ec2rolecreds implements HandleFailToRefresh, which hands back the same dead
|
||||
// key with Expires pushed 5-15 minutes out, so the agent would sign requests
|
||||
// with a credential the control plane has already rejected — indefinitely,
|
||||
// since every subsequent refresh fails the same way.
|
||||
func TestRetrieve_FailedRefreshDoesNotExtendExpiry(t *testing.T) {
|
||||
srv := imdsStubFailAfterFirst(t, map[string]string{
|
||||
"Code": "Success",
|
||||
"AccessKeyId": "AKIA",
|
||||
"SecretAccessKey": "secret",
|
||||
"Token": "session",
|
||||
// Already expired, so the next Retrieve must go back to IMDS.
|
||||
"Expiration": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
first, err := p.Retrieve(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("first Retrieve: %v", err)
|
||||
}
|
||||
|
||||
got, err := p.Retrieve(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("refresh failure was reported as success: %+v", got)
|
||||
}
|
||||
if got.Expiration.After(first.Expiration) {
|
||||
t.Errorf("expiry extended on refresh failure: %v -> %v", first.Expiration, got.Expiration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieve_CancelledContext(t *testing.T) {
|
||||
srv := imdsStub(t, nil, map[string]string{"Code": "Success", "AccessKeyId": "A", "SecretAccessKey": "B"})
|
||||
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -40,7 +40,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
// stagedAddon mirrors the gateway's StagedAddonManifest wire shape; duplicated
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -38,6 +38,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/tlsconfig"
|
||||
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
|
||||
)
|
||||
|
||||
// serverTLSConfig is the webhook's loopback serving TLS config.
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
// config is the webhook's runtime configuration, sourced from the first-boot
|
||||
|
||||
@@ -32,6 +32,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/ui"
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
//test:in-package — the wizard model, its screen constants and the key handlers
|
||||
//under test are all unexported, and the tests read model fields directly to
|
||||
//assert which screen a key sequence landed on.
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// namedKeys maps the key names the handlers switch on to their tea.KeyType.
|
||||
// Anything not listed is sent as runes.
|
||||
var namedKeys = map[string]tea.KeyType{
|
||||
"enter": tea.KeyEnter,
|
||||
"esc": tea.KeyEsc,
|
||||
"tab": tea.KeyTab,
|
||||
"shift+tab": tea.KeyShiftTab,
|
||||
"up": tea.KeyUp,
|
||||
"down": tea.KeyDown,
|
||||
"left": tea.KeyLeft,
|
||||
"right": tea.KeyRight,
|
||||
" ": tea.KeySpace,
|
||||
"backspace": tea.KeyBackspace,
|
||||
"ctrl+c": tea.KeyCtrlC,
|
||||
}
|
||||
|
||||
// keyMsg builds the tea.KeyMsg for a key name and checks it round-trips, so a
|
||||
// mis-built message fails here rather than silently exercising the wrong branch.
|
||||
func keyMsg(t *testing.T, key string) tea.KeyMsg {
|
||||
t.Helper()
|
||||
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||
if kt, ok := namedKeys[key]; ok {
|
||||
msg = tea.KeyMsg{Type: kt}
|
||||
}
|
||||
if got := msg.String(); got != key {
|
||||
t.Fatalf("built key %q, which the handlers see as %q", key, got)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// press feeds keys through Update, which is the only entry point the bubbletea
|
||||
// runtime uses, and returns the resulting model.
|
||||
func press(t *testing.T, m model, keys ...string) model {
|
||||
t.Helper()
|
||||
for _, k := range keys {
|
||||
next, _ := m.Update(keyMsg(t, k))
|
||||
typed, ok := next.(model)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, not a model", next)
|
||||
}
|
||||
m = typed
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// typeText sends a string one rune at a time, the way a terminal does.
|
||||
func typeText(t *testing.T, m model, s string) model {
|
||||
t.Helper()
|
||||
for _, r := range s {
|
||||
m = press(t, m, string(r))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func wizardModel(t *testing.T, diskCount, nicCount int) model {
|
||||
t.Helper()
|
||||
disks := make([]install.Disk, 0, diskCount)
|
||||
for i := range diskCount {
|
||||
disks = append(disks, install.Disk{
|
||||
Path: "/dev/sd" + string(rune('a'+i)),
|
||||
Bytes: 200 << 30,
|
||||
LogicalBlockSize: 512,
|
||||
})
|
||||
}
|
||||
m := newModel(disks, nics(nicCount))
|
||||
m.width, m.height = 100, 40
|
||||
return m
|
||||
}
|
||||
|
||||
// The wizard has to be completable from the welcome screen using only the keys
|
||||
// the help lines advertise, and produce the config the operator entered.
|
||||
func TestWizardWalkthroughProducesTheEnteredConfig(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
|
||||
m = press(t, m, "enter") // welcome → disk
|
||||
if m.screen != screenDisk {
|
||||
t.Fatalf("screen = %v, want the disk screen", m.screen)
|
||||
}
|
||||
|
||||
m = press(t, m, "enter") // preselected disk is valid → erase confirmation
|
||||
if m.screen != screenDiskConfirm {
|
||||
t.Fatalf("screen = %v, want the erase confirmation", m.screen)
|
||||
}
|
||||
|
||||
m = typeText(t, m, "yes")
|
||||
m = press(t, m, "enter") // → network roles
|
||||
if m.screen != screenNetworkRoles {
|
||||
t.Fatalf("screen = %v, want the network roles screen", m.screen)
|
||||
}
|
||||
|
||||
m = press(t, m, "down", "down", "down", "enter") // Continue → identity
|
||||
if m.screen != screenIdentity {
|
||||
t.Fatalf("screen = %v, want the identity screen", m.screen)
|
||||
}
|
||||
|
||||
m = typeText(t, m, "node1")
|
||||
m = press(t, m, "enter") // → credentials
|
||||
if m.screen != screenPassword {
|
||||
t.Fatalf("screen = %v, want the credentials screen", m.screen)
|
||||
}
|
||||
|
||||
m = typeText(t, m, "admin@example.com")
|
||||
m = press(t, m, "enter") // email accepted → password
|
||||
m = typeText(t, m, "hunter2hunter2")
|
||||
m = press(t, m, "enter") // → confirm password
|
||||
m = typeText(t, m, "hunter2hunter2")
|
||||
m = press(t, m, "enter") // → final confirmation
|
||||
if m.screen != screenConfirm {
|
||||
t.Fatalf("screen = %v, want the final confirmation (err: %q)", m.screen, m.validationErr)
|
||||
}
|
||||
|
||||
m = press(t, m, "y")
|
||||
if m.screen != screenDone {
|
||||
t.Fatalf("screen = %v, want done", m.screen)
|
||||
}
|
||||
if m.result == nil {
|
||||
t.Fatal("result is nil after confirming")
|
||||
}
|
||||
|
||||
cfg := m.result
|
||||
if cfg.Hostname != "node1" {
|
||||
t.Errorf("Hostname = %q, want node1", cfg.Hostname)
|
||||
}
|
||||
if cfg.Email != "admin@example.com" {
|
||||
t.Errorf("Email = %q, want admin@example.com", cfg.Email)
|
||||
}
|
||||
if cfg.RootPassword != "hunter2hunter2" {
|
||||
t.Errorf("RootPassword = %q, want what was typed", cfg.RootPassword)
|
||||
}
|
||||
if !slices.Equal(cfg.Storage.Paths(), []string{"/dev/sda"}) {
|
||||
t.Errorf("Storage.Paths() = %v, want the preselected disk", cfg.Storage.Paths())
|
||||
}
|
||||
if cfg.WAN.Interface != "eno1" {
|
||||
t.Errorf("WAN interface = %q, want the first NIC", cfg.WAN.Interface)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("the wizard produced a config it would refuse to install: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc has to walk back the way it came, or an operator who overshoots is stuck
|
||||
// with a wizard they can only cancel out of.
|
||||
func TestEscapeWalksBackThroughTheScreens(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m = press(t, m, "enter") // disk
|
||||
m = press(t, m, "enter") // erase confirmation
|
||||
|
||||
for _, step := range []struct {
|
||||
key string
|
||||
want screen
|
||||
}{
|
||||
{"esc", screenDisk},
|
||||
{"esc", screenWelcome},
|
||||
} {
|
||||
m = press(t, m, step.key)
|
||||
if m.screen != step.want {
|
||||
t.Fatalf("after esc: screen = %v, want %v", m.screen, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+C is handled before any screen sees the key, so it works everywhere.
|
||||
func TestCtrlCCancelsWithAnError(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
next, cmd := m.Update(keyMsg(t, "ctrl+c"))
|
||||
final, ok := next.(model)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T", next)
|
||||
}
|
||||
if final.err == nil || !strings.Contains(final.err.Error(), "cancelled") {
|
||||
t.Fatalf("err = %v, want a cancellation", final.err)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("ctrl+c must return tea.Quit")
|
||||
}
|
||||
}
|
||||
|
||||
// A window resize has to reach the model, since every view lays out against it.
|
||||
func TestWindowResizeIsStored(t *testing.T) {
|
||||
m := model{}
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 50})
|
||||
resized, ok := next.(model)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T", next)
|
||||
}
|
||||
if resized.width != 120 || resized.height != 50 {
|
||||
t.Errorf("size = %dx%d, want 120x50", resized.width, resized.height)
|
||||
}
|
||||
}
|
||||
|
||||
// The erase prompt is the last point of no return, so anything other than an
|
||||
// explicit "yes" has to keep the operator on it.
|
||||
func TestEraseConfirmationRequiresYes(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m = press(t, m, "enter", "enter") // → erase confirmation
|
||||
|
||||
m = typeText(t, m, "no")
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenDiskConfirm {
|
||||
t.Fatalf("screen = %v, want to stay on the confirmation", m.screen)
|
||||
}
|
||||
if !strings.Contains(m.validationErr, "yes") {
|
||||
t.Errorf("validationErr = %q, want it to say what to type", m.validationErr)
|
||||
}
|
||||
if !strings.Contains(m.viewDiskConfirm(100), m.validationErr) {
|
||||
t.Error("the confirmation screen must show the error it set")
|
||||
}
|
||||
}
|
||||
|
||||
// The filesystem selector and the disk list share one screen, so the cursor has
|
||||
// to move between them and stop at both ends.
|
||||
func TestDiskScreenCursorStaysInRange(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenDisk
|
||||
|
||||
m = press(t, m, "up", "up")
|
||||
if m.storageCursor != fsRow {
|
||||
t.Errorf("storageCursor = %d, want it clamped to the filesystem row", m.storageCursor)
|
||||
}
|
||||
|
||||
m = press(t, m, "down", "down", "down", "down")
|
||||
if m.storageCursor != len(m.disks) {
|
||||
t.Errorf("storageCursor = %d, want it clamped to the last disk", m.storageCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// Left/right only cycle the filesystem while the cursor is on that row, and
|
||||
// they wrap, so one key reaches every topology.
|
||||
func TestFilesystemCyclesOnlyOnItsOwnRow(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenDisk
|
||||
|
||||
m = press(t, m, "left")
|
||||
if got := m.fsType(); got != install.AllFSTypes[len(install.AllFSTypes)-1] {
|
||||
t.Errorf("fsType = %v, want the cycle to wrap backwards to the last", got)
|
||||
}
|
||||
m = press(t, m, "right")
|
||||
if got := m.fsType(); got != install.AllFSTypes[0] {
|
||||
t.Errorf("fsType = %v, want the cycle to wrap forwards to the first", got)
|
||||
}
|
||||
|
||||
m = press(t, m, "down") // onto the first disk
|
||||
before := m.fsType()
|
||||
m = press(t, m, "left", "right")
|
||||
if m.fsType() != before {
|
||||
t.Errorf("fsType changed to %v while the cursor was on a disk", m.fsType())
|
||||
}
|
||||
}
|
||||
|
||||
// Space toggles the disk under the cursor, and r cycles its role — the two keys
|
||||
// the storage screen exists for.
|
||||
func TestDiskScreenSelectionKeys(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenDisk
|
||||
|
||||
// The second disk is unselected; the first arrives preselected as os.
|
||||
m = press(t, m, "down", "down", " ")
|
||||
if !slices.Contains(m.selected, 1) {
|
||||
t.Fatalf("selected = %v, want the second disk added", m.selected)
|
||||
}
|
||||
|
||||
roleBefore := m.roleOf[slices.Index(m.selected, 1)]
|
||||
m = press(t, m, "r")
|
||||
if got := m.roleOf[slices.Index(m.selected, 1)]; got == roleBefore {
|
||||
t.Errorf("role stayed %q after r", got)
|
||||
}
|
||||
|
||||
m = press(t, m, " ")
|
||||
if slices.Contains(m.selected, 1) {
|
||||
t.Fatalf("selected = %v, want the second disk removed again", m.selected)
|
||||
}
|
||||
if len(m.roleOf) != len(m.selected) {
|
||||
t.Errorf("roleOf (%d) and selected (%d) drifted", len(m.roleOf), len(m.selected))
|
||||
}
|
||||
}
|
||||
|
||||
// A selection that cannot be installed has to be reported on the screen the
|
||||
// operator can still fix it on, not part-way through the install.
|
||||
func TestDiskScreenRefusesAnInvalidSelection(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenDisk
|
||||
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1) // needs 2 disks
|
||||
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenDisk {
|
||||
t.Fatalf("screen = %v, want to stay on the disk screen", m.screen)
|
||||
}
|
||||
if m.validationErr == "" {
|
||||
t.Fatal("an invalid selection must set a validation error")
|
||||
}
|
||||
if !strings.Contains(m.viewDisk(100), m.validationErr) {
|
||||
t.Error("the disk screen must render the error it set")
|
||||
}
|
||||
|
||||
// Selecting the second disk satisfies the mirror and clears the way.
|
||||
m = press(t, m, "down", "down", " ", "enter")
|
||||
if m.screen != screenDiskConfirm {
|
||||
t.Fatalf("screen = %v, want to advance once the mirror has two disks", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
// The ZFS options screen is only reachable on a pool, since its settings have
|
||||
// no meaning on ext4.
|
||||
func TestZFSOptionsScreenIsZFSOnly(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenDisk
|
||||
|
||||
m = press(t, m, "a")
|
||||
if m.screen != screenDisk {
|
||||
t.Fatalf("screen = %v, want a to do nothing on ext4", m.screen)
|
||||
}
|
||||
|
||||
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
|
||||
m = press(t, m, "a")
|
||||
if m.screen != screenZFSOptions {
|
||||
t.Fatalf("screen = %v, want the ZFS options screen", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
// Every option defaults to auto and cycles through its own choice list, with
|
||||
// the cursor clamped to the rows that exist.
|
||||
func TestZFSOptionsCycleTheirValues(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenZFSOptions
|
||||
|
||||
if got := zfsOptions[0].value(&m.zfs); got != "auto" {
|
||||
t.Errorf("ashift starts at %q, want auto", got)
|
||||
}
|
||||
m = press(t, m, "right")
|
||||
if got := zfsOptions[0].value(&m.zfs); got == "auto" {
|
||||
t.Error("right must move ashift off auto")
|
||||
}
|
||||
m = press(t, m, "left")
|
||||
if got := zfsOptions[0].value(&m.zfs); got != "auto" {
|
||||
t.Errorf("ashift = %q after cycling back, want auto", got)
|
||||
}
|
||||
|
||||
m = press(t, m, "up", "up")
|
||||
if m.zfsCursor != 0 {
|
||||
t.Errorf("zfsCursor = %d, want it clamped to the first row", m.zfsCursor)
|
||||
}
|
||||
for range len(zfsOptions) + 2 {
|
||||
m = press(t, m, "down")
|
||||
}
|
||||
if m.zfsCursor != len(zfsOptions)-1 {
|
||||
t.Errorf("zfsCursor = %d, want it clamped to the last row", m.zfsCursor)
|
||||
}
|
||||
|
||||
// The compression choices are strings rather than ints, so they exercise
|
||||
// the other half of cycle.
|
||||
m.zfsCursor = 1
|
||||
m = press(t, m, "right")
|
||||
if m.zfs.Compress == "" {
|
||||
t.Error("right must set a compression algorithm")
|
||||
}
|
||||
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenDisk {
|
||||
t.Fatalf("screen = %v, want enter to return to the disk screen", m.screen)
|
||||
}
|
||||
}
|
||||
|
||||
// The screen has to show what each option is currently set to, since that is
|
||||
// the only place the pool's geometry is stated before it is created.
|
||||
func TestZFSOptionsViewShowsValuesAndTheFocusedNote(t *testing.T) {
|
||||
m := wizardModel(t, 2, 1)
|
||||
m.screen = screenZFSOptions
|
||||
m.zfs.Compress = "zstd"
|
||||
|
||||
got := m.viewZFSOptions(100)
|
||||
for _, want := range []string{"ashift", "auto", "compression", "zstd", zfsOptions[0].note} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("ZFS options screen is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, zfsOptions[1].note) {
|
||||
t.Error("only the focused row's note should be shown")
|
||||
}
|
||||
}
|
||||
|
||||
// Credentials are three fields on one screen, so focus has to cycle both ways
|
||||
// and land somewhere sensible after a validation failure.
|
||||
func TestCredentialsFocusCyclesBothWays(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m.screen = screenPassword
|
||||
m = m.setCredsFocus(0)
|
||||
|
||||
m = press(t, m, "tab")
|
||||
if m.credsFocus != 1 || !m.passwordInput.Focused() {
|
||||
t.Fatalf("credsFocus = %d, want the password field focused", m.credsFocus)
|
||||
}
|
||||
m = press(t, m, "tab", "tab")
|
||||
if m.credsFocus != 0 || !m.emailInput.Focused() {
|
||||
t.Fatalf("credsFocus = %d, want the cycle to wrap to email", m.credsFocus)
|
||||
}
|
||||
m = press(t, m, "shift+tab")
|
||||
if m.credsFocus != 2 || !m.passwordConfirmInput.Focused() {
|
||||
t.Fatalf("credsFocus = %d, want shift+tab to wrap backwards to confirm", m.credsFocus)
|
||||
}
|
||||
}
|
||||
|
||||
// Each credential rule has to stop the wizard on the screen and put the cursor
|
||||
// on the field at fault, not just refuse to advance.
|
||||
func TestCredentialValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
confirm string
|
||||
wantErr string
|
||||
wantFocus int
|
||||
wantScreen screen
|
||||
}{
|
||||
{
|
||||
name: "rejects a malformed email", email: "not-an-email",
|
||||
password: "hunter2hunter2", confirm: "hunter2hunter2",
|
||||
wantErr: "email", wantFocus: 0, wantScreen: screenPassword,
|
||||
},
|
||||
{
|
||||
name: "rejects an empty password", email: "admin@example.com",
|
||||
wantErr: "Password is required", wantFocus: 1, wantScreen: screenPassword,
|
||||
},
|
||||
{
|
||||
name: "rejects a mismatch", email: "admin@example.com",
|
||||
password: "hunter2hunter2", confirm: "hunter2hunter3",
|
||||
wantErr: "do not match", wantFocus: 2, wantScreen: screenPassword,
|
||||
},
|
||||
{
|
||||
name: "accepts a matching pair", email: "admin@example.com",
|
||||
password: "hunter2hunter2", confirm: "hunter2hunter2",
|
||||
wantScreen: screenConfirm,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m.screen = screenPassword
|
||||
m.emailInput.SetValue(tt.email)
|
||||
m.passwordInput.SetValue(tt.password)
|
||||
m.passwordConfirmInput.SetValue(tt.confirm)
|
||||
m = m.setCredsFocus(2) // enter on confirm runs every rule
|
||||
|
||||
m = press(t, m, "enter")
|
||||
|
||||
if m.screen != tt.wantScreen {
|
||||
t.Fatalf("screen = %v, want %v (err %q)", m.screen, tt.wantScreen, m.validationErr)
|
||||
}
|
||||
if tt.wantErr == "" {
|
||||
if m.validationErr != "" {
|
||||
t.Fatalf("validationErr = %q, want none", m.validationErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(m.validationErr), strings.ToLower(tt.wantErr)) {
|
||||
t.Errorf("validationErr = %q, want it to mention %q", m.validationErr, tt.wantErr)
|
||||
}
|
||||
if m.credsFocus != tt.wantFocus {
|
||||
t.Errorf("credsFocus = %d, want the cursor on field %d", m.credsFocus, tt.wantFocus)
|
||||
}
|
||||
if !strings.Contains(m.viewPassword(100), m.validationErr) {
|
||||
t.Error("the credentials screen must render the error it set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The hostname ends up in the config and in DNS, so an empty one has to be
|
||||
// caught on the screen rather than installed.
|
||||
func TestHostnameIsRequired(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m.screen = screenIdentity
|
||||
m.hostnameInput.Focus()
|
||||
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenIdentity {
|
||||
t.Fatalf("screen = %v, want to stay on identity", m.screen)
|
||||
}
|
||||
if !strings.Contains(m.validationErr, "Hostname") {
|
||||
t.Errorf("validationErr = %q, want it to name the field", m.validationErr)
|
||||
}
|
||||
if !m.hostnameInput.Focused() {
|
||||
t.Error("the hostname field must keep focus after a failed submit")
|
||||
}
|
||||
if !strings.Contains(m.viewIdentity(100), m.validationErr) {
|
||||
t.Error("the identity screen must render the error it set")
|
||||
}
|
||||
}
|
||||
|
||||
// Tab order follows the visible fields, which change with the addressing mode,
|
||||
// so it has to be derived from them rather than fixed.
|
||||
func TestRoleEditorTabsThroughVisibleFieldsOnly(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRole
|
||||
m.roleCursor = 0 // wan, DHCP by default
|
||||
|
||||
m = press(t, m, "tab")
|
||||
if m.roles[0].focus != roleFieldMethod {
|
||||
t.Fatalf("focus = %v, want the IP method field", m.roles[0].focus)
|
||||
}
|
||||
m = press(t, m, "tab")
|
||||
if m.roles[0].focus != roleFieldNIC {
|
||||
t.Fatalf("focus = %v, want the DHCP form to wrap after two fields", m.roles[0].focus)
|
||||
}
|
||||
|
||||
// Switching to static reveals the address fields, and tab must reach them.
|
||||
m = press(t, m, "tab", "right")
|
||||
if m.roles[0].dhcp {
|
||||
t.Fatal("right on the method field must select static addressing")
|
||||
}
|
||||
m = press(t, m, "tab")
|
||||
if m.roles[0].focus != roleFieldIP || !m.roles[0].ip.Focused() {
|
||||
t.Fatalf("focus = %v, want the IP field focused", m.roles[0].focus)
|
||||
}
|
||||
m = press(t, m, "shift+tab")
|
||||
if m.roles[0].focus != roleFieldMethod {
|
||||
t.Fatalf("focus = %v, want shift+tab to go back", m.roles[0].focus)
|
||||
}
|
||||
}
|
||||
|
||||
// A focused text field has to receive typed runes, since that is the only way
|
||||
// an address is entered.
|
||||
func TestRoleEditorTypesIntoTheFocusedField(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRole
|
||||
m.roles[0].dhcp = false
|
||||
m.roles[0].focus = roleFieldIP
|
||||
m.roles[0].focusCurrent()
|
||||
|
||||
m = typeText(t, m, "192.168.1.10")
|
||||
if got := m.roles[0].ip.Value(); got != "192.168.1.10" {
|
||||
t.Fatalf("ip = %q, want what was typed", got)
|
||||
}
|
||||
if got := m.roles[0].mask.Value(); got != "" {
|
||||
t.Errorf("mask = %q, want the keys to reach only the focused field", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The interface selector wraps and offers the fold option on lan and vpc only,
|
||||
// so a single-NIC node stays configurable with one key.
|
||||
func TestNICSelectorWrapsAndFoldsWhereAllowed(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRole
|
||||
|
||||
// wan cannot fold, so it wraps across the real NICs only.
|
||||
m.roleCursor = 0
|
||||
m = press(t, m, "left")
|
||||
if m.roles[0].nic != len(m.nics)-1 {
|
||||
t.Fatalf("wan nic = %d, want it to wrap to the last NIC", m.roles[0].nic)
|
||||
}
|
||||
m = press(t, m, "right")
|
||||
if m.roles[0].nic != 0 {
|
||||
t.Fatalf("wan nic = %d, want it to wrap back to the first", m.roles[0].nic)
|
||||
}
|
||||
|
||||
// lan may fold, so the cycle includes the unbound position.
|
||||
m.roleCursor = 1
|
||||
m.roles[1].nic = 0
|
||||
m = press(t, m, "left")
|
||||
if m.roles[1].nic != foldedNIC {
|
||||
t.Fatalf("lan nic = %d, want the fold position", m.roles[1].nic)
|
||||
}
|
||||
if !m.roles[1].folded() {
|
||||
t.Error("the lan role should read as folded")
|
||||
}
|
||||
}
|
||||
|
||||
// Per-role validation runs on Enter and keeps the operator on the editor, since
|
||||
// an invalid address here fails the install after the disk is partitioned.
|
||||
func TestRoleEditorValidatesBeforeLeaving(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRole
|
||||
m.roleCursor = 0
|
||||
m.roles[0].dhcp = false
|
||||
m.roles[0].ip.SetValue("not-an-ip")
|
||||
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenNetworkRole {
|
||||
t.Fatalf("screen = %v, want to stay on the role editor", m.screen)
|
||||
}
|
||||
if !strings.Contains(m.validationErr, "IP address") {
|
||||
t.Errorf("validationErr = %q, want it to name the field", m.validationErr)
|
||||
}
|
||||
if !strings.Contains(m.viewNetworkRole(100), m.validationErr) {
|
||||
t.Error("the role editor must render the error it set")
|
||||
}
|
||||
|
||||
// A prefix length is rejected here because netgen parses the mask with
|
||||
// net.ParseIP and would fail the install itself.
|
||||
m.roles[0].ip.SetValue("192.168.1.10")
|
||||
m.roles[0].mask.SetValue("24")
|
||||
m = press(t, m, "enter")
|
||||
if !strings.Contains(m.validationErr, "dotted-decimal") {
|
||||
t.Errorf("validationErr = %q, want the mask rule", m.validationErr)
|
||||
}
|
||||
|
||||
m.roles[0].mask.SetValue("255.255.255.0")
|
||||
m.roles[0].gateway.SetValue("192.168.1.1")
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenNetworkRoles {
|
||||
t.Fatalf("screen = %v, want a valid role to return to the overview (err %q)", m.screen, m.validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc abandons the editor without validating, so a half-typed address is not a
|
||||
// trap the operator has to finish before backing out.
|
||||
func TestRoleEditorEscapeSkipsValidation(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRole
|
||||
m.roles[0].dhcp = false
|
||||
m.roles[0].ip.SetValue("192.")
|
||||
|
||||
m = press(t, m, "esc")
|
||||
if m.screen != screenNetworkRoles {
|
||||
t.Fatalf("screen = %v, want the overview", m.screen)
|
||||
}
|
||||
if m.validationErr != "" {
|
||||
t.Errorf("validationErr = %q, want esc to clear it", m.validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The editor shows only the fields that apply, so the DHCP form stays two lines
|
||||
// and Advanced is what reveals VLAN and MTU.
|
||||
func TestRoleEditorViewShowsTheApplicableFields(t *testing.T) {
|
||||
m := wizardModel(t, 1, 3)
|
||||
m.screen = screenNetworkRole
|
||||
m.roleCursor = 0
|
||||
|
||||
dhcpView := m.viewNetworkRole(100)
|
||||
if !strings.Contains(dhcpView, "DHCP") || !strings.Contains(dhcpView, "WAN") {
|
||||
t.Errorf("DHCP view is missing the plane or the method:\n%s", dhcpView)
|
||||
}
|
||||
for _, hidden := range []string{"IP address", "Subnet mask", "VLAN id"} {
|
||||
if strings.Contains(dhcpView, hidden) {
|
||||
t.Errorf("DHCP view should not show %q:\n%s", hidden, dhcpView)
|
||||
}
|
||||
}
|
||||
|
||||
m.roles[0].dhcp = false
|
||||
m.advanced = true
|
||||
staticView := m.viewNetworkRole(100)
|
||||
for _, want := range []string{"IP address", "Subnet mask", "Gateway", "DNS", "VLAN id", "MTU"} {
|
||||
if !strings.Contains(staticView, want) {
|
||||
t.Errorf("static advanced view is missing %q:\n%s", want, staticView)
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway and DNS belong to the plane holding the default route, so a
|
||||
// static lan must not offer them.
|
||||
m.roleCursor = 1
|
||||
m.roles[1].nic = 1
|
||||
m.roles[1].dhcp = false
|
||||
lanView := m.viewNetworkRole(100)
|
||||
for _, hidden := range []string{"Gateway", "DNS"} {
|
||||
if strings.Contains(lanView, hidden) {
|
||||
t.Errorf("static lan view should not show %q:\n%s", hidden, lanView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A folded role names the plane it landed on, on both screens, so the operator
|
||||
// can see where the traffic actually goes before committing.
|
||||
func TestFoldedRolesAreNamedNotBlank(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1) // one NIC folds lan and vpc onto wan
|
||||
|
||||
overview := m.viewNetworkRoles(100)
|
||||
if !strings.Contains(overview, "folds onto") {
|
||||
t.Errorf("overview does not state where the folded roles land:\n%s", overview)
|
||||
}
|
||||
|
||||
m.screen = screenNetworkRole
|
||||
m.roleCursor = 2
|
||||
if got := m.viewNetworkRole(100); !strings.Contains(got, "fold onto") {
|
||||
t.Errorf("role editor does not offer the fold plainly:\n%s", got)
|
||||
}
|
||||
|
||||
m.screen = screenConfirm
|
||||
if got := m.viewConfirm(100); !strings.Contains(got, "folded onto") {
|
||||
t.Errorf("confirmation does not state the folded planes:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue is refused while the config would fail, and the message is the one
|
||||
// install.Config.Validate produced.
|
||||
func TestRolesOverviewRefusesAnInvalidConfig(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = screenNetworkRoles
|
||||
m.roles[1].nic = 0 // lan on the same NIC as wan, no VLAN to separate them
|
||||
m.roleCursor = continueRow
|
||||
|
||||
m = press(t, m, "enter")
|
||||
if m.screen != screenNetworkRoles {
|
||||
t.Fatalf("screen = %v, want to stay on the overview", m.screen)
|
||||
}
|
||||
if !strings.Contains(m.validationErr, "VLAN") {
|
||||
t.Errorf("validationErr = %q, want the shared-NIC rule", m.validationErr)
|
||||
}
|
||||
// The message is wrapped to the box, so match its opening rather than
|
||||
// the whole line.
|
||||
if !strings.Contains(m.viewNetworkRoles(100), "both bind eno1") {
|
||||
t.Error("the overview must render the error it set")
|
||||
}
|
||||
|
||||
// a toggles Advanced for the whole node rather than per role.
|
||||
m = press(t, m, "a")
|
||||
if !m.advanced {
|
||||
t.Error("a must turn Advanced on")
|
||||
}
|
||||
if !strings.Contains(m.viewNetworkRoles(100), "Advanced: on") {
|
||||
t.Error("the overview must show Advanced is on")
|
||||
}
|
||||
}
|
||||
|
||||
// The final screen is the last chance to catch a wrong disk or address, so the
|
||||
// summary has to carry what was entered.
|
||||
func TestConfirmScreenSummarisesTheEnteredConfig(t *testing.T) {
|
||||
m := wizardModel(t, 1, 3)
|
||||
m.screen = screenConfirm
|
||||
m.hostnameInput.SetValue("node7")
|
||||
m.roles[1].dhcp = false
|
||||
m.roles[1].ip.SetValue("10.0.0.3")
|
||||
m.roles[1].mask.SetValue("255.255.255.0")
|
||||
|
||||
got := m.viewConfirm(100)
|
||||
for _, want := range []string{"node7", "/dev/sda", "ext4", "eno1", "10.0.0.3/255.255.255.0", "br-lan", "will erase"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("confirmation is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// n on the confirmation cancels rather than installing.
|
||||
func TestConfirmScreenCancelsOnN(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m.screen = screenConfirm
|
||||
|
||||
m = press(t, m, "n")
|
||||
if m.err == nil {
|
||||
t.Fatal("n must set the cancellation error")
|
||||
}
|
||||
if m.result != nil {
|
||||
t.Error("a cancelled install must produce no config")
|
||||
}
|
||||
}
|
||||
|
||||
// View dispatches on the screen, so each one has to render its own content
|
||||
// rather than falling through to an empty frame.
|
||||
func TestViewRendersEveryScreen(t *testing.T) {
|
||||
tests := []struct {
|
||||
screen screen
|
||||
want string
|
||||
}{
|
||||
{screenWelcome, "Enter to begin"},
|
||||
{screenDisk, "Select Installation Disk"},
|
||||
{screenZFSOptions, "ZFS Options"},
|
||||
{screenDiskConfirm, "Confirm Disk Erasure"},
|
||||
{screenNetworkRoles, "Network Planes"},
|
||||
{screenNetworkRole, "Configure WAN plane"},
|
||||
{screenIdentity, "Node Identity"},
|
||||
{screenPassword, "Administrator Credentials"},
|
||||
{screenConfirm, "Confirm Installation"},
|
||||
{screenDone, "Installation complete"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.screen = tt.screen
|
||||
if got := m.View(); !strings.Contains(got, tt.want) {
|
||||
t.Errorf("screen %v does not render %q:\n%s", tt.screen, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The disk screen has to state what the selection actually builds, since the
|
||||
// cost of the redundancy choice is otherwise invisible until after the install.
|
||||
func TestDiskViewStatesTheResultingGeometry(t *testing.T) {
|
||||
m := wizardModel(t, 3, 1)
|
||||
m.screen = screenDisk
|
||||
|
||||
// ext4 with a role per drive: the mountpoints are the summary that matters.
|
||||
m = press(t, m, "down", "down", " ", "down", " ")
|
||||
if got := m.viewDisk(100); !strings.Contains(got, "/var/lib/spinifex") {
|
||||
t.Errorf("ext4 view does not state the role layout:\n%s", got)
|
||||
}
|
||||
// Matched spares are suggested, never applied.
|
||||
if got := m.viewDisk(100); !strings.Contains(got, "survive a disk failure") {
|
||||
t.Errorf("view does not suggest the pool the matched disks allow:\n%s", got)
|
||||
}
|
||||
|
||||
// A mirror with too few disks quotes the requirement, not a capacity.
|
||||
m = wizardModel(t, 3, 1)
|
||||
m.screen = screenDisk
|
||||
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
|
||||
if got := m.viewDisk(100); !strings.Contains(got, "1 selected") {
|
||||
t.Errorf("view does not state what the mirror is missing:\n%s", got)
|
||||
}
|
||||
|
||||
// Once buildable it quotes usable capacity and the failures tolerated.
|
||||
m = press(t, m, "down", "down", " ")
|
||||
got := m.viewDisk(100)
|
||||
if !strings.Contains(got, "usable") || !strings.Contains(got, "survives 1 disk failure") {
|
||||
t.Errorf("view does not quote the pool geometry:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "A for ZFS options") {
|
||||
t.Errorf("the ZFS help line should replace the role key:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The overview names the card behind each port, which is what distinguishes the
|
||||
// 25gbe uplink from the onboard 1gbe.
|
||||
func TestRolesOverviewNamesTheHardware(t *testing.T) {
|
||||
m := wizardModel(t, 1, 2)
|
||||
m.nics[0].AltName = "enp1s0f0"
|
||||
m.advanced = true
|
||||
|
||||
got := m.viewNetworkRoles(100)
|
||||
for _, want := range []string{"eno1", "25Gbps", "Mellanox", "enp1s0f0", "VLAN", "MTU"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("overview is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{512, "512B"},
|
||||
{2 << 20, "2.0M"},
|
||||
{3 << 30, "3.0G"},
|
||||
{2 << 40, "2.0T"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := humanBytes(tt.bytes); got != tt.want {
|
||||
t.Errorf("humanBytes(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The overview writes an address the way an operator does, and falls back to
|
||||
// the raw mask rather than dropping it when it is not dotted-decimal.
|
||||
func TestFormatCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr, mask string
|
||||
want string
|
||||
}{
|
||||
{"dotted mask becomes a prefix", "10.0.0.3", "255.255.255.0", "10.0.0.3/24"},
|
||||
{"short mask keeps its prefix", "10.0.0.3", "255.255.0.0", "10.0.0.3/16"},
|
||||
{"no mask yet", "10.0.0.3", "", "10.0.0.3"},
|
||||
{"no address is blank", "", "255.255.255.0", ""},
|
||||
{"unparseable mask is shown as typed", "10.0.0.3", "24", "10.0.0.3/24"},
|
||||
{"whitespace is trimmed", " 10.0.0.3 ", " 255.255.255.0 ", "10.0.0.3/24"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := formatCIDR(tt.addr, tt.mask); got != tt.want {
|
||||
t.Errorf("formatCIDR(%q, %q) = %q, want %q", tt.addr, tt.mask, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Messages that are not keys reach the focused input, which is what keeps the
|
||||
// cursor blinking on the two single-field screens.
|
||||
func TestNonKeyMessagesReachTheActiveInput(t *testing.T) {
|
||||
m := wizardModel(t, 1, 1)
|
||||
m.screen = screenIdentity
|
||||
m.hostnameInput.Focus()
|
||||
|
||||
next, _ := m.Update(struct{ tea.Msg }{})
|
||||
if _, ok := next.(model); !ok {
|
||||
t.Fatalf("Update returned %T", next)
|
||||
}
|
||||
|
||||
m.screen = screenWelcome
|
||||
if _, cmd := m.Update(struct{ tea.Msg }{}); cmd != nil {
|
||||
t.Error("a screen with no input must issue no command")
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/otelsetup"
|
||||
"github.com/mulgadc/spinifex/spinifex/lbagent"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -50,7 +50,13 @@ func newCommandRegistry(engine engineOps, storage storageOps) commandRegistry {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold)
|
||||
if err := engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// After the hold, so what is flushed is the engine's checkpointed
|
||||
// state. Its own files are not the risk — those it repairs from the
|
||||
// WAL — but the parameter file beside them it never opens.
|
||||
return "", storage.SyncDataMount(ctx)
|
||||
},
|
||||
handlers_rds.CommandUnquiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
return "", engine.Unquiesce(ctx)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -107,6 +108,10 @@ var engineLayouts = map[string]engineLayout{
|
||||
service: "postgresql",
|
||||
dataMount: "/var/lib/postgresql",
|
||||
port: 5432,
|
||||
// logfile in the image's /etc/conf.d/postgresql. The postmaster states
|
||||
// which setting it refused there and nowhere else — the console shows only
|
||||
// that pg_ctl could not start it, on a guest with no SSH.
|
||||
errorLog: "/var/log/postgresql/postmaster.log",
|
||||
newProbe: newPostgresProbe,
|
||||
newEngine: newPostgresEngineFromCatalog,
|
||||
},
|
||||
@@ -131,6 +136,61 @@ var engineLayouts = map[string]engineLayout{
|
||||
},
|
||||
}
|
||||
|
||||
// How much of the engine's error log a probe reason carries. The server names
|
||||
// what it refused on in its last few lines, and the reason reaches a customer
|
||||
// through StatusInfos, so this quotes the tail rather than the whole file.
|
||||
const (
|
||||
engineErrorLogTailBytes = 4096
|
||||
engineErrorLogTailLines = 4
|
||||
)
|
||||
|
||||
// Appends the last few lines of the engine's error log to reason. An unset path,
|
||||
// an unreadable file or an empty one leaves reason alone: the probe's own
|
||||
// statement is still true, and a guessed cause would be worse than none.
|
||||
func withEngineLogTail(reason, path string) string {
|
||||
tail := engineLogTail(path)
|
||||
if tail == "" {
|
||||
return reason
|
||||
}
|
||||
return reason + "; the engine's log ends: " + tail
|
||||
}
|
||||
|
||||
func engineLogTail(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
offset := max(info.Size()-engineErrorLogTailBytes, 0)
|
||||
buf := make([]byte, info.Size()-offset)
|
||||
if _, err := f.ReadAt(buf, offset); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(buf), "\n")
|
||||
// A read that started mid-file almost certainly started mid-line, and half a
|
||||
// line is more misleading than one fewer.
|
||||
if offset > 0 && len(lines) > 0 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
kept := make([]string, 0, engineErrorLogTailLines)
|
||||
for i := len(lines) - 1; i >= 0 && len(kept) < engineErrorLogTailLines; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
slices.Reverse(kept)
|
||||
return strings.Join(kept, " | ")
|
||||
}
|
||||
|
||||
// Builds the health probe the image's engine supplies.
|
||||
func newProbe(cfg config, run probeRunner) (*engineProbe, error) {
|
||||
layout, err := layoutFor(cfg)
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestEngineLogTail(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "drops a line the byte cap cut in half",
|
||||
body: strings.Repeat("x", mariadbErrorLogTailBytes) + "\nlast\n",
|
||||
body: strings.Repeat("x", engineErrorLogTailBytes) + "\nlast\n",
|
||||
want: "last",
|
||||
wantMissing: "x",
|
||||
},
|
||||
@@ -209,3 +209,54 @@ func TestWithEngineLogTailLeavesReasonAloneWithoutALog(t *testing.T) {
|
||||
t.Errorf("withEngineLogTail() = %q, want the reason unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
const postgresConfD = "../../scripts/images/rds-postgres/postgresql.confd"
|
||||
|
||||
// "engine did not respond" is where a postmaster that refused a setting lands,
|
||||
// and it is the same message as an engine that never got as far as trying. Only
|
||||
// the server's own log separates them, and the guest has no other way out.
|
||||
func TestPostgresProbe_QuotesTheEngineLogWhenTheEngineIsAbsent(t *testing.T) {
|
||||
log := writeEngineLog(t, strings.Join([]string{
|
||||
`2026-09-06 10:20:33 UTC [712] LOG: starting PostgreSQL 18.0`,
|
||||
`2026-09-06 10:20:33 UTC [712] FATAL: could not map anonymous shared memory`,
|
||||
"",
|
||||
}, "\n"))
|
||||
|
||||
state, message := postgresProbeState("/run/postgresql", log,
|
||||
func(context.Context, string, ...string) (int, string, error) { return 2, "", nil })(t.Context(), 5432)
|
||||
|
||||
if state != engineAbsent {
|
||||
t.Errorf("state = %v, want absent", state)
|
||||
}
|
||||
if !strings.Contains(message, "could not map anonymous shared memory") {
|
||||
t.Errorf("message = %q, want the postmaster's own refusal quoted", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresProbe_SurvivesAnAbsentEngineLog(t *testing.T) {
|
||||
state, message := postgresProbeState("/run/postgresql", filepath.Join(t.TempDir(), "nope.log"),
|
||||
func(context.Context, string, ...string) (int, string, error) { return 2, "", nil })(t.Context(), 5432)
|
||||
|
||||
if state != engineAbsent {
|
||||
t.Errorf("state = %v, want absent", state)
|
||||
}
|
||||
if message != "engine did not respond on /run/postgresql:5432" {
|
||||
t.Errorf("message = %q, want the bare reason when there is no log to quote", message)
|
||||
}
|
||||
}
|
||||
|
||||
// The layout names a path the image has to be directing the postmaster to, or
|
||||
// the tail above quotes nothing on the one failure it exists for.
|
||||
func TestPostgresErrorLogPathMatchesTheImage(t *testing.T) {
|
||||
raw, err := os.ReadFile(postgresConfD)
|
||||
if err != nil {
|
||||
t.Fatalf("read postgresql.confd: %v", err)
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^logfile="(.*)"$`).FindStringSubmatch(string(raw))
|
||||
if m == nil {
|
||||
t.Fatal("postgresql.confd has no logfile assignment")
|
||||
}
|
||||
if got := engineLayouts[enginePostgres].errorLog; got != m[1] {
|
||||
t.Errorf("layout errorLog = %q, but the image sets logfile = %q", got, m[1])
|
||||
}
|
||||
}
|
||||
@@ -100,11 +100,6 @@ const (
|
||||
// rds-init keeps for the platform and never hands to the customer.
|
||||
mariadbSuperuser = "root"
|
||||
mariadbProbeConnectTimeoutSeconds = 3
|
||||
// How much of the engine's error log a probe reason carries. The server names
|
||||
// what it refused on in its last few lines, and the reason reaches a customer
|
||||
// through StatusInfos, so this quotes the tail rather than the whole file.
|
||||
mariadbErrorLogTailBytes = 4096
|
||||
mariadbErrorLogTailLines = 4
|
||||
// How much of the probe client's stderr a reason carries, on the same grounds
|
||||
// as the log tail above.
|
||||
mariadbProbeStderrMaxBytes = 512
|
||||
@@ -280,53 +275,6 @@ func collapseProbeStderr(stderr string) string {
|
||||
return line
|
||||
}
|
||||
|
||||
// Appends the last few lines of the engine's error log to reason. An unset path,
|
||||
// an unreadable file or an empty one leaves reason alone: the probe's own
|
||||
// statement is still true, and a guessed cause would be worse than none.
|
||||
func withEngineLogTail(reason, path string) string {
|
||||
tail := engineLogTail(path)
|
||||
if tail == "" {
|
||||
return reason
|
||||
}
|
||||
return reason + "; the engine's log ends: " + tail
|
||||
}
|
||||
|
||||
func engineLogTail(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
offset := max(info.Size()-mariadbErrorLogTailBytes, 0)
|
||||
buf := make([]byte, info.Size()-offset)
|
||||
if _, err := f.ReadAt(buf, offset); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(buf), "\n")
|
||||
// A read that started mid-file almost certainly started mid-line, and half a
|
||||
// line is more misleading than one fewer.
|
||||
if offset > 0 && len(lines) > 0 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
kept := make([]string, 0, mariadbErrorLogTailLines)
|
||||
for i := len(lines) - 1; i >= 0 && len(kept) < mariadbErrorLogTailLines; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
slices.Reverse(kept)
|
||||
return strings.Join(kept, " | ")
|
||||
}
|
||||
|
||||
func readPidFile(path string) (int, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -47,10 +47,10 @@ const postgresProbeBinary = "pg_isready"
|
||||
// enforcement rule — libpq reads a host beginning with / as a socket directory,
|
||||
// and the generated `local ... peer` line covers it.
|
||||
func newPostgresProbe(cfg config, run probeRunner) *engineProbe {
|
||||
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, run))
|
||||
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, cfg.EngineErrorLog, run))
|
||||
}
|
||||
|
||||
func postgresProbeState(host string, run probeRunner) probeStateFn {
|
||||
func postgresProbeState(host, errorLog string, run probeRunner) probeStateFn {
|
||||
return func(ctx context.Context, port int64) (engineState, string) {
|
||||
portArg := strconv.FormatInt(port, 10)
|
||||
code, _, err := run(ctx, postgresProbeBinary, "-h", host, "-p", portArg, "-q")
|
||||
@@ -63,9 +63,14 @@ func postgresProbeState(host string, run probeRunner) probeStateFn {
|
||||
case code == 0:
|
||||
return engineServing, ""
|
||||
case code == 1:
|
||||
return engineRecovering, "engine is rejecting connections (startup or recovery)"
|
||||
return engineRecovering, withEngineLogTail(
|
||||
"engine is rejecting connections (startup or recovery)", errorLog)
|
||||
default:
|
||||
return engineAbsent, fmt.Sprintf("engine did not respond on %s:%s", host, portArg)
|
||||
// A postmaster that refused a setting never opens the socket, so this is
|
||||
// the state a bad parameter lands in. The reason for it is only in the
|
||||
// server's own log, and it reaches the customer through StatusInfos.
|
||||
return engineAbsent, withEngineLogTail(
|
||||
fmt.Sprintf("engine did not respond on %s:%s", host, portArg), errorLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,26 @@ func (h *heartbeater) beat(ctx context.Context) {
|
||||
h.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
|
||||
}
|
||||
|
||||
// How often a not-yet-serving engine is reported. The control plane owns the
|
||||
// steady-state cadence, but while the engine has never answered it is actively
|
||||
// waiting on this instance — a restart is not finished until a healthy beat
|
||||
// lands — and a full interval of silence there is pure added downtime.
|
||||
const startupHeartbeatInterval = 5 * time.Second
|
||||
|
||||
func (h *heartbeater) nextInterval() time.Duration {
|
||||
if h.probe != nil && !h.probe.seenHealthy && startupHeartbeatInterval < h.interval {
|
||||
return startupHeartbeatInterval
|
||||
}
|
||||
return h.interval
|
||||
}
|
||||
|
||||
// A timer rather than a ticker, so a cadence change takes effect on the next
|
||||
// beat, not at a restart.
|
||||
// beat, not at a restart. The first beat is not waited for: a just-registered
|
||||
// agent has told the control plane nothing about the engine yet, and holding
|
||||
// that back for an interval makes every restart look longer than it was.
|
||||
func (h *heartbeater) Run(ctx context.Context) {
|
||||
timer := time.NewTimer(h.interval)
|
||||
h.beat(ctx)
|
||||
timer := time.NewTimer(h.nextInterval())
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -101,7 +117,7 @@ func (h *heartbeater) Run(ctx context.Context) {
|
||||
return
|
||||
case <-timer.C:
|
||||
h.beat(ctx)
|
||||
timer.Reset(h.interval)
|
||||
timer.Reset(h.nextInterval())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,34 @@ func (r *countingServingRecorder) RecordServingParameters(context.Context) error
|
||||
r.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
// A restart is not finished until a healthy beat lands, so the two cadence
|
||||
// rules that shorten it are worth pinning: report at once rather than after a
|
||||
// full interval, and keep reporting quickly while the engine has never served.
|
||||
func TestHeartbeater_ReportsPromptlyUntilTheEngineHasServed(t *testing.T) {
|
||||
code := 2
|
||||
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
|
||||
return code, "", nil
|
||||
})
|
||||
cp := newFakeControlPlane()
|
||||
h := newHeartbeater(cp, probe, nil, 0)
|
||||
|
||||
if got := h.nextInterval(); got != startupHeartbeatInterval {
|
||||
t.Errorf("interval before the engine has served = %s, want %s", got, startupHeartbeatInterval)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go h.Run(ctx)
|
||||
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "the first heartbeat")
|
||||
cancel()
|
||||
|
||||
if states := cp.snapshotStates(); states[0].health != handlers_rds.EngineHealthStarting {
|
||||
t.Errorf("first beat health = %q, want %q", states[0].health, handlers_rds.EngineHealthStarting)
|
||||
}
|
||||
|
||||
code = 0
|
||||
h.beat(context.Background())
|
||||
if got := h.nextInterval(); got != h.interval {
|
||||
t.Errorf("interval after the engine served = %s, want the control plane's %s", got, h.interval)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -16,21 +16,32 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig(defaultEnvFile)
|
||||
|
||||
agent, err := New(cfg)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Retried rather than fatal: construction resolves the IMDS credential chain,
|
||||
// so a metadata service that is not answering yet would otherwise end the
|
||||
// process with the engine still down and nothing left to bring it back.
|
||||
var agent *Agent
|
||||
err := retry(ctx, "startup", func(context.Context) error {
|
||||
var err error
|
||||
agent, err = New(cfg)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
slog.Error("rds-agent: startup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// A shutdown signal cancels whatever boot retry loop was running; that is a
|
||||
// clean stop, not a failure.
|
||||
if err := agent.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -132,6 +133,50 @@ func TestCommandRegistry_QuiesceCarriesTheLabelAndDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's own hold settles only the files it owns. Everything else on that
|
||||
// volume — the parameter file the platform writes beside the datadir — is dirty
|
||||
// page cache until this, and a snapshot of it restores an engine that will not
|
||||
// start.
|
||||
func TestCommandRegistry_QuiesceFlushesTheDataMount(t *testing.T) {
|
||||
storage := &fakeStorage{}
|
||||
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, storage), 0).execute(context.Background(),
|
||||
quiesceCommand("cmd-5a", "360"))
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
||||
t.Fatalf("reply = %+v, want succeeded", reply)
|
||||
}
|
||||
if !storage.synced {
|
||||
t.Error("the data mount was not flushed, so the snapshot may not hold what the guest wrote")
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot taken after a flush that did not happen is the failure this whole
|
||||
// path exists to prevent, so it is reported rather than swallowed. The hold is
|
||||
// left on the engine: it expires on its own deadline.
|
||||
func TestCommandRegistry_QuiesceFailsWhenTheDataMountCannotBeFlushed(t *testing.T) {
|
||||
storage := &fakeStorage{syncErr: errors.New("input/output error")}
|
||||
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, storage), 0).execute(context.Background(),
|
||||
quiesceCommand("cmd-5b", "360"))
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusFailed {
|
||||
t.Fatalf("reply = %+v, want failed", reply)
|
||||
}
|
||||
if !strings.Contains(reply.Message, "input/output error") {
|
||||
t.Errorf("reply message = %q, want the flush error", reply.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func quiesceCommand(id, deadline string) handlers_rds.Command {
|
||||
return handlers_rds.Command{
|
||||
CommandID: id,
|
||||
Type: handlers_rds.CommandQuiesce,
|
||||
Parameters: []handlers_rds.Parameter{
|
||||
{Name: handlers_rds.CommandParamQuiesceLabel, Value: "orders-db-pre-upgrade"},
|
||||
{Name: handlers_rds.CommandParamQuiesceDeadlineSeconds, Value: deadline},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A hold the guest chose the length of would not be the bound the control plane
|
||||
// is relying on, so a missing or unreadable deadline is refused.
|
||||
func TestCommandRegistry_QuiesceRejectsAnUnusableDeadline(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// The in-guest half of a storage grow: the control plane has already grown the
|
||||
@@ -17,6 +19,9 @@ type storageOps interface {
|
||||
// Extends the filesystem at the data mount onto its whole device, and
|
||||
// reports what it did for the command reply.
|
||||
GrowFilesystem(ctx context.Context) (string, error)
|
||||
// Flushes every dirty page on the data mount to its device. A backup hold
|
||||
// settles the engine's own files and nothing else on that filesystem.
|
||||
SyncDataMount(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Tool names rather than paths: the image installs them from
|
||||
@@ -50,6 +55,23 @@ func newGuestStorage(cfg config, run commandRunner) *guestStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// syncfs rather than fsync: the whole filesystem, not one file. Config the
|
||||
// engine never opens lives on this volume too, and a snapshot that catches it
|
||||
// half-written leaves a restored instance the engine cannot start from.
|
||||
func (g *guestStorage) SyncDataMount(ctx context.Context) error {
|
||||
f, err := os.Open(g.dataMount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open the data mount %s to flush it: %w", g.dataMount, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := unix.Syncfs(int(f.Fd())); err != nil {
|
||||
return fmt.Errorf("flush the data mount %s: %w", g.dataMount, err)
|
||||
}
|
||||
slog.InfoContext(ctx, "rds-agent: data mount flushed for backup", "dataMount", g.dataMount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The device and filesystem behind the data mount, resolved from the kernel's
|
||||
// own mount table rather than from anything the control plane asserted.
|
||||
type dataMount struct {
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
// is that the command reaches it rather than what it does to a filesystem.
|
||||
type fakeStorage struct {
|
||||
grown bool
|
||||
synced bool
|
||||
message string
|
||||
err error
|
||||
syncErr error
|
||||
}
|
||||
|
||||
var _ storageOps = (*fakeStorage)(nil)
|
||||
@@ -26,6 +28,11 @@ func (f *fakeStorage) GrowFilesystem(context.Context) (string, error) {
|
||||
return f.message, f.err
|
||||
}
|
||||
|
||||
func (f *fakeStorage) SyncDataMount(context.Context) error {
|
||||
f.synced = true
|
||||
return f.syncErr
|
||||
}
|
||||
|
||||
// runLog records what a grow shelled out to, in order, so the test asserts on
|
||||
// the tools invoked rather than on a filesystem it would have to create.
|
||||
type runLog struct {
|
||||
@@ -227,3 +234,25 @@ func TestCommandRegistry_GrowFilesystemReachesTheGuestStorage(t *testing.T) {
|
||||
t.Errorf("reply = %+v, want succeeded carrying what the grow reported", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDataMount_FlushesTheMountItWasGiven(t *testing.T) {
|
||||
g := &guestStorage{dataMount: t.TempDir()}
|
||||
|
||||
if err := g.SyncDataMount(context.Background()); err != nil {
|
||||
t.Fatalf("SyncDataMount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The control plane snapshots on the strength of this reply, so a mount that is
|
||||
// not there has to be an error rather than a flush of nothing.
|
||||
func TestSyncDataMount_FailsWhenTheMountIsNotThere(t *testing.T) {
|
||||
g := &guestStorage{dataMount: filepath.Join(t.TempDir(), "absent")}
|
||||
|
||||
err := g.SyncDataMount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("SyncDataMount succeeded on a mount that does not exist")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent") {
|
||||
t.Errorf("error = %v, want the mount it could not open", err)
|
||||
}
|
||||
}
|
||||
|
||||
+151
-26
@@ -27,18 +27,20 @@ import (
|
||||
"time"
|
||||
"uuid"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/masterkey"
|
||||
"github.com/mulgadc/bluebottle/pkg/safecast"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
"github.com/mulgadc/spinifex/spinifex/gpu"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/hostdns"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/host"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -217,6 +219,18 @@ decision made per volume.`,
|
||||
Run: runVolumesOrphansCmd,
|
||||
}
|
||||
|
||||
var volumesUnsealedCmd = &cobra.Command{
|
||||
Use: "unsealed",
|
||||
Short: "Report volumes whose writes have not been confirmed to the backend",
|
||||
Long: `List volumes a node has open, or had open, without a confirmed seal to the
|
||||
backend. The node named holds writes that predastore may not have.
|
||||
|
||||
Starting such a volume elsewhere is allowed and is preferred over leaving the
|
||||
instance unable to run, but it opens from the last checkpoint that did reach
|
||||
the backend. This is the list of volumes where that would cost something.`,
|
||||
Run: runVolumesUnsealedCmd,
|
||||
}
|
||||
|
||||
var accountCmd = &cobra.Command{
|
||||
Use: "account",
|
||||
Short: "Manage Spinifex accounts",
|
||||
@@ -317,6 +331,7 @@ func init() {
|
||||
|
||||
adminCmd.AddCommand(volumesCmd)
|
||||
volumesCmd.AddCommand(volumesOrphansCmd)
|
||||
volumesCmd.AddCommand(volumesUnsealedCmd)
|
||||
|
||||
adminCmd.AddCommand(accountCmd)
|
||||
accountCmd.AddCommand(accountCreateCmd)
|
||||
@@ -343,6 +358,7 @@ func init() {
|
||||
|
||||
// Flags for admin init
|
||||
adminInitCmd.Flags().Bool("force", false, "Force re-initialization (overwrites existing config)")
|
||||
adminInitCmd.Flags().Bool("discard-jetstream", true, "With --nodes >= 2, remove this node's existing JetStream streams once every node has joined; =false refuses to form over a non-empty store instead. Ignored with --nodes 1")
|
||||
adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create")
|
||||
adminInitCmd.Flags().String("az", "ap-southeast-2a", "Mulga AZ to create")
|
||||
adminInitCmd.Flags().String("node", "node1", "Node name, increment for additional nodes (default, node1)")
|
||||
@@ -445,7 +461,7 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 {
|
||||
if sizeBytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return utils.SafeInt64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
|
||||
return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
|
||||
}
|
||||
|
||||
func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
@@ -693,7 +709,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
err = admin.ImportImage(context.Background(), provider, admin.ImportOpts{
|
||||
VolumeID: volumeId,
|
||||
NodeID: appConfig.Node,
|
||||
SizeBytes: utils.SafeUint64ToInt64(ami.VolumeSizeGiB * bytesPerGiB),
|
||||
SizeBytes: safecast.Uint64ToInt64(ami.VolumeSizeGiB * bytesPerGiB),
|
||||
AvailabilityZone: node.AZ,
|
||||
SourcePath: extractedImagePath,
|
||||
Snapshot: true,
|
||||
@@ -731,10 +747,10 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// DescribeSnapshots call cannot resolve it. The volume ID matches ami.ImageID:
|
||||
// admin.ImportImage creates the volume under the AMI's own ID.
|
||||
func registerImportedAMISnapshot(store objectstore.ObjectStore, bucket string, ami ebsmetadata.AMI, az string, encrypted bool) error {
|
||||
cfg := &handlers_ec2_snapshot.SnapshotConfig{
|
||||
cfg := ebsmetadata.Snapshot{
|
||||
SnapshotID: ami.SnapshotID,
|
||||
VolumeID: ami.ImageID,
|
||||
VolumeSize: utils.SafeUint64ToInt64(ami.VolumeSizeGiB),
|
||||
VolumeSize: safecast.Uint64ToInt64(ami.VolumeSizeGiB),
|
||||
State: "completed",
|
||||
Progress: "100%",
|
||||
StartTime: time.Now(),
|
||||
@@ -743,7 +759,7 @@ func registerImportedAMISnapshot(store objectstore.ObjectStore, bucket string, a
|
||||
OwnerID: utils.GlobalAccountID,
|
||||
AvailabilityZone: az,
|
||||
}
|
||||
if err := handlers_ec2_snapshot.WriteSnapshotConfig(store, bucket, ami.SnapshotID, cfg); err != nil {
|
||||
if err := ebsmetadata.NewStore(store, bucket).PutSnapshot(context.Background(), cfg); err != nil {
|
||||
return fmt.Errorf("register snapshot metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -842,9 +858,9 @@ func runimagesRemoveCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
fmt.Printf(" Backing storage: %s/ (%d objects, %s)\n",
|
||||
preview.ImageID, preview.AMIObjectCount, utils.HumanBytes(utils.SafeInt64ToUint64(preview.AMIBytesTotal)))
|
||||
preview.ImageID, preview.AMIObjectCount, utils.HumanBytes(safecast.Int64ToUint64(preview.AMIBytesTotal)))
|
||||
fmt.Printf(" %s/ (%d objects, %s)\n",
|
||||
admin.SnapPrefix(preview.ImageID), preview.SnapObjectCount, utils.HumanBytes(utils.SafeInt64ToUint64(preview.SnapBytesTotal)))
|
||||
admin.SnapPrefix(preview.ImageID), preview.SnapObjectCount, utils.HumanBytes(safecast.Int64ToUint64(preview.SnapBytesTotal)))
|
||||
fmt.Println()
|
||||
|
||||
// Account-owned guard before salvage / dependents — the AWS-flow hint is
|
||||
@@ -900,7 +916,7 @@ func runimagesRemoveCmd(cmd *cobra.Command, args []string) {
|
||||
// BytesDeleted is logical: predastore reclaims the underlying disk space
|
||||
// asynchronously via background compaction, not at delete time.
|
||||
fmt.Printf("✅ Removed AMI %s (%d objects, %s marked for deletion; disk space is reclaimed by background compaction).\n",
|
||||
imageID, res.ObjectsDeleted, utils.HumanBytes(utils.SafeInt64ToUint64(res.BytesDeleted)))
|
||||
imageID, res.ObjectsDeleted, utils.HumanBytes(safecast.Int64ToUint64(res.BytesDeleted)))
|
||||
}
|
||||
|
||||
func printDependents(w io.Writer, d admin.Dependents) {
|
||||
@@ -1298,6 +1314,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if spxRoot == "" {
|
||||
spxRoot = DefaultDataDir()
|
||||
}
|
||||
discardJetStream, _ := cmd.Flags().GetBool("discard-jetstream")
|
||||
if err := checkInitJetStreamStore(jetStreamStoreDir(spxRoot), nodes, discardJetStream); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Preserve the previously-captured operator email across --force re-inits
|
||||
// when --email is omitted (e.g. reset-dev-env.sh workflows). Without this
|
||||
// a reset would silently blank the address.
|
||||
@@ -1599,6 +1624,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
ClusterName: clusterName,
|
||||
|
||||
PredastoreHostID: predastoreHostID,
|
||||
PredastoreAdminPort: admin.PredastoreAdminPort,
|
||||
CompactionIntervalSeconds: compactionInterval,
|
||||
Services: services,
|
||||
|
||||
@@ -1829,6 +1855,17 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
fmt.Printf("✅ All %d nodes joined!\n", expectedNodes)
|
||||
|
||||
// The pre-start check refused a store with streams unless discard was asked
|
||||
// for, so anything left here is removed now that the cluster is committed.
|
||||
if discard, _ := cmd.Flags().GetBool("discard-jetstream"); discard {
|
||||
storeDir := jetStreamStoreDir(spxRoot)
|
||||
if err := discardJetStreamStore(storeDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Discarded this node's pre-formation JetStream store: %s\n", storeDir)
|
||||
}
|
||||
|
||||
// Build cluster topology from formation data
|
||||
allNodes := fs.Nodes()
|
||||
clusterRoutes := formation.BuildClusterRoutes(allNodes)
|
||||
@@ -1900,6 +1937,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
ClusterName: clusterName,
|
||||
|
||||
PredastoreHostID: predastoreHostID,
|
||||
PredastoreAdminPort: admin.PredastoreAdminPort,
|
||||
CompactionIntervalSeconds: compactionInterval,
|
||||
Services: services,
|
||||
RemoteNodes: buildRemoteNodes(allNodes, node, northstarConfigPath),
|
||||
@@ -1985,19 +2023,27 @@ func joinRetryable(err error, statusCode int) bool {
|
||||
}
|
||||
|
||||
// checkJoinPreconditions rejects a join that would silently destroy this node's
|
||||
// existing cluster identity. Joining adopts the primary's CA and master key,
|
||||
// overwriting whatever is here: correct on a freshly installed node, and on one
|
||||
// that has been in service it orphans every fragment and volume sealed under the
|
||||
// old key. runAdminInit guards the same way before re-initializing.
|
||||
func checkJoinPreconditions(configDir string, force bool) error {
|
||||
// identity or JetStream data; --force accepts that loss. A running NATS service
|
||||
// is refused even with --force, because the store cannot be removed under it.
|
||||
func checkJoinPreconditions(configDir, storeDir string, force bool) error {
|
||||
if err := checkStoreDiscardable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if force {
|
||||
return nil
|
||||
}
|
||||
tomlPath := filepath.Join(configDir, "spinifex.toml")
|
||||
if !admin.FileExists(tomlPath) {
|
||||
return nil
|
||||
if admin.FileExists(tomlPath) {
|
||||
return fmt.Errorf("this node is already initialized: %s", tomlPath)
|
||||
}
|
||||
return fmt.Errorf("this node is already initialized: %s", tomlPath)
|
||||
streams, err := localStreams(storeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(streams) > 0 {
|
||||
return fmt.Errorf("this node's JetStream store %s holds %d stream(s)", storeDir, len(streams))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinDiscardsIdentityMsg spells out what a forced join throws away. Kept out of
|
||||
@@ -2006,6 +2052,8 @@ const joinDiscardsIdentityMsg = `Joining will discard this node's own cluster id
|
||||
- CA certificate and key
|
||||
- master key, and any data sealed under it
|
||||
- viperblock key, and any volumes encrypted under it
|
||||
- JetStream store (<data-dir>/nats/jetstream): every stream and KV bucket
|
||||
this node holds, so it takes the cluster's copy instead of diverging from it
|
||||
|
||||
That is safe on a freshly installed node — an ISO install initializes a
|
||||
single-node cluster at first boot, and nothing has been sealed under these keys
|
||||
@@ -2086,7 +2134,11 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
// Checked before any network call so a node that will not join says so
|
||||
// immediately. Unlike init this exits non-zero: a node that did not join
|
||||
// must not look like success to a provisioning script.
|
||||
if err := checkJoinPreconditions(configDir, force); err != nil {
|
||||
if dataDir == "" {
|
||||
dataDir = DefaultDataDir()
|
||||
}
|
||||
storeDir := jetStreamStoreDir(dataDir)
|
||||
if err := checkJoinPreconditions(configDir, storeDir, force); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ %v\n\n%s\n", err, joinDiscardsIdentityMsg)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -2096,11 +2148,6 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
clusterBind = bindIP
|
||||
}
|
||||
|
||||
// Set default data directory
|
||||
if dataDir == "" {
|
||||
dataDir = DefaultDataDir()
|
||||
}
|
||||
|
||||
fmt.Println("🚀 Joining Spinifex cluster...")
|
||||
fmt.Printf("Node: %s\n", node)
|
||||
fmt.Printf("Leader: %s\n", leaderHost)
|
||||
@@ -2281,6 +2328,14 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Removed only once formation has succeeded, so a failed join leaves the
|
||||
// node's single-node cluster intact.
|
||||
if err := discardJetStreamStore(storeDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Discarded this node's pre-formation JetStream store: %s\n", storeDir)
|
||||
|
||||
// Set up config directory
|
||||
if configDir == "" {
|
||||
configDir = DefaultConfigDir()
|
||||
@@ -2459,6 +2514,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
ClusterName: creds.ClusterName,
|
||||
|
||||
PredastoreHostID: predastoreHostID,
|
||||
PredastoreAdminPort: admin.PredastoreAdminPort,
|
||||
CompactionIntervalSeconds: compactionInterval,
|
||||
Services: services,
|
||||
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node, northstarConfigPath),
|
||||
@@ -2578,7 +2634,7 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
|
||||
}
|
||||
|
||||
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
|
||||
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
|
||||
masterKey, err := masterkey.ReadShared(masterKeyPath)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
|
||||
@@ -2840,9 +2896,44 @@ func northstarFromFormation(creds *formation.SharedCredentials, dirs configDirs)
|
||||
}, filepath.Join(dirs.Northstar, "northstar.toml")
|
||||
}
|
||||
|
||||
// resolverAllowFrom returns the addresses permitted to recurse against this
|
||||
// node's northstar: every cluster node, on whichever address its peers dial.
|
||||
//
|
||||
// Guests are covered without appearing here. vpcd's per-tap DNS shim relays
|
||||
// guest queries to a node's :5300 from a node address, so trusting the nodes
|
||||
// trusts every guest behind them, and no guest CIDR is needed.
|
||||
func resolverAllowFrom(settings admin.ConfigSettings) []string {
|
||||
seen := make(map[string]bool)
|
||||
var addrs []string
|
||||
add := func(ip string) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || ip == "0.0.0.0" || seen[ip] {
|
||||
return
|
||||
}
|
||||
seen[ip] = true
|
||||
addrs = append(addrs, ip)
|
||||
}
|
||||
|
||||
// Both local addresses: a node resolving through its own advertised address
|
||||
// is seen arriving from that address over lo, not from 127.0.0.1.
|
||||
add(settings.AdvertiseIP)
|
||||
add(settings.BindIP)
|
||||
add(settings.ClusterBindIP)
|
||||
for _, peer := range settings.RemoteNodes {
|
||||
host := peer.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
add(host)
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// generateAndWriteConfigs renders the standard config files (spinifex.toml,
|
||||
// awsgw.toml, nats.conf, and optionally predastore.toml) from templates.
|
||||
func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings admin.ConfigSettings, skipPredastore bool) error {
|
||||
settings.ResolverAllowFrom = resolverAllowFrom(settings)
|
||||
|
||||
// A one-sided pair must disable Northstar wholesale. Rendering only the
|
||||
// public stanza or only the secret file advertises a resolver that cannot run.
|
||||
northstarEnabled := settings.NorthstarAccessKey != "" && settings.NorthstarSecretKey != ""
|
||||
@@ -2984,7 +3075,7 @@ type writeBootstrapResult struct {
|
||||
func ensureMasterKey(configDir string) (key []byte, existed bool, err error) {
|
||||
keyPath := filepath.Join(configDir, "master.key")
|
||||
if admin.FileExists(keyPath) {
|
||||
key, err = handlers_iam.LoadMasterKey(keyPath)
|
||||
key, err = masterkey.ReadShared(keyPath)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("load master key: %w", err)
|
||||
}
|
||||
@@ -3077,7 +3168,7 @@ func ensureViperblockEncryptionKey(configDir string) ([]byte, string, error) {
|
||||
}
|
||||
keyPath := filepath.Join(viperblockDir, "encryption.key")
|
||||
if admin.FileExists(keyPath) {
|
||||
key, err := handlers_iam.LoadMasterKey(keyPath)
|
||||
key, err := masterkey.ReadShared(keyPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("load viperblock encryption key: %w", err)
|
||||
}
|
||||
@@ -3658,3 +3749,37 @@ func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Im
|
||||
fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:")
|
||||
fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName)
|
||||
}
|
||||
|
||||
func runVolumesUnsealedCmd(_ *cobra.Command, _ []string) {
|
||||
_, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not connect to the cluster: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
unsealed, err := vbwire.ListUnsealedVolumes(context.Background(), nc)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Could not read the unsealed volumes:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(unsealed) == 0 {
|
||||
fmt.Println("No unsealed volumes: every volume's writes are confirmed to the backend.")
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(unsealed, func(i, j int) bool { return unsealed[i].VolumeID < unsealed[j].VolumeID })
|
||||
fmt.Printf("%d volume(s) with writes the backend may not have:\n\n", len(unsealed))
|
||||
for _, volume := range unsealed {
|
||||
fmt.Printf(" %s\n", volume.VolumeID)
|
||||
fmt.Printf(" held by: %s\n", volume.Owner)
|
||||
if !volume.Since.IsZero() {
|
||||
fmt.Printf(" since: %s\n", volume.Since.Format(time.RFC3339))
|
||||
}
|
||||
fmt.Printf(" reason: %s\n", volume.Reason)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("A volume open right now is listed here and that is normal — the entry")
|
||||
fmt.Println("clears on its next successful seal. One naming a node that is gone is")
|
||||
fmt.Println("the case worth acting on: starting it elsewhere loses writes made since.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/highwayhash"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kvCmd = &cobra.Command{
|
||||
Use: "kv",
|
||||
Short: "Inspect JetStream KV state",
|
||||
}
|
||||
|
||||
var kvDigestCmd = &cobra.Command{
|
||||
Use: "digest",
|
||||
Short: "Print a digest of every stream held by this node's JetStream replica",
|
||||
Long: `Read this node's own copy of every JetStream stream and print one line per
|
||||
stream: name, first and last sequence, message count, and a SHA-256 over every
|
||||
stored message (sequence, subject, timestamp, headers, data).
|
||||
|
||||
Run it on every node and compare the output. Replicas of one stream must
|
||||
produce identical lines; any difference means the replicas have diverged, and
|
||||
--seqs prints a hash per sequence to find where. For a check that tells a
|
||||
diverged replica from one that is merely behind, run it with --json --seqs on
|
||||
every node and pass the results to "spx admin kv compare".
|
||||
|
||||
It never touches the live store. A direct get over NATS may be answered by any
|
||||
replica, so instead the streams tree is copied to --work-dir and read by a
|
||||
private in-process server with no listener, which is removed afterwards. The
|
||||
copy's stream config is set to one replica with no expiry so it can be loaded.
|
||||
|
||||
Writes landing during the copy, or applied but not yet flushed to disk, can
|
||||
make a replica's tail differ, so gate API writes and re-run before calling a
|
||||
tail difference divergence.`,
|
||||
Run: runKVDigest,
|
||||
}
|
||||
|
||||
func init() {
|
||||
adminCmd.AddCommand(kvCmd)
|
||||
kvCmd.AddCommand(kvDigestCmd)
|
||||
kvDigestCmd.Flags().String("store-dir", "", "JetStream store to read (default <spinifex-dir>/nats/jetstream)")
|
||||
kvDigestCmd.Flags().String("work-dir", os.TempDir(), "Directory to hold the temporary copy")
|
||||
kvDigestCmd.Flags().StringSlice("stream", nil, "Only these streams (repeatable)")
|
||||
kvDigestCmd.Flags().Bool("seqs", false, "Also print a hash per sequence")
|
||||
kvDigestCmd.Flags().Bool("json", false, "Print one JSON document, the input to \"spx admin kv compare\"")
|
||||
}
|
||||
|
||||
// nodeDigest is one node's digest report, the unit "kv compare" reads.
|
||||
type nodeDigest struct {
|
||||
Host string `json:"host"`
|
||||
Store string `json:"store"`
|
||||
CopiedAt time.Time `json:"copied_at"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
Streams []streamDigest `json:"streams"`
|
||||
}
|
||||
|
||||
// streamDigest is one stream's content as held by one replica.
|
||||
type streamDigest struct {
|
||||
streamMeta
|
||||
|
||||
Name string `json:"name"`
|
||||
FirstSeq uint64 `json:"first_seq"`
|
||||
LastSeq uint64 `json:"last_seq"`
|
||||
Msgs uint64 `json:"msgs"`
|
||||
Digest string `json:"sha256"`
|
||||
Seqs []seqDigest `json:"seqs,omitempty"`
|
||||
}
|
||||
|
||||
// streamMeta is the part of the live stream config that decides which replica
|
||||
// differences are legitimate. It is read before the copy is rewritten.
|
||||
type streamMeta struct {
|
||||
Replicas int `json:"replicas,omitempty"`
|
||||
Retention string `json:"retention,omitempty"`
|
||||
MaxAge time.Duration `json:"max_age_ns,omitempty"`
|
||||
AllowMsgTTL bool `json:"allow_msg_ttl,omitempty"`
|
||||
MaxMsgs int64 `json:"max_msgs,omitempty"`
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// seqDigest is the hash of one stored sequence, or "deleted" when the replica
|
||||
// has no message at that sequence. Subject, time and TTL are empty then.
|
||||
type seqDigest struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Hash string `json:"hash"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Time time.Time `json:"time,omitzero"`
|
||||
TTL time.Duration `json:"ttl_ns,omitempty"`
|
||||
}
|
||||
|
||||
// deletedHash marks a sequence the replica holds no message for.
|
||||
const deletedHash = "deleted"
|
||||
|
||||
func runKVDigest(cmd *cobra.Command, _ []string) {
|
||||
storeDir, _ := cmd.Flags().GetString("store-dir")
|
||||
if storeDir == "" {
|
||||
spxRoot, _ := cmd.Flags().GetString("spinifex-dir")
|
||||
if spxRoot == "" {
|
||||
spxRoot = DefaultDataDir()
|
||||
}
|
||||
storeDir = jetStreamStoreDir(spxRoot)
|
||||
}
|
||||
workParent, _ := cmd.Flags().GetString("work-dir")
|
||||
only, _ := cmd.Flags().GetStringSlice("stream")
|
||||
withSeqs, _ := cmd.Flags().GetBool("seqs")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
work, err := os.MkdirTemp(workParent, "spx-kv-digest-")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
|
||||
copiedAt := time.Now().UTC()
|
||||
skipped, metas, err := copyStreamTree(storeDir, filepath.Join(work, "jetstream"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
|
||||
os.RemoveAll(work)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
digests, err := digestStore(ctx, work, only, withSeqs, metas)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
|
||||
cancel()
|
||||
os.RemoveAll(work)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(cmd.OutOrStdout())
|
||||
if err := enc.Encode(nodeDigest{Host: host, Store: storeDir, CopiedAt: copiedAt, Skipped: skipped, Streams: digests}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeDigests(cmd.OutOrStdout(), host, storeDir, copiedAt, skipped, digests)
|
||||
}
|
||||
|
||||
// writeDigests prints the report. Only the "#" lines carry node-specific
|
||||
// values, so `grep -v '^#'` output from two replicas diffs cleanly.
|
||||
func writeDigests(w io.Writer, host, storeDir string, copiedAt time.Time, skipped []string, digests []streamDigest) {
|
||||
fmt.Fprintf(w, "# host=%s store=%s copied_at=%s\n", host, storeDir, copiedAt.Format(time.RFC3339))
|
||||
for _, s := range skipped {
|
||||
fmt.Fprintf(w, "# skipped account stream (not read): %s\n", s)
|
||||
}
|
||||
for _, d := range digests {
|
||||
fmt.Fprintf(w, "%s\tfirst=%d\tlast=%d\tmsgs=%d\tsha256=%s\n", d.Name, d.FirstSeq, d.LastSeq, d.Msgs, d.Digest)
|
||||
for _, s := range d.Seqs {
|
||||
fmt.Fprintf(w, "%s\tseq=%d\t%s\n", d.Name, s.Seq, s.Hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// globalAccountDir is the account Spinifex's token-authenticated clients use.
|
||||
// Streams under any other account are listed but not read.
|
||||
const globalAccountDir = "$G"
|
||||
|
||||
// copyStreamTree copies <storeDir>/$G/streams to <dst>/$G/streams, leaving out
|
||||
// consumer state (obs). It returns streams found under other accounts and each
|
||||
// copied stream's config as the live server held it, keyed by stream name.
|
||||
func copyStreamTree(storeDir, dst string) (skipped []string, metas map[string]streamMeta, err error) {
|
||||
streams, err := localStreams(storeDir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
return nil, nil, fmt.Errorf("no streams found under %s", storeDir)
|
||||
}
|
||||
for _, s := range streams {
|
||||
if !strings.HasPrefix(s, globalAccountDir+"/") {
|
||||
skipped = append(skipped, s)
|
||||
}
|
||||
}
|
||||
|
||||
src := filepath.Join(storeDir, globalAccountDir, "streams")
|
||||
out := filepath.Join(dst, globalAccountDir, "streams")
|
||||
err = filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// <stream>/obs holds consumers, which are not stream content.
|
||||
if parts := strings.Split(rel, string(filepath.Separator)); d.IsDir() && len(parts) == 2 && parts[1] == "obs" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
target := filepath.Join(out, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o700)
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("copy streams from %s: %w", src, err)
|
||||
}
|
||||
entries, err := os.ReadDir(out)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metas = make(map[string]streamMeta, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
meta, err := rewriteStreamMetaStandalone(filepath.Join(out, e.Name()))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metas[e.Name()] = meta
|
||||
}
|
||||
}
|
||||
return skipped, metas, nil
|
||||
}
|
||||
|
||||
// rewriteStreamMetaStandalone makes a copied stream loadable by a standalone
|
||||
// server, which refuses replicas > 1 and would expire aged messages on
|
||||
// recovery. meta.sum is highwayhash64 keyed by sha256 of the directory name.
|
||||
// It returns the settings as they were before the rewrite.
|
||||
func rewriteStreamMetaStandalone(streamDir string) (streamMeta, error) {
|
||||
metaPath := filepath.Join(streamDir, "meta.inf")
|
||||
raw, err := os.ReadFile(metaPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return streamMeta{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return streamMeta{}, err
|
||||
}
|
||||
var orig struct {
|
||||
Replicas int `json:"num_replicas"`
|
||||
Retention string `json:"retention"`
|
||||
MaxAge time.Duration `json:"max_age"`
|
||||
AllowMsgTTL bool `json:"allow_msg_ttl"`
|
||||
MaxMsgs int64 `json:"max_msgs"`
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &orig); err != nil {
|
||||
return streamMeta{}, fmt.Errorf("parse %s: %w", metaPath, err)
|
||||
}
|
||||
kept := streamMeta{
|
||||
Replicas: orig.Replicas, Retention: orig.Retention, MaxAge: orig.MaxAge,
|
||||
AllowMsgTTL: orig.AllowMsgTTL, MaxMsgs: orig.MaxMsgs, MaxBytes: orig.MaxBytes,
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var meta map[string]any
|
||||
if err := dec.Decode(&meta); err != nil {
|
||||
return streamMeta{}, fmt.Errorf("parse %s: %w", metaPath, err)
|
||||
}
|
||||
meta["num_replicas"] = 1
|
||||
meta["max_age"] = 0
|
||||
delete(meta, "allow_msg_ttl")
|
||||
delete(meta, "subject_delete_marker_ttl")
|
||||
delete(meta, "placement")
|
||||
out, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return streamMeta{}, err
|
||||
}
|
||||
key := sha256.Sum256([]byte(filepath.Base(streamDir)))
|
||||
hh, err := highwayhash.New64(key[:])
|
||||
if err != nil {
|
||||
return streamMeta{}, err
|
||||
}
|
||||
hh.Write(out)
|
||||
if err := os.WriteFile(metaPath, out, 0o600); err != nil {
|
||||
return streamMeta{}, err
|
||||
}
|
||||
return kept, os.WriteFile(filepath.Join(streamDir, "meta.sum"), []byte(hex.EncodeToString(hh.Sum(nil))), 0o600)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// digestStore opens storeParent (holding jetstream/) in a standalone server
|
||||
// that accepts in-process connections only, and digests each stream.
|
||||
func digestStore(ctx context.Context, storeParent string, only []string, withSeqs bool, metas map[string]streamMeta) ([]streamDigest, error) {
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
ServerName: "spx-kv-digest",
|
||||
JetStream: true,
|
||||
StoreDir: storeParent,
|
||||
DontListen: true,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create reader server: %w", err)
|
||||
}
|
||||
ns.Start()
|
||||
defer func() {
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}()
|
||||
if !ns.ReadyForConnections(30 * time.Second) {
|
||||
return nil, errors.New("reader server did not become ready")
|
||||
}
|
||||
|
||||
nc, err := nats.Connect("", nats.InProcessServer(ns))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to reader server: %w", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var names []string
|
||||
lister := js.StreamNames(ctx)
|
||||
for name := range lister.Name() {
|
||||
if len(only) == 0 || slices.Contains(only, name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if err := lister.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list streams: %w", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
digests := make([]streamDigest, 0, len(names))
|
||||
for _, name := range names {
|
||||
d, err := digestStream(ctx, js, name, withSeqs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.streamMeta = metas[name]
|
||||
digests = append(digests, d)
|
||||
}
|
||||
return digests, nil
|
||||
}
|
||||
|
||||
func digestStream(ctx context.Context, js jetstream.JetStream, name string, withSeqs bool) (streamDigest, error) {
|
||||
s, err := js.Stream(ctx, name)
|
||||
if err != nil {
|
||||
return streamDigest{}, fmt.Errorf("open stream %s: %w", name, err)
|
||||
}
|
||||
info, err := s.Info(ctx)
|
||||
if err != nil {
|
||||
return streamDigest{}, fmt.Errorf("stream info %s: %w", name, err)
|
||||
}
|
||||
st := info.State
|
||||
d := streamDigest{Name: name, FirstSeq: st.FirstSeq, LastSeq: st.LastSeq, Msgs: st.Msgs}
|
||||
|
||||
whole := sha256.New()
|
||||
for seq := st.FirstSeq; st.Msgs > 0 && seq <= st.LastSeq; seq++ {
|
||||
msg, err := s.GetMsg(ctx, seq)
|
||||
sd := seqDigest{Seq: seq}
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrMsgNotFound):
|
||||
sd.Hash = deletedHash
|
||||
case err != nil:
|
||||
return streamDigest{}, fmt.Errorf("read %s seq %d: %w", name, seq, err)
|
||||
default:
|
||||
sd.Hash = hashStoredMsg(msg)
|
||||
sd.Subject = msg.Subject
|
||||
sd.Time = msg.Time.UTC()
|
||||
sd.TTL = msgTTL(msg.Header.Get(jetstream.MsgTTLHeader))
|
||||
}
|
||||
fmt.Fprintf(whole, "%d:%s\n", seq, sd.Hash)
|
||||
if withSeqs {
|
||||
d.Seqs = append(d.Seqs, sd)
|
||||
}
|
||||
}
|
||||
d.Digest = hex.EncodeToString(whole.Sum(nil))
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// hashStoredMsg hashes everything a replica stores for one sequence. Header
|
||||
// keys are sorted so the hash does not depend on map iteration order.
|
||||
func hashStoredMsg(m *jetstream.RawStreamMsg) string {
|
||||
h := sha256.New()
|
||||
var n [8]byte
|
||||
field := func(b []byte) {
|
||||
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
|
||||
h.Write(n[:])
|
||||
h.Write(b)
|
||||
}
|
||||
binary.BigEndian.PutUint64(n[:], m.Sequence)
|
||||
h.Write(n[:])
|
||||
field([]byte(m.Subject))
|
||||
binary.BigEndian.PutUint64(n[:], uint64(m.Time.UnixNano()))
|
||||
h.Write(n[:])
|
||||
keys := make([]string, 0, len(m.Header))
|
||||
for k := range m.Header {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
field([]byte(k))
|
||||
for _, v := range m.Header[k] {
|
||||
field([]byte(v))
|
||||
}
|
||||
}
|
||||
field(m.Data)
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// msgTTL parses a Nats-TTL header: a Go duration or whole seconds. "never", an
|
||||
// absent header or an unparseable one yield zero, meaning no expiry.
|
||||
func msgTTL(v string) time.Duration {
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kvCompareCmd = &cobra.Command{
|
||||
Use: "compare <digest.json>...",
|
||||
Short: "Compare the kv digests of every node and report replicas that have diverged",
|
||||
Long: `Read the "spx admin kv digest --json --seqs" output of every node in the
|
||||
cluster, one file per node ("-" reads any number of them from stdin), and
|
||||
check that every stream's replicas hold the same content.
|
||||
|
||||
A replica that is merely behind is not divergent, so only these fail:
|
||||
|
||||
- the same sequence holds different content on two replicas;
|
||||
- a message held by one replica is missing from another, and nothing on
|
||||
that replica explains it: no later write to the same subject, no expiry
|
||||
under max_age or a message TTL, and no stream limit dropping its head;
|
||||
- a stream is held by fewer nodes than its replica count, or by more;
|
||||
- replicas disagree on the replica count;
|
||||
- no sequence range is common to every replica, so nothing can be checked.
|
||||
|
||||
Sequences past the slowest replica's last one are not compared. Digests are
|
||||
taken from live stores a few seconds apart, so re-run before trusting a
|
||||
failure that could be a write landing between two copies.
|
||||
|
||||
Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runKVCompare,
|
||||
}
|
||||
|
||||
func init() {
|
||||
kvCmd.AddCommand(kvCompareCmd)
|
||||
}
|
||||
|
||||
func runKVCompare(cmd *cobra.Command, args []string) {
|
||||
var nodes []nodeDigest
|
||||
for _, arg := range args {
|
||||
var r io.Reader
|
||||
if arg == "-" {
|
||||
r = cmd.InOrStdin()
|
||||
} else {
|
||||
f, err := os.Open(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
got, err := loadNodeDigests(r, arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
nodes = append(nodes, got...)
|
||||
}
|
||||
res, err := compareNodeDigests(nodes)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
writeCompareResult(cmd.OutOrStdout(), nodes, res)
|
||||
if res.divergent() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadNodeDigests reads every JSON digest document in r.
|
||||
func loadNodeDigests(r io.Reader, source string) ([]nodeDigest, error) {
|
||||
dec := json.NewDecoder(r)
|
||||
var out []nodeDigest
|
||||
for {
|
||||
var d nodeDigest
|
||||
err := dec.Decode(&d)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: document %d: %w", source, len(out)+1, err)
|
||||
}
|
||||
if d.Host == "" {
|
||||
return nil, fmt.Errorf("%s: document %d has no host; is it \"kv digest --json\" output?", source, len(out)+1)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s: no digest documents", source)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxFindingsPerStream caps what one stream prints; the rest are counted.
|
||||
const maxFindingsPerStream = 5
|
||||
|
||||
type streamVerdict struct {
|
||||
Name string
|
||||
Holders []string
|
||||
Problems []string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
type kvCompareResult struct {
|
||||
Streams []streamVerdict
|
||||
}
|
||||
|
||||
func (r kvCompareResult) divergent() bool {
|
||||
return slices.ContainsFunc(r.Streams, func(v streamVerdict) bool { return len(v.Problems) > 0 })
|
||||
}
|
||||
|
||||
// replicaView is one node's copy of one stream, indexed for the per-sequence rules.
|
||||
type replicaView struct {
|
||||
host string
|
||||
copiedAt time.Time
|
||||
d *streamDigest
|
||||
bySeq map[uint64]seqDigest
|
||||
lastBySubject map[string]uint64
|
||||
}
|
||||
|
||||
func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) {
|
||||
if len(nodes) < 2 {
|
||||
return kvCompareResult{}, fmt.Errorf("need the digests of at least two nodes, got %d", len(nodes))
|
||||
}
|
||||
hosts := make([]string, 0, len(nodes))
|
||||
byName := map[string][]*replicaView{}
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if slices.Contains(hosts, n.Host) {
|
||||
return kvCompareResult{}, fmt.Errorf("two digests are from host %s", n.Host)
|
||||
}
|
||||
hosts = append(hosts, n.Host)
|
||||
for j := range n.Streams {
|
||||
d := &n.Streams[j]
|
||||
v := &replicaView{host: n.Host, copiedAt: n.CopiedAt, d: d,
|
||||
bySeq: make(map[uint64]seqDigest, len(d.Seqs)), lastBySubject: map[string]uint64{}}
|
||||
for _, s := range d.Seqs {
|
||||
v.bySeq[s.Seq] = s
|
||||
if s.Hash != deletedHash && s.Seq > v.lastBySubject[s.Subject] {
|
||||
v.lastBySubject[s.Subject] = s.Seq
|
||||
}
|
||||
}
|
||||
byName[d.Name] = append(byName[d.Name], v)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(byName))
|
||||
for name := range byName {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var res kvCompareResult
|
||||
for _, name := range names {
|
||||
res.Streams = append(res.Streams, compareStream(name, hosts, byName[name]))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func compareStream(name string, hosts []string, views []*replicaView) streamVerdict {
|
||||
v := streamVerdict{Name: name}
|
||||
for _, r := range views {
|
||||
v.Holders = append(v.Holders, r.host)
|
||||
}
|
||||
|
||||
counts := map[int][]string{}
|
||||
replicas := 0
|
||||
for _, r := range views {
|
||||
if n := r.d.Replicas; n > 0 {
|
||||
counts[n] = append(counts[n], r.host)
|
||||
replicas = max(replicas, n)
|
||||
}
|
||||
}
|
||||
if len(counts) > 1 {
|
||||
var parts []string
|
||||
for n, hs := range counts {
|
||||
parts = append(parts, fmt.Sprintf("%d on %s", n, strings.Join(hs, ",")))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
v.Problems = append(v.Problems, "replicas disagree on the replica count: "+strings.Join(parts, "; "))
|
||||
}
|
||||
switch {
|
||||
case replicas > 0 && len(views) < replicas:
|
||||
missing := slices.DeleteFunc(slices.Clone(hosts), func(h string) bool { return slices.Contains(v.Holders, h) })
|
||||
msg := fmt.Sprintf("held by %d of %d replicas; missing from %s", len(views), replicas, strings.Join(missing, ","))
|
||||
if len(hosts) < replicas {
|
||||
msg += fmt.Sprintf(" (only %d digests given)", len(hosts))
|
||||
}
|
||||
v.Problems = append(v.Problems, msg)
|
||||
case replicas > 0 && len(views) > replicas:
|
||||
v.Problems = append(v.Problems, fmt.Sprintf("held by %d nodes but configured for %d replicas; a node outside the stream's group still holds a copy", len(views), replicas))
|
||||
}
|
||||
if len(views) < 2 {
|
||||
v.Notes = append(v.Notes, "single copy, nothing to compare")
|
||||
return v
|
||||
}
|
||||
|
||||
identical := true
|
||||
for _, r := range views[1:] {
|
||||
if r.d.Digest != views[0].d.Digest || r.d.FirstSeq != views[0].d.FirstSeq || r.d.LastSeq != views[0].d.LastSeq {
|
||||
identical = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if identical {
|
||||
v.Notes = append(v.Notes, fmt.Sprintf("identical, seqs %d-%d", views[0].d.FirstSeq, views[0].d.LastSeq))
|
||||
return v
|
||||
}
|
||||
for _, r := range views {
|
||||
if r.d.Msgs > 0 && len(r.d.Seqs) == 0 {
|
||||
v.Problems = append(v.Problems, "contents differ and "+r.host+"'s digest has no per-sequence detail; re-run digest with --seqs")
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
lo, hi := views[0].d.FirstSeq, views[0].d.LastSeq
|
||||
seqSet := map[uint64]struct{}{}
|
||||
for _, r := range views {
|
||||
lo, hi = max(lo, r.d.FirstSeq), min(hi, r.d.LastSeq)
|
||||
for s := range r.bySeq {
|
||||
seqSet[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
v.Problems = append(v.Problems, "no sequence range is common to every replica, so nothing could be compared")
|
||||
return v
|
||||
}
|
||||
seqs := make([]uint64, 0, len(seqSet))
|
||||
for s := range seqSet {
|
||||
if s <= hi {
|
||||
seqs = append(seqs, s)
|
||||
}
|
||||
}
|
||||
slices.Sort(seqs)
|
||||
|
||||
var problems []string
|
||||
explained := 0
|
||||
for _, seq := range seqs {
|
||||
var present, absent []*replicaView
|
||||
for _, r := range views {
|
||||
if e, ok := r.bySeq[seq]; ok && e.Hash != deletedHash {
|
||||
present = append(present, r)
|
||||
} else {
|
||||
absent = append(absent, r)
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
continue
|
||||
}
|
||||
byHash := map[string][]string{}
|
||||
for _, r := range present {
|
||||
h := r.bySeq[seq].Hash
|
||||
byHash[h] = append(byHash[h], r.host)
|
||||
}
|
||||
if len(byHash) > 1 {
|
||||
var parts []string
|
||||
for h, hs := range byHash {
|
||||
parts = append(parts, strings.Join(hs, ",")+"="+h)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
problems = append(problems, fmt.Sprintf("seq %d %s holds different content: %s", seq, present[0].bySeq[seq].Subject, strings.Join(parts, " ")))
|
||||
continue
|
||||
}
|
||||
msg := present[0].bySeq[seq]
|
||||
for _, r := range absent {
|
||||
if absenceExplained(r, msg) {
|
||||
explained++
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("seq %d %s is on %s but missing from %s, with no later write to that subject, expiry or limit to explain it",
|
||||
seq, msg.Subject, hostsOf(present), r.host))
|
||||
}
|
||||
}
|
||||
if len(problems) > maxFindingsPerStream {
|
||||
problems = append(problems[:maxFindingsPerStream], fmt.Sprintf("... and %d more", len(problems)-maxFindingsPerStream))
|
||||
}
|
||||
v.Problems = append(v.Problems, problems...)
|
||||
|
||||
note := fmt.Sprintf("seqs %d-%d compared", lo, hi)
|
||||
if explained > 0 {
|
||||
note += fmt.Sprintf(", %d superseded or expired messages missing from some replicas", explained)
|
||||
}
|
||||
var ahead []string
|
||||
for _, r := range views {
|
||||
if r.d.LastSeq > hi {
|
||||
ahead = append(ahead, fmt.Sprintf("%s +%d", r.host, r.d.LastSeq-hi))
|
||||
}
|
||||
}
|
||||
if len(ahead) > 0 {
|
||||
note += ", tail not compared: " + strings.Join(ahead, " ")
|
||||
}
|
||||
v.Notes = append(v.Notes, note)
|
||||
return v
|
||||
}
|
||||
|
||||
// absenceExplained reports whether a replica lacking msg removed it legitimately:
|
||||
// the subject was written again later, the message aged out before that replica
|
||||
// was copied, or a stream limit dropped it from a head the replica has moved past.
|
||||
func absenceExplained(r *replicaView, msg seqDigest) bool {
|
||||
if msg.Subject == "" {
|
||||
return false
|
||||
}
|
||||
if r.lastBySubject[msg.Subject] > msg.Seq {
|
||||
return true
|
||||
}
|
||||
meta := r.d.streamMeta
|
||||
if meta.Retention != "" && meta.Retention != "limits" {
|
||||
return true
|
||||
}
|
||||
if !msg.Time.IsZero() {
|
||||
if meta.MaxAge > 0 && msg.Time.Add(meta.MaxAge).Before(r.copiedAt) {
|
||||
return true
|
||||
}
|
||||
if msg.TTL > 0 && msg.Time.Add(msg.TTL).Before(r.copiedAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return msg.Seq < r.d.FirstSeq && (meta.MaxMsgs > 0 || meta.MaxBytes > 0)
|
||||
}
|
||||
|
||||
func hostsOf(views []*replicaView) string {
|
||||
hs := make([]string, len(views))
|
||||
for i, r := range views {
|
||||
hs[i] = r.host
|
||||
}
|
||||
return strings.Join(hs, ",")
|
||||
}
|
||||
|
||||
func writeCompareResult(w io.Writer, nodes []nodeDigest, res kvCompareResult) {
|
||||
fmt.Fprintf(w, "compared %d digests:", len(nodes))
|
||||
for _, n := range nodes {
|
||||
fmt.Fprintf(w, " %s@%s", n.Host, n.CopiedAt.Format(time.TimeOnly))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
bad := 0
|
||||
for _, v := range res.Streams {
|
||||
status := "ok"
|
||||
if len(v.Problems) > 0 {
|
||||
status = "DIVERGED"
|
||||
bad++
|
||||
}
|
||||
fmt.Fprintf(w, "%-9s %s [%s]\n", status, v.Name, strings.Join(v.Holders, ","))
|
||||
for _, p := range v.Problems {
|
||||
fmt.Fprintf(w, " ! %s\n", p)
|
||||
}
|
||||
for _, n := range v.Notes {
|
||||
fmt.Fprintf(w, " %s\n", n)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%d streams: %d consistent, %d diverged\n", len(res.Streams), len(res.Streams)-bad, bad)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
type msg struct {
|
||||
seq uint64
|
||||
subject string
|
||||
hash string // "deleted" for a gap
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
name string
|
||||
replicas int
|
||||
maxAge time.Duration
|
||||
maxMsgs int64
|
||||
msgs []msg
|
||||
noSeqs bool
|
||||
}
|
||||
|
||||
// doc renders one node's digest the way "kv digest --json --seqs" prints it.
|
||||
func doc(t *testing.T, host string, copiedAt time.Time, streams ...stream) string {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
for _, s := range streams {
|
||||
var seqs []map[string]any
|
||||
var first, last, count uint64
|
||||
digest := host + ":" // only equality matters to compare
|
||||
for _, m := range s.msgs {
|
||||
if first == 0 {
|
||||
first = m.seq
|
||||
}
|
||||
last = m.seq
|
||||
e := map[string]any{"seq": m.seq, "hash": m.hash}
|
||||
if m.hash != "deleted" {
|
||||
count++
|
||||
e["subject"] = m.subject
|
||||
at := m.at
|
||||
if at.IsZero() {
|
||||
at = t0
|
||||
}
|
||||
e["time"] = at
|
||||
}
|
||||
digest += m.hash + ","
|
||||
seqs = append(seqs, e)
|
||||
}
|
||||
sd := map[string]any{"name": s.name, "first_seq": first, "last_seq": last, "msgs": count, "sha256": digest}
|
||||
if s.replicas > 0 {
|
||||
sd["replicas"] = s.replicas
|
||||
}
|
||||
if s.maxAge > 0 {
|
||||
sd["max_age_ns"] = s.maxAge
|
||||
}
|
||||
if s.maxMsgs > 0 {
|
||||
sd["max_msgs"] = s.maxMsgs
|
||||
}
|
||||
if !s.noSeqs {
|
||||
sd["seqs"] = seqs
|
||||
}
|
||||
out = append(out, sd)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"host": host, "store": "/s", "copied_at": copiedAt, "streams": out})
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func kvs(msgs ...msg) []msg { return msgs }
|
||||
|
||||
func TestKVCompareIdentical(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
|
||||
// The digest field embeds the host, so this exercises the per-seq path too.
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, s))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "1 streams: 1 consistent, 0 diverged")
|
||||
}
|
||||
|
||||
// The prod failure: each node wrote its own copy before formation, so the same
|
||||
// sequence holds different content on different replicas.
|
||||
func TestKVCompareSameSeqDifferentContent(t *testing.T) {
|
||||
a := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "aaaa", t0})}
|
||||
b := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "bbbb", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "node1", t0, a), doc(t, "node2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "DIVERGED KV_acct")
|
||||
assert.Contains(t, report, "seq 1 $KV.acct.000000000002 holds different content: node1=aaaa node2=bbbb")
|
||||
}
|
||||
|
||||
func TestKVCompareMissingMessage(t *testing.T) {
|
||||
full := kvs(msg{1, "k", "h1", t0}, msg{2, "j", "h2", t0}, msg{3, "k", "h3", t0})
|
||||
|
||||
t.Run("superseded by a later write to the subject", func(t *testing.T) {
|
||||
lagging := stream{name: "KV_a", replicas: 2, msgs: full}
|
||||
purged := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "", "deleted", time.Time{}}, full[1], full[2])}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, lagging), doc(t, "n2", t0, purged))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "1 superseded or expired")
|
||||
})
|
||||
|
||||
t.Run("nothing explains it", func(t *testing.T) {
|
||||
have := stream{name: "KV_a", replicas: 2, msgs: full}
|
||||
lost := stream{name: "KV_a", replicas: 2, msgs: kvs(full[0], msg{2, "", "deleted", time.Time{}}, full[2])}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, lost))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "seq 2 j is on n1 but missing from n2")
|
||||
})
|
||||
|
||||
t.Run("aged out under max_age before the replica was copied", func(t *testing.T) {
|
||||
old := t0.Add(-time.Hour)
|
||||
have := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{1, "l", "h1", old}, msg{2, "m", "h2", t0})}
|
||||
gone := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{2, "m", "h2", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
|
||||
have.maxAge, gone.maxAge = 0, 0
|
||||
divergent, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent, "without max_age the same gap is unexplained")
|
||||
})
|
||||
|
||||
t.Run("head dropped by a stream limit", func(t *testing.T) {
|
||||
behind := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{1, "a", "h1", t0}, msg{2, "b", "h2", t0})}
|
||||
ahead := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{2, "b", "h2", t0}, msg{3, "c", "h3", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKVCompareTailLag(t *testing.T) {
|
||||
behind := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
|
||||
ahead := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0}, msg{3, "k", "h3", t0}, msg{4, "k", "h4", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "tail not compared: n2 +2")
|
||||
}
|
||||
|
||||
func TestKVCompareNoCommonRange(t *testing.T) {
|
||||
a := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
b := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{5, "k", "h5", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "no sequence range is common")
|
||||
}
|
||||
|
||||
func TestKVCompareReplicaPlacement(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
other := stream{name: "KV_b", replicas: 1, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
|
||||
t.Run("fewer holders than replicas", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, other))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "held by 2 of 3 replicas; missing from n3")
|
||||
})
|
||||
|
||||
t.Run("more holders than replicas", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0, other))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "held by 2 nodes but configured for 1 replicas")
|
||||
})
|
||||
|
||||
t.Run("replica counts disagree", func(t *testing.T) {
|
||||
r1 := s
|
||||
r1.replicas = 1
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, r1))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "replicas disagree on the replica count")
|
||||
})
|
||||
|
||||
t.Run("single replica stream on one node", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKVCompareNeedsSeqs(t *testing.T) {
|
||||
a := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
b := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h2", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "re-run digest with --seqs")
|
||||
}
|
||||
|
||||
func TestKVCompareInput(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
|
||||
// Several documents in one stream, as "-" reads them from stdin.
|
||||
divergent, _, err := cmd.CompareKVDigests(doc(t, "n1", t0, s) + "\n" + doc(t, "n2", t0, s))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent)
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s))
|
||||
assert.ErrorContains(t, err, "at least two nodes")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n1", t0, s))
|
||||
assert.ErrorContains(t, err, "two digests are from host n1")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(`KV_a first=1 last=1`)
|
||||
assert.Error(t, err, "text digest output is rejected")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(`{"streams":[]}`)
|
||||
assert.ErrorContains(t, err, "no host")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(" ")
|
||||
assert.ErrorContains(t, err, "no digest documents")
|
||||
}
|
||||
|
||||
func TestAdminInitDiscardsJetStreamByDefault(t *testing.T) {
|
||||
assert.Equal(t, "true", cmd.AdminInitFlagDefault("discard-jetstream"))
|
||||
assert.True(t, strings.HasPrefix(cmd.AdminInitFlagDefault("ipsec"), "true"))
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//test:in-package: exercises the unexported digest helpers against a real clustered
|
||||
// store, which the cobra command only reaches through a live node.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// kvServer runs a standalone JetStream server on storeParent and returns a KV
|
||||
// bucket on it plus a stop function.
|
||||
func kvServer(t *testing.T, storeParent, bucket string) (jetstream.KeyValue, func()) {
|
||||
t.Helper()
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
ServerName: "kv-test", JetStream: true, StoreDir: storeParent,
|
||||
DontListen: true, NoLog: true, NoSigs: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(10*time.Second))
|
||||
nc, err := nats.Connect("", nats.InProcessServer(ns))
|
||||
require.NoError(t, err)
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: bucket, History: 5})
|
||||
require.NoError(t, err)
|
||||
return kv, func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func digestOf(t *testing.T, storeDir string, withSeqs bool) []streamDigest {
|
||||
t.Helper()
|
||||
work := t.TempDir()
|
||||
_, metas, err := copyStreamTree(storeDir, filepath.Join(work, "jetstream"))
|
||||
require.NoError(t, err)
|
||||
d, err := digestStore(context.Background(), work, nil, withSeqs, metas)
|
||||
require.NoError(t, err)
|
||||
return d
|
||||
}
|
||||
|
||||
func TestKVDigest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
parent := t.TempDir()
|
||||
kv, stop := kvServer(t, parent, "test")
|
||||
for _, kvp := range [][2]string{{"a", "1"}, {"b", "2"}, {"a", "3"}} {
|
||||
_, err := kv.PutString(ctx, kvp[0], kvp[1])
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, kv.Delete(ctx, "b"))
|
||||
store := filepath.Join(parent, "jetstream")
|
||||
|
||||
// Copied while the server is live, exactly as it runs on a node.
|
||||
live := digestOf(t, store, true)
|
||||
stop()
|
||||
|
||||
require.Len(t, live, 1)
|
||||
assert.Equal(t, "KV_test", live[0].Name)
|
||||
assert.Equal(t, uint64(1), live[0].FirstSeq)
|
||||
assert.Equal(t, uint64(4), live[0].LastSeq)
|
||||
assert.Equal(t, uint64(4), live[0].Msgs)
|
||||
require.Len(t, live[0].Seqs, 4)
|
||||
|
||||
t.Run("stable across reads and a stopped server", func(t *testing.T) {
|
||||
again := digestOf(t, store, false)
|
||||
assert.Equal(t, live[0].Digest, again[0].Digest)
|
||||
})
|
||||
|
||||
t.Run("source store is left untouched", func(t *testing.T) {
|
||||
_, err := os.Stat(filepath.Join(store, "$G", "streams", "KV_test"))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
// Same keys and values written separately differ in timestamps, which is
|
||||
// exactly what separates two nodes' pre-formation writes.
|
||||
t.Run("independently written store differs", func(t *testing.T) {
|
||||
other := t.TempDir()
|
||||
kv2, stop2 := kvServer(t, other, "test")
|
||||
for _, kvp := range [][2]string{{"a", "1"}, {"b", "2"}, {"a", "3"}} {
|
||||
_, err := kv2.PutString(ctx, kvp[0], kvp[1])
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, kv2.Delete(ctx, "b"))
|
||||
stop2()
|
||||
d := digestOf(t, filepath.Join(other, "jetstream"), true)
|
||||
assert.NotEqual(t, live[0].Digest, d[0].Digest)
|
||||
|
||||
// Two stores adopted as replicas of one stream: same subjects and
|
||||
// sequences, different content, which compare must call divergent.
|
||||
res, err := compareNodeDigests([]nodeDigest{
|
||||
{Host: "node1", CopiedAt: time.Now(), Streams: live},
|
||||
{Host: "node2", CopiedAt: time.Now(), Streams: d},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.divergent())
|
||||
require.Len(t, res.Streams, 1)
|
||||
assert.Contains(t, strings.Join(res.Streams[0].Problems, "\n"), "seq 1 $KV.test.a holds different content")
|
||||
})
|
||||
|
||||
t.Run("report lines", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDigests(&buf, "node1", store, time.Unix(0, 0), []string{"ACC/S"}, live)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "# host=node1 ")
|
||||
assert.Contains(t, out, "# skipped account stream (not read): ACC/S")
|
||||
assert.Contains(t, out, "KV_test\tfirst=1\tlast=4\tmsgs=4\tsha256="+live[0].Digest)
|
||||
assert.Equal(t, 4, strings.Count(out, "KV_test\tseq="))
|
||||
})
|
||||
}
|
||||
|
||||
func TestKVDigestStreamFilterAndDeletedSeq(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
parent := t.TempDir()
|
||||
// The KV bucket is only there to be excluded by the stream filter.
|
||||
_, stop := kvServer(t, parent, "other")
|
||||
stop()
|
||||
|
||||
// KV streams deny message deletes, so an interior gap needs a plain stream.
|
||||
ns, err := server.NewServer(&server.Options{JetStream: true, StoreDir: parent, DontListen: true, NoLog: true, NoSigs: true})
|
||||
require.NoError(t, err)
|
||||
ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(10*time.Second))
|
||||
nc, err := nats.Connect("", nats.InProcessServer(ns))
|
||||
require.NoError(t, err)
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
s, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ONE", Subjects: []string{"one.>"}})
|
||||
require.NoError(t, err)
|
||||
for _, v := range []string{"v1", "v2", "v3"} {
|
||||
_, err = js.Publish(ctx, "one.k", []byte(v))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, s.DeleteMsg(ctx, 2))
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
|
||||
work := t.TempDir()
|
||||
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
|
||||
require.NoError(t, err)
|
||||
d, err := digestStore(ctx, work, []string{"ONE"}, true, metas)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 1)
|
||||
assert.Equal(t, "ONE", d[0].Name)
|
||||
assert.Equal(t, uint64(2), d[0].Msgs)
|
||||
require.Len(t, d[0].Seqs, 3)
|
||||
assert.Equal(t, seqDigest{Seq: 2, Hash: "deleted"}, d[0].Seqs[1])
|
||||
for _, i := range []int{0, 2} {
|
||||
assert.Equal(t, "one.k", d[0].Seqs[i].Subject)
|
||||
assert.False(t, d[0].Seqs[i].Time.IsZero())
|
||||
}
|
||||
assert.Equal(t, "limits", d[0].Retention)
|
||||
assert.Equal(t, 1, d[0].Replicas)
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// A replicated stream's meta.inf carries num_replicas > 1, which a standalone
|
||||
// server refuses to load; this is the case every production node presents.
|
||||
func TestKVDigestClusteredReplicas(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const n = 3
|
||||
ports := make([]int, n)
|
||||
routes := make([]string, n)
|
||||
for i := range ports {
|
||||
ports[i] = freePort(t)
|
||||
routes[i] = fmt.Sprintf("nats://127.0.0.1:%d", ports[i])
|
||||
}
|
||||
stores := make([]string, n)
|
||||
servers := make([]*server.Server, n)
|
||||
for i := range servers {
|
||||
stores[i] = t.TempDir()
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
ServerName: fmt.Sprintf("s%d", i+1), JetStream: true, StoreDir: stores[i],
|
||||
Host: "127.0.0.1", Port: -1, NoLog: true, NoSigs: true,
|
||||
Cluster: server.ClusterOpts{Name: "digest", Host: "127.0.0.1", Port: ports[i]},
|
||||
Routes: server.RoutesFromStr(strings.Join(routes, ",")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ns.Start()
|
||||
servers[i] = ns
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, ns := range servers {
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}
|
||||
})
|
||||
|
||||
nc, err := nats.Connect("", nats.InProcessServer(servers[0]))
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
var kv jetstream.KeyValue
|
||||
require.Eventually(t, func() bool {
|
||||
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
|
||||
return err == nil
|
||||
}, 30*time.Second, 250*time.Millisecond, "clustered KV create: %v", err)
|
||||
for i := range 5 {
|
||||
_, err := kv.PutString(ctx, fmt.Sprintf("k%d", i%2), fmt.Sprintf("v%d", i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
s, err := js.Stream(ctx, "KV_rep")
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
info, err := s.Info(ctx)
|
||||
if err != nil || info.Cluster == nil || len(info.Cluster.Replicas) != n-1 {
|
||||
return false
|
||||
}
|
||||
for _, r := range info.Cluster.Replicas {
|
||||
if !r.Current || r.Lag != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, 30*time.Second, 250*time.Millisecond)
|
||||
|
||||
// Current means applied, not flushed; shutdown flushes every block to disk.
|
||||
nc.Close()
|
||||
for _, ns := range servers {
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}
|
||||
|
||||
var first string
|
||||
var nodes []nodeDigest
|
||||
for i, store := range stores {
|
||||
d := digestOf(t, filepath.Join(store, "jetstream"), true)
|
||||
require.Len(t, d, 1, "replica %d", i+1)
|
||||
assert.Equal(t, uint64(5), d[0].Msgs, "replica %d", i+1)
|
||||
assert.Equal(t, n, d[0].Replicas, "live replica count survives the standalone rewrite")
|
||||
assert.Equal(t, time.Hour, d[0].MaxAge)
|
||||
nodes = append(nodes, nodeDigest{Host: fmt.Sprintf("s%d", i+1), CopiedAt: time.Now(), Streams: d})
|
||||
if i == 0 {
|
||||
first = d[0].Digest
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, first, d[0].Digest, "replica %d matches replica 1", i+1)
|
||||
}
|
||||
res, err := compareNodeDigests(nodes)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.divergent(), "%+v", res)
|
||||
}
|
||||
|
||||
// Recovery applies MaxAge, so without the rewrite an expired bucket copy
|
||||
// would read as empty and mask whatever the replica holds.
|
||||
func TestKVDigestIgnoresExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
parent := t.TempDir()
|
||||
ns, err := server.NewServer(&server.Options{JetStream: true, StoreDir: parent, DontListen: true, NoLog: true, NoSigs: true})
|
||||
require.NoError(t, err)
|
||||
ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(10*time.Second))
|
||||
nc, err := nats.Connect("", nats.InProcessServer(ns))
|
||||
require.NoError(t, err)
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: time.Second})
|
||||
require.NoError(t, err)
|
||||
_, err = kv.PutString(ctx, "k", "v")
|
||||
require.NoError(t, err)
|
||||
|
||||
work := t.TempDir()
|
||||
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
|
||||
require.NoError(t, err)
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
d, err := digestStore(ctx, work, nil, false, metas)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 1)
|
||||
assert.Equal(t, uint64(1), d[0].Msgs)
|
||||
}
|
||||
|
||||
func TestCopyStreamTree(t *testing.T) {
|
||||
store := seedStore(t, "KV_a")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(store, "$G", "streams", "KV_a", "obs", "c1"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(store, "$G", "streams", "KV_a", "obs", "c1", "o.dat"), []byte("x"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(store, "ACME", "streams", "ORDERS"), 0o755))
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "jetstream")
|
||||
skipped, _, err := copyStreamTree(store, dst)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"ACME/ORDERS"}, skipped)
|
||||
|
||||
_, err = os.Stat(filepath.Join(dst, "$G", "streams", "KV_a", "msgs", "1.blk"))
|
||||
assert.NoError(t, err, "message blocks are copied")
|
||||
_, err = os.Stat(filepath.Join(dst, "$G", "streams", "KV_a", "obs"))
|
||||
assert.True(t, os.IsNotExist(err), "consumer state is left out")
|
||||
|
||||
_, _, err = copyStreamTree(filepath.Join(t.TempDir(), "absent"), dst)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -16,13 +16,14 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/bluebottle/pkg/masterkey"
|
||||
"github.com/mulgadc/bluebottle/pkg/safecast"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
|
||||
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway/bedrock/hfhub"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -350,6 +351,17 @@ func pullFilesToPrefix(ctx context.Context, hf *hfhub.Client, store objectstore.
|
||||
return uploaded, nil
|
||||
}
|
||||
|
||||
// weightsStagingRefusal builds the model-ID refusal error weights staging and
|
||||
// pulling share. Unknown IDs and provider-served IDs get distinct wording so
|
||||
// a caller can tell which one it hit; taking found/selfHost as plain bools
|
||||
// keeps this testable without a real catalog entry for the provider case.
|
||||
func weightsStagingRefusal(modelID, operation string, found, _ bool) error {
|
||||
if !found {
|
||||
return fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
|
||||
}
|
||||
return fmt.Errorf("%q is a provider-served model, not self-host; weights %s does not apply", modelID, operation)
|
||||
}
|
||||
|
||||
// runPullWeights holds 'ochre weights pull' decision and side-effect logic:
|
||||
// catalog validation, ref resolution to an immutable commit SHA (D3), tree
|
||||
// listing and safetensors-only filtering (D4), streaming each selected file
|
||||
@@ -359,10 +371,7 @@ func pullFilesToPrefix(ctx context.Context, hf *hfhub.Client, store objectstore.
|
||||
func runPullWeights(ctx context.Context, hf *hfhub.Client, store objectstore.ObjectStore, modelID, hfRepo, revision string, revisionExplicit bool, s3URIFlag string) (string, error) {
|
||||
spec, found, selfHost := gateway_bedrock.LookupServingSpec(modelID)
|
||||
if !found || !selfHost {
|
||||
if !found {
|
||||
return "", fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
|
||||
}
|
||||
return "", fmt.Errorf("%q is a provider-served model, not self-host; weights pull does not apply", modelID)
|
||||
return "", weightsStagingRefusal(modelID, "pull", found, selfHost)
|
||||
}
|
||||
|
||||
// The catalog carries the canonical repo and a pinned revision so a bare
|
||||
@@ -624,12 +633,12 @@ func buildWeightsImage(srcDir, imagePath string, contentBytes int64) error {
|
||||
// metadata.json sitting alongside them, so without this the endpoint launcher
|
||||
// cannot see the snapshot at all and fails with InvalidSnapshot.NotFound.
|
||||
func registerWeightsSnapshot(store objectstore.ObjectStore, bucket, snapshotID, volumeID string, volumeSize uint64, az string, encrypted bool) error {
|
||||
cfg := &handlers_ec2_snapshot.SnapshotConfig{
|
||||
cfg := ebsmetadata.Snapshot{
|
||||
SnapshotID: snapshotID,
|
||||
VolumeID: volumeID,
|
||||
// GiB, not bytes: CreateVolume compares this against a requested Size
|
||||
// already in GiB, and would reject every clone if handed raw bytes.
|
||||
VolumeSize: utils.SafeUint64ToInt64(volumeSize / bytesPerGiB),
|
||||
VolumeSize: safecast.Uint64ToInt64(volumeSize / bytesPerGiB),
|
||||
State: "completed",
|
||||
Progress: "100%",
|
||||
StartTime: time.Now(),
|
||||
@@ -638,7 +647,7 @@ func registerWeightsSnapshot(store objectstore.ObjectStore, bucket, snapshotID,
|
||||
OwnerID: utils.GlobalAccountID,
|
||||
AvailabilityZone: az,
|
||||
}
|
||||
if err := handlers_ec2_snapshot.WriteSnapshotConfig(store, bucket, snapshotID, cfg); err != nil {
|
||||
if err := ebsmetadata.NewStore(store, bucket).PutSnapshot(context.Background(), cfg); err != nil {
|
||||
return fmt.Errorf("register snapshot metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -666,10 +675,7 @@ type weightsSnapshotChecker func(ctx context.Context, snapshotID string) (bool,
|
||||
// fake materializer.
|
||||
func runStageWeights(ctx context.Context, store objectstore.ObjectStore, weightsStore *gateway_bedrock.WeightsStore, tmpDirFlag, modelID, s3URI string, materialize weightsMaterializer, checkSnapshotLive weightsSnapshotChecker) (string, error) {
|
||||
if _, found, selfHost := gateway_bedrock.LookupServingSpec(modelID); !found || !selfHost {
|
||||
if !found {
|
||||
return "", fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
|
||||
}
|
||||
return "", fmt.Errorf("%q is a provider-served model, not self-host; weights staging does not apply", modelID)
|
||||
return "", weightsStagingRefusal(modelID, "staging", found, selfHost)
|
||||
}
|
||||
|
||||
bucket, prefix, err := parseWeightsS3URI(s3URI)
|
||||
@@ -779,7 +785,7 @@ func materializeWeightsVolume(ctx context.Context, provider ebsprovider.EBSProvi
|
||||
if err := admin.ImportImage(ctx, provider, admin.ImportOpts{
|
||||
VolumeID: volumeId,
|
||||
NodeID: nodeID,
|
||||
SizeBytes: utils.SafeUint64ToInt64(volumeBytes),
|
||||
SizeBytes: safecast.Uint64ToInt64(volumeBytes),
|
||||
AvailabilityZone: node.AZ,
|
||||
SourcePath: imagePath,
|
||||
Snapshot: true,
|
||||
@@ -799,13 +805,14 @@ func materializeWeightsVolume(ctx context.Context, provider ebsprovider.EBSProvi
|
||||
}
|
||||
|
||||
// newWeightsSnapshotChecker is the real weightsSnapshotChecker: it reads the
|
||||
// EC2 control plane's metadata.json for snapshotID from the node's
|
||||
// predastore bucket -- the same record registerWeightsSnapshot writes at
|
||||
// stage time -- and treats a missing object as gone. Any other read failure
|
||||
// is a real error, not a signal to guess the snapshot's existence.
|
||||
// EC2 control plane's snapshot document for snapshotID under the global
|
||||
// account -- the same record registerWeightsSnapshot writes at stage time --
|
||||
// and treats a missing object as gone. Any other read failure is a real
|
||||
// error, not a signal to guess the snapshot's existence.
|
||||
func newWeightsSnapshotChecker(store objectstore.ObjectStore, bucket string) weightsSnapshotChecker {
|
||||
metadata := ebsmetadata.NewStore(store, bucket)
|
||||
return func(ctx context.Context, snapshotID string) (bool, error) {
|
||||
if _, err := handlers_ec2_snapshot.ReadSnapshotConfig(ctx, store, bucket, snapshotID); err != nil {
|
||||
if _, err := metadata.GetSnapshot(ctx, utils.GlobalAccountID, snapshotID); err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -999,7 +1006,7 @@ func resolveHFToken(ctx context.Context, cmd *cobra.Command, cfg *config.Cluster
|
||||
}
|
||||
|
||||
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
|
||||
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
|
||||
masterKey, err := masterkey.ReadShared(masterKeyPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1071,7 +1078,7 @@ func ochreCredentialsStore() (*gateway_bedrock.CredentialStore, func(), error) {
|
||||
return nil, nil, fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
|
||||
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
|
||||
masterKey, err := masterkey.ReadShared(masterKeyPath)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("load master key: %w", err)
|
||||
|
||||
@@ -235,10 +235,12 @@ func TestRunPullWeights_UnknownModelRefused(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "not present in the Ochre catalog")
|
||||
}
|
||||
|
||||
// TestRunPullWeights_ProviderModelRefused covers the self-host gate: a
|
||||
// provider-served model must refuse before any network work, same as stage.
|
||||
func TestRunPullWeights_ProviderModelRefused(t *testing.T) {
|
||||
_, err := runPullWeights(context.Background(), deadHFClient(), explodingObjectStore{t: t}, providerModelID, testHFRepo, "main", true, "")
|
||||
// TestWeightsStagingRefusal_PullOperationWording covers the self-host gate's
|
||||
// message for the 'pull' operation. v1 ships no provider-tier catalog entry,
|
||||
// so this drives weightsStagingRefusal directly rather than through a real
|
||||
// LookupServingSpec lookup.
|
||||
func TestWeightsStagingRefusal_PullOperationWording(t *testing.T) {
|
||||
err := weightsStagingRefusal(providerModelID, "pull", true, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not self-host")
|
||||
}
|
||||
|
||||
@@ -15,20 +15,22 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// selfHostModelID and providerModelID are real catalog entries: the former
|
||||
// is self-host (weights staging applies), the latter is provider-served
|
||||
// (weights staging must refuse it), so tests exercise LookupServingSpec's
|
||||
// found/selfHost distinction against the real catalog rather than a stub.
|
||||
// selfHostModelID is a real catalog entry (weights staging applies).
|
||||
// providerModelID is an arbitrary ID not present in the catalog at all: v1
|
||||
// ships no provider-tier entry, so the found-but-not-selfHost case is
|
||||
// exercised directly against weightsStagingRefusal instead, not through a
|
||||
// real LookupServingSpec lookup.
|
||||
const (
|
||||
selfHostModelID = "meta.llama3-2-1b-instruct-v1:0"
|
||||
providerModelID = "anthropic.claude-3-5-sonnet-20240620-v1:0"
|
||||
@@ -404,24 +406,17 @@ func TestRunStageWeights_UnknownModelIDRefused(t *testing.T) {
|
||||
assert.Empty(t, entries, "refusal must not write a KV entry")
|
||||
}
|
||||
|
||||
// TestRunStageWeights_ProviderModelIDRefused covers refusal on a model ID
|
||||
// that exists in the catalog but is provider-served, not self-host. The
|
||||
// error must be distinguishable from the unknown-model-ID case, since
|
||||
// LookupServingSpec returns found and selfHost separately for exactly this
|
||||
// reason.
|
||||
func TestRunStageWeights_ProviderModelIDRefused(t *testing.T) {
|
||||
weightsStore := newWeightsStoreForTest(t)
|
||||
|
||||
_, err := runStageWeights(context.Background(), explodingObjectStore{t: t}, weightsStore, t.TempDir(),
|
||||
providerModelID, "s3://models/claude/", explodingMaterializer(t), explodingSnapshotChecker(t))
|
||||
// TestWeightsStagingRefusal_ProviderServedDistinguishesFromUnknown covers
|
||||
// refusal for a model ID that is found in the catalog but is provider-served,
|
||||
// not self-host. The error must be distinguishable from the unknown-model-ID
|
||||
// case; v1 ships no provider-tier entry, so this drives weightsStagingRefusal
|
||||
// directly with explicit found/selfHost values rather than a real lookup.
|
||||
func TestWeightsStagingRefusal_ProviderServedDistinguishesFromUnknown(t *testing.T) {
|
||||
err := weightsStagingRefusal(providerModelID, "staging", true, false)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "provider-served")
|
||||
assert.NotContains(t, err.Error(), "unknown model ID", "must be distinguishable from the unknown-model-ID refusal")
|
||||
|
||||
entries, listErr := weightsStore.ListWeights(context.Background())
|
||||
require.NoError(t, listErr)
|
||||
assert.Empty(t, entries, "refusal must not write a KV entry")
|
||||
}
|
||||
|
||||
// TestRunStageWeights_MissingRequiredFilesRefusedBeforeMaterializing covers
|
||||
@@ -1100,7 +1095,7 @@ func TestRegisterWeightsSnapshot_WritesEC2ReadableMetadata(t *testing.T) {
|
||||
require.NoError(t, registerWeightsSnapshot(store, bucket,
|
||||
"snap-vol-abc", "vol-abc", 12*bytesPerGiB, "ap-southeast-2a", true))
|
||||
|
||||
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, bucket, "snap-vol-abc")
|
||||
cfg, err := ebsmetadata.NewStore(store, bucket).GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-vol-abc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "snap-vol-abc", cfg.SnapshotID)
|
||||
assert.Equal(t, "vol-abc", cfg.VolumeID)
|
||||
@@ -1117,7 +1112,7 @@ func TestRegisterWeightsSnapshot_UnencryptedVolume(t *testing.T) {
|
||||
require.NoError(t, registerWeightsSnapshot(store, "predastore",
|
||||
"snap-vol-plain", "vol-plain", bytesPerGiB, "ap-southeast-2a", false))
|
||||
|
||||
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, "predastore", "snap-vol-plain")
|
||||
cfg, err := ebsmetadata.NewStore(store, "predastore").GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-vol-plain")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Encrypted)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type fakeVectorService struct {
|
||||
queryErr error
|
||||
listJobs []handlers_ochrevector.JobRecord
|
||||
listJobsErr error
|
||||
stopJob handlers_ochrevector.JobRecord
|
||||
stopJobErr error
|
||||
|
||||
createIndexInputs []*handlers_ochrevector.CreateIndexRequest
|
||||
deleteIndexInputs []*handlers_ochrevector.DeleteIndexRequest
|
||||
@@ -94,6 +96,17 @@ func (f *fakeVectorService) ListJobs(_ context.Context, _ *handlers_ochrevector.
|
||||
return &handlers_ochrevector.ListJobsResponse{Jobs: f.listJobs}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) StopJob(_ context.Context, req *handlers_ochrevector.StopJobRequest, _ string) (*handlers_ochrevector.StopJobResponse, error) {
|
||||
if f.stopJobErr != nil {
|
||||
return nil, f.stopJobErr
|
||||
}
|
||||
job := f.stopJob
|
||||
if job.ID == "" {
|
||||
job.ID = req.JobID
|
||||
}
|
||||
return &handlers_ochrevector.StopJobResponse{Job: job}, nil
|
||||
}
|
||||
|
||||
var _ handlers_ochrevector.VectorService = (*fakeVectorService)(nil)
|
||||
|
||||
const testIndexID = "idx-0123456789abcdef0"
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/northstar/pkg/backend"
|
||||
nsconfig "github.com/mulgadc/northstar/pkg/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
@@ -1286,6 +1289,9 @@ func TestAMIVolumeSizeGiB(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckJoinPreconditions(t *testing.T) {
|
||||
withFakeProc(t, nil)
|
||||
noStore := filepath.Join(t.TempDir(), "nats", "jetstream")
|
||||
|
||||
writeToml := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -1296,13 +1302,13 @@ func TestCheckJoinPreconditions(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("uninitialized node joins", func(t *testing.T) {
|
||||
if err := checkJoinPreconditions(t.TempDir(), false); err != nil {
|
||||
if err := checkJoinPreconditions(t.TempDir(), noStore, false); err != nil {
|
||||
t.Fatalf("want nil, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initialized node is refused", func(t *testing.T) {
|
||||
err := checkJoinPreconditions(writeToml(t), false)
|
||||
err := checkJoinPreconditions(writeToml(t), noStore, false)
|
||||
if err == nil {
|
||||
t.Fatal("want refusal, got nil")
|
||||
}
|
||||
@@ -1314,7 +1320,7 @@ func TestCheckJoinPreconditions(t *testing.T) {
|
||||
// The guidance is the whole point of the guard: an operator must be able to
|
||||
// tell what a forced join would destroy without reading the source.
|
||||
t.Run("guidance names what is discarded", func(t *testing.T) {
|
||||
for _, want := range []string{"CA certificate", "master key", "viperblock key", "--force"} {
|
||||
for _, want := range []string{"CA certificate", "master key", "viperblock key", "JetStream store", "--force"} {
|
||||
if !strings.Contains(joinDiscardsIdentityMsg, want) {
|
||||
t.Errorf("guidance missing %q", want)
|
||||
}
|
||||
@@ -1322,10 +1328,27 @@ func TestCheckJoinPreconditions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("force overrides", func(t *testing.T) {
|
||||
if err := checkJoinPreconditions(writeToml(t), true); err != nil {
|
||||
if err := checkJoinPreconditions(writeToml(t), noStore, true); err != nil {
|
||||
t.Fatalf("want nil with force, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// A store with streams and no config is still data the join would destroy.
|
||||
t.Run("uninitialized node with streams is refused", func(t *testing.T) {
|
||||
err := checkJoinPreconditions(t.TempDir(), seedStore(t, "KV_a"), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "JetStream store") {
|
||||
t.Fatalf("want JetStream refusal, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// --force accepts the loss of data, never a wipe under a live server.
|
||||
t.Run("running NATS is refused even with force", func(t *testing.T) {
|
||||
withFakeProc(t, map[string]string{"4242": "/usr/local/bin/spx\x00service\x00nats\x00start\x00"})
|
||||
err := checkJoinPreconditions(writeToml(t), seedStore(t, "KV_a"), true)
|
||||
if !errors.Is(err, errNATSRunning) {
|
||||
t.Fatalf("want errNATSRunning, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinRetryable(t *testing.T) {
|
||||
@@ -1358,3 +1381,58 @@ func TestJoinRetryable(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rendered resolver must refuse the internet and still serve the cluster.
|
||||
// Both halves matter: an open resolver is the bug, and a resolver that refuses
|
||||
// its own nodes silently stops internal names resolving.
|
||||
func TestGenerateAndWriteConfigs_NorthstarRecursionIsClosedButServesTheCluster(t *testing.T) {
|
||||
dirs, err := createConfigSubdirs(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
nsPath := filepath.Join(dirs.Northstar, "northstar.toml")
|
||||
|
||||
settings := multinodeNorthstarSettings(nsPath)
|
||||
settings.BindIP = "10.2.0.2"
|
||||
settings.AdvertiseIP = "72.52.77.228"
|
||||
settings.RemoteNodes = []admin.RemoteNode{
|
||||
{Name: "node2", Host: "72.52.77.229", NorthstarConfigPath: nsPath},
|
||||
{Name: "node3", Host: "72.52.77.230:4432", NorthstarConfigPath: nsPath},
|
||||
}
|
||||
|
||||
require.NoError(t, generateAndWriteConfigs(dirs, filepath.Join(t.TempDir(), "spinifex.toml"), settings, true))
|
||||
|
||||
raw, err := os.ReadFile(nsPath)
|
||||
require.NoError(t, err)
|
||||
rendered := string(raw)
|
||||
|
||||
assert.Contains(t, rendered, "allow_recursion = false",
|
||||
"a node bound to a public address must not be an open resolver")
|
||||
|
||||
// The node's own advertised address: resolv.conf points there, so the query
|
||||
// arrives from it over lo rather than from 127.0.0.1.
|
||||
assert.Contains(t, rendered, `"72.52.77.228"`)
|
||||
assert.Contains(t, rendered, `"10.2.0.2"`)
|
||||
// Peers, with any port stripped.
|
||||
assert.Contains(t, rendered, `"72.52.77.229"`)
|
||||
assert.Contains(t, rendered, `"72.52.77.230"`)
|
||||
assert.NotContains(t, rendered, "72.52.77.230:4432")
|
||||
|
||||
// It must parse as the config northstar will actually load.
|
||||
cfg, err := nsconfig.LoadServerConfig(nsPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Upstream.AllowRecursion)
|
||||
|
||||
prefixes, err := cfg.Upstream.ParseAllowRecursionFrom()
|
||||
require.NoError(t, err)
|
||||
policy := backend.NewRecursionPolicy(cfg.Upstream.AllowRecursion, prefixes)
|
||||
|
||||
// The cluster resolves.
|
||||
assert.True(t, policy.Allows(netip.MustParseAddr("72.52.77.228")), "node must resolve through itself")
|
||||
assert.True(t, policy.Allows(netip.MustParseAddr("72.52.77.229")), "peer node must resolve")
|
||||
assert.True(t, policy.Allows(netip.MustParseAddr("127.0.0.1")), "loopback is always allowed")
|
||||
// Guests reach northstar via vpcd's shim, which relays from a node address,
|
||||
// so a guest query is seen as one of the addresses asserted above.
|
||||
|
||||
// The internet does not.
|
||||
assert.False(t, policy.Allows(netip.MustParseAddr("198.51.100.7")))
|
||||
assert.False(t, policy.Allows(netip.MustParseAddr("8.8.8.8")))
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
@@ -146,3 +148,28 @@ var PrintAccountTable = printAccountTable
|
||||
|
||||
// AccountSummaries exposes accountSummaries for testing.
|
||||
var AccountSummaries = accountSummaries
|
||||
|
||||
// CompareKVDigests runs "kv compare" over digest JSON documents and returns
|
||||
// whether any stream diverged, plus the printed report.
|
||||
func CompareKVDigests(docs ...string) (bool, string, error) {
|
||||
var nodes []nodeDigest
|
||||
for i, doc := range docs {
|
||||
got, err := loadNodeDigests(strings.NewReader(doc), "doc"+strconv.Itoa(i))
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
nodes = append(nodes, got...)
|
||||
}
|
||||
res, err := compareNodeDigests(nodes)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeCompareResult(&buf, nodes, res)
|
||||
return res.divergent(), buf.String(), nil
|
||||
}
|
||||
|
||||
// AdminInitFlagDefault returns the registered default of an admin init flag.
|
||||
func AdminInitFlagDefault(name string) string {
|
||||
return adminInitCmd.Flags().Lookup(name).DefValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jetStreamStoreDir is where the NATS service keeps its JetStream data under a
|
||||
// node's data directory. The nats.conf template sets store_dir to
|
||||
// <data_dir>/nats/, and nats-server appends "jetstream" itself.
|
||||
func jetStreamStoreDir(dataDir string) string {
|
||||
return filepath.Join(dataDir, "nats", "jetstream")
|
||||
}
|
||||
|
||||
// localStreams lists the streams a JetStream store holds on disk, as
|
||||
// "<account>/<stream>". A missing store holds nothing.
|
||||
func localStreams(storeDir string) ([]string, error) {
|
||||
accounts, err := os.ReadDir(storeDir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JetStream store %s: %w", storeDir, err)
|
||||
}
|
||||
var streams []string
|
||||
for _, acc := range accounts {
|
||||
if !acc.IsDir() {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(storeDir, acc.Name(), "streams"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read streams for account %s: %w", acc.Name(), err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
streams = append(streams, acc.Name()+"/"+e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(streams)
|
||||
return streams, nil
|
||||
}
|
||||
|
||||
// procRoot is overridable in tests so the process scan can run against a fake
|
||||
// /proc tree.
|
||||
var procRoot = "/proc"
|
||||
|
||||
// natsRunningLocally reports whether this host runs the Spinifex NATS service,
|
||||
// found as a process whose command line is `spx service nats ...`. Removing a
|
||||
// store under a live server races JetStream, which rewrites its index files.
|
||||
func natsRunningLocally() (bool, error) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("scan processes: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.TrimLeft(e.Name(), "0123456789") != "" {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline"))
|
||||
if err != nil || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
if isSpxNATSService(bytes.Split(bytes.TrimRight(raw, "\x00"), []byte{0})) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isSpxNATSService matches `spx service nats` with any leading path on spx.
|
||||
func isSpxNATSService(argv [][]byte) bool {
|
||||
return len(argv) >= 3 &&
|
||||
filepath.Base(string(argv[0])) == "spx" &&
|
||||
string(argv[1]) == "service" &&
|
||||
string(argv[2]) == "nats"
|
||||
}
|
||||
|
||||
// errNATSRunning is returned whenever a store would be discarded with the NATS
|
||||
// service still up. No flag overrides it: the wipe would race the live server.
|
||||
var errNATSRunning = errors.New("the NATS service is still running on this node; stop it first (sudo systemctl stop spinifex.target, then confirm `pgrep -af 'spx service'` prints nothing)")
|
||||
|
||||
// checkStoreDiscardable fails if the NATS service is running locally.
|
||||
func checkStoreDiscardable() error {
|
||||
running, err := natsRunningLocally()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running {
|
||||
return errNATSRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardJetStreamStore removes a node's JetStream store before it enters a
|
||||
// multi-node cluster. nats-server adopts a same-named local stream and raft
|
||||
// catch-up never repairs the prefix it already holds.
|
||||
func discardJetStreamStore(storeDir string) error {
|
||||
if err := checkStoreDiscardable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(storeDir); err != nil {
|
||||
return fmt.Errorf("remove JetStream store %s: %w", storeDir, err)
|
||||
}
|
||||
if _, err := os.Stat(storeDir); !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("JetStream store %s still present after removal", storeDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkInitJetStreamStore refuses a multi-node init over a store holding streams
|
||||
// when discard was turned off. Keeping it as a seed is not safe: leadership
|
||||
// ignores who holds data, and unassigned local streams are deleted as orphans.
|
||||
func checkInitJetStreamStore(storeDir string, nodes int, discard bool) error {
|
||||
if nodes < 2 {
|
||||
return nil
|
||||
}
|
||||
streams, err := localStreams(storeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !discard {
|
||||
return fmt.Errorf("this node's JetStream store %s holds %d stream(s) from before formation; "+
|
||||
"a multi-node cluster cannot adopt them consistently. "+
|
||||
"re-run without --discard-jetstream=false to remove them once every node has joined", storeDir, len(streams))
|
||||
}
|
||||
return checkStoreDiscardable()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//test:in-package: swaps the unexported procRoot for a fake /proc tree and asserts on
|
||||
// the store helpers the init and join commands call.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withFakeProc points the process scan at a temp tree holding the given
|
||||
// pid -> cmdline entries (NUL-separated argv) for the duration of the test.
|
||||
func withFakeProc(t *testing.T, procs map[string]string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for pid, cmdline := range procs {
|
||||
dir := filepath.Join(root, pid)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
|
||||
}
|
||||
prev := procRoot
|
||||
procRoot = root
|
||||
t.Cleanup(func() { procRoot = prev })
|
||||
}
|
||||
|
||||
// seedStore builds a JetStream store with the named streams under account $G,
|
||||
// laid out the way nats-server writes it, and returns the store path.
|
||||
func seedStore(t *testing.T, streams ...string) string {
|
||||
t.Helper()
|
||||
store := filepath.Join(t.TempDir(), "nats", "jetstream")
|
||||
for _, s := range streams {
|
||||
dir := filepath.Join(store, "$G", "streams", s, "msgs")
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "1.blk"), []byte("x"), 0o600))
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestJetStreamStoreDir(t *testing.T) {
|
||||
assert.Equal(t, "/var/lib/spinifex/nats/jetstream", jetStreamStoreDir("/var/lib/spinifex"))
|
||||
}
|
||||
|
||||
func TestLocalStreams(t *testing.T) {
|
||||
t.Run("missing store holds nothing", func(t *testing.T) {
|
||||
got, err := localStreams(filepath.Join(t.TempDir(), "absent"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("lists streams across accounts, sorted", func(t *testing.T) {
|
||||
store := seedStore(t, "KV_b", "KV_a")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(store, "$SYS", "_js_", "_meta_"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(store, "$G", "streams", "stray-file"), nil, 0o600))
|
||||
|
||||
got, err := localStreams(store)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"$G/KV_a", "$G/KV_b"}, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNATSRunningLocally(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
procs map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"no processes", nil, false},
|
||||
{"installed service", map[string]string{"10": "/usr/local/bin/spx\x00service\x00nats\x00start\x00"}, true},
|
||||
{"bare spx on PATH", map[string]string{"10": "spx\x00service\x00nats\x00start\x00"}, true},
|
||||
// The join itself is an spx process and must not count.
|
||||
{"spx admin join", map[string]string{"10": "spx\x00admin\x00join\x00--force\x00"}, false},
|
||||
{"other spx service", map[string]string{"10": "spx\x00service\x00predastore\x00start\x00"}, false},
|
||||
{"nats-server binary", map[string]string{"10": "nats-server\x00service\x00nats\x00"}, false},
|
||||
{"kernel thread, empty cmdline", map[string]string{"2": ""}, false},
|
||||
{"non-pid entry ignored", map[string]string{"self": "spx\x00service\x00nats\x00"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withFakeProc(t, tt.procs)
|
||||
got, err := natsRunningLocally()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardJetStreamStore(t *testing.T) {
|
||||
t.Run("removes the store", func(t *testing.T) {
|
||||
withFakeProc(t, nil)
|
||||
store := seedStore(t, "KV_a")
|
||||
require.NoError(t, discardJetStreamStore(store))
|
||||
_, err := os.Stat(store)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
|
||||
t.Run("missing store is not an error", func(t *testing.T) {
|
||||
withFakeProc(t, nil)
|
||||
assert.NoError(t, discardJetStreamStore(filepath.Join(t.TempDir(), "absent")))
|
||||
})
|
||||
|
||||
t.Run("refuses under a running NATS and leaves the store", func(t *testing.T) {
|
||||
withFakeProc(t, map[string]string{"7": "spx\x00service\x00nats\x00start\x00"})
|
||||
store := seedStore(t, "KV_a")
|
||||
assert.ErrorIs(t, discardJetStreamStore(store), errNATSRunning)
|
||||
_, err := os.Stat(store)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckInitJetStreamStore(t *testing.T) {
|
||||
withFakeProc(t, nil)
|
||||
|
||||
t.Run("single-node init keeps its store", func(t *testing.T) {
|
||||
assert.NoError(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 1, false))
|
||||
})
|
||||
|
||||
t.Run("multi-node init over an empty store", func(t *testing.T) {
|
||||
assert.NoError(t, checkInitJetStreamStore(filepath.Join(t.TempDir(), "absent"), 3, false))
|
||||
})
|
||||
|
||||
t.Run("multi-node init over streams is refused without the flag", func(t *testing.T) {
|
||||
err := checkInitJetStreamStore(seedStore(t, "KV_a", "KV_b"), 4, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "2 stream(s)")
|
||||
assert.Contains(t, err.Error(), "--discard-jetstream")
|
||||
})
|
||||
|
||||
t.Run("multi-node init over streams proceeds with the flag", func(t *testing.T) {
|
||||
assert.NoError(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 4, true))
|
||||
})
|
||||
|
||||
t.Run("flag does not override a running NATS", func(t *testing.T) {
|
||||
withFakeProc(t, map[string]string{"7": "spx\x00service\x00nats\x00start\x00"})
|
||||
assert.ErrorIs(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 4, true), errNATSRunning)
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -29,7 +28,7 @@ func TestRegisterImportedAMISnapshot_WritesEC2ReadableMetadata(t *testing.T) {
|
||||
|
||||
require.NoError(t, registerImportedAMISnapshot(store, bucket, ami, "ap-southeast-2a", true))
|
||||
|
||||
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, bucket, "snap-ami-import01")
|
||||
cfg, err := ebsmetadata.NewStore(store, bucket).GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-ami-import01")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "snap-ami-import01", cfg.SnapshotID)
|
||||
assert.Equal(t, "ami-import01", cfg.VolumeID, "the import path creates the volume under the AMI's own ID")
|
||||
@@ -50,7 +49,7 @@ func TestRegisterImportedAMISnapshot_UnencryptedVolume(t *testing.T) {
|
||||
|
||||
require.NoError(t, registerImportedAMISnapshot(store, "predastore", ami, "ap-southeast-2a", false))
|
||||
|
||||
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, "predastore", "snap-ami-plain01")
|
||||
cfg, err := ebsmetadata.NewStore(store, "predastore").GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-ami-plain01")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Encrypted)
|
||||
}
|
||||
|
||||
@@ -386,6 +386,7 @@ var viperblockStartCmd = &cobra.Command{
|
||||
NodeName: clusterConfig.Node,
|
||||
ShardWAL: shardWAL,
|
||||
GCEnabled: gcEnabled,
|
||||
WALBaseDir: nodeConfig.Viperblock.WALBaseDir,
|
||||
EncryptionKeyFile: encryptionKeyFile,
|
||||
Threads: nodeConfig.EBS.ResolvedThreads(),
|
||||
CacheSizeMB: nodeConfig.EBS.ResolvedCacheSizeMB(),
|
||||
@@ -840,6 +841,7 @@ var spinifexUIStartCmd = &cobra.Command{
|
||||
tlsCert := viper.GetString("spinifex-ui-tls-cert")
|
||||
tlsKey := viper.GetString("spinifex-ui-tls-key")
|
||||
baseDir := viper.GetString("spinifex-ui-base-dir")
|
||||
ochreEnabled := viper.GetBool("spinifex-ui-ochre-enabled")
|
||||
|
||||
cfg, err := loadLocalConfig()
|
||||
if err != nil {
|
||||
@@ -853,12 +855,13 @@ var spinifexUIStartCmd = &cobra.Command{
|
||||
defer initTelemetry("spinifex-ui", false)()
|
||||
|
||||
svc, err := service.New("spinifex-ui", &spinifexui.Config{
|
||||
Port: port,
|
||||
Host: host,
|
||||
TLSCert: tlsCert,
|
||||
TLSKey: tlsKey,
|
||||
BaseDir: baseDir,
|
||||
Region: region,
|
||||
Port: port,
|
||||
Host: host,
|
||||
TLSCert: tlsCert,
|
||||
TLSKey: tlsKey,
|
||||
BaseDir: baseDir,
|
||||
Region: region,
|
||||
OchreEnabled: ochreEnabled,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -1012,6 +1015,7 @@ var vpcdStartCmd = &cobra.Command{
|
||||
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
|
||||
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
|
||||
BaseDir: nodeConfig.BaseDir,
|
||||
DataDir: nodeConfig.DataDir,
|
||||
Debug: false,
|
||||
ExternalMode: clusterConfig.Network.ExternalMode,
|
||||
ExternalPools: extPools,
|
||||
@@ -1025,6 +1029,8 @@ var vpcdStartCmd = &cobra.Command{
|
||||
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
|
||||
IPSecEnabled: clusterConfig.Network.IPSecEnabled,
|
||||
UnderlayMTU: clusterConfig.Network.UnderlayMTU,
|
||||
BlockedWANPorts: clusterConfig.Network.ResolvedBlockedWANPorts(),
|
||||
EgressBlockExemptVPCs: clusterConfig.Network.EgressBlockExemptVPCs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create vpcd service: %w", err)
|
||||
@@ -1535,6 +1541,10 @@ func init() {
|
||||
viper.BindEnv("spinifex-ui-base-dir", "SPINIFEX_UI_BASE_DIR")
|
||||
viper.BindPFlag("spinifex-ui-base-dir", spinifexUICmd.PersistentFlags().Lookup("base-dir"))
|
||||
|
||||
spinifexUICmd.PersistentFlags().Bool("ochre-enabled", false, "enable the Ochre console")
|
||||
viper.BindEnv("spinifex-ui-ochre-enabled", "SPINIFEX_UI_OCHRE_ENABLED")
|
||||
viper.BindPFlag("spinifex-ui-ochre-enabled", spinifexUICmd.PersistentFlags().Lookup("ochre-enabled"))
|
||||
|
||||
spinifexUICmd.AddCommand(spinifexUIStartCmd)
|
||||
spinifexUICmd.AddCommand(spinifexUIStopCmd)
|
||||
spinifexUICmd.AddCommand(spinifexUIStatusCmd)
|
||||
|
||||
@@ -38,3 +38,25 @@ secret_key = "{{.NorthstarSecretKey}}"
|
||||
# Forwarders for non-authoritative (recursive) queries. Empty => REFUSED.
|
||||
[upstream]
|
||||
nameservers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}:53"{{end}}]
|
||||
|
||||
# Who may have a query forwarded to those nameservers. Authoritative answers are
|
||||
# never gated by this — a public authoritative server must answer everyone.
|
||||
#
|
||||
# allow_recursion = true serves recursion to ANY client, including the whole
|
||||
# internet, because :53 above is bound to the node's public address. Do not set
|
||||
# it on a node reachable from the internet. An open resolver is found by scanners
|
||||
# within hours and is then used as a DDoS reflector, which gets the node's whole
|
||||
# address range blocklisted — an outage for every tenant on it, and slow to undo.
|
||||
# It is here for an isolated or lab deployment where the DNS port is not exposed.
|
||||
#
|
||||
# Trusted clients do not need it. List them in allow_recursion_from instead,
|
||||
# which is unaffected by allow_recursion and is how the cluster resolves:
|
||||
# - Cluster nodes resolve through northstar (resolv.conf points at the node's
|
||||
# own advertised address, so the query arrives from that public IP over lo,
|
||||
# not from 127.0.0.1).
|
||||
# - EC2 instances reach vpcd's per-tap DNS shim on 169.254.169.253, which
|
||||
# relays to northstar's :5300 from a node address — so listing the nodes
|
||||
# covers every guest, and guest addresses never appear here.
|
||||
# Loopback is always allowed and does not need listing.
|
||||
allow_recursion = false
|
||||
allow_recursion_from = [{{range $i, $s := .ResolverAllowFrom}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
|
||||
|
||||
@@ -36,6 +36,7 @@ data_dir = "{{$.PredastoreDataDir}}"
|
||||
tls_cert = "{{$.ConfigDir}}/server.pem"
|
||||
tls_key = "{{$.ConfigDir}}/server.key"
|
||||
encryption_key = "{{$.ConfigDir}}/predastore/encryption.key"
|
||||
admin_port = {{$.AdminPort}}
|
||||
{{- range $.ClusterNodes}}
|
||||
{{- if eq .HostID $host.ID}}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ data_dir = "{{.PredastoreDataDir}}"
|
||||
tls_cert = "{{.ConfigDir}}/server.pem"
|
||||
tls_key = "{{.ConfigDir}}/server.key"
|
||||
encryption_key = "{{.ConfigDir}}/predastore/encryption.key"
|
||||
admin_port = {{.PredastoreAdminPort}}
|
||||
|
||||
# The gate serves the S3 API. Without one the host answers no S3 request.
|
||||
# S3 is the public plane, so it binds every interface while the host's
|
||||
|
||||
@@ -14,6 +14,13 @@ internal_suffix = "spinifex.internal"
|
||||
|
||||
[network]
|
||||
ipsec_enabled = {{.IPSecEnabled}}
|
||||
# Outbound TCP ports blocked for guest egress to public destinations, like AWS's
|
||||
# default outbound-SMTP block. Omit the key for the default [25, 465, 587]; set
|
||||
# [] to disable. Private destinations are always exempt.
|
||||
# blocked_ports_wan = [25, 465, 587]
|
||||
# VPC IDs exempted from the block — the operator workaround for a tenant that
|
||||
# legitimately needs to send mail, until per-account exceptions exist.
|
||||
# egress_block_exempt_vpcs = ["vpc-..."]
|
||||
{{- if .ExternalMode}}
|
||||
external_mode = "{{.ExternalMode}}"
|
||||
{{- range .Pools}}
|
||||
@@ -111,6 +118,14 @@ token = "{{.NatsToken}}"
|
||||
[nodes.{{.Node}}.viperblock]
|
||||
shardwal = false
|
||||
gc_enabled = true
|
||||
#
|
||||
# wal_base_dir: put every volume's WAL under this directory instead of the
|
||||
# node's base_dir. A WAL fsync shares the device queue with predastore, the
|
||||
# chunk cache and every other volume on the node, so a busy node lengthens the
|
||||
# fsync tail that a datastore guest measures as its commit latency. Pointing
|
||||
# this at its own device keeps that traffic out of the WAL's queue. Unset
|
||||
# keeps the WAL under base_dir.
|
||||
#wal_base_dir = "/var/lib/spinifex-wal"
|
||||
{{- if .EncryptionKeyFile}}
|
||||
encryption_key_file = "{{.EncryptionKeyFile}}"
|
||||
{{- end}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -38,6 +38,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/loadgen"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+96
-1025
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browsing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
|
||||
|
||||
The [AWS model operation coverage](aws-model-operation-coverage.md) report is generated from Spinifex's dispatch tables and the pinned AWS API models. Run `make aws-model-coverage` to regenerate and print it.
|
||||
The [AWS API coverage](coverage/README.md) pages are generated from Spinifex's dispatch tables and the pinned AWS API models, one page per service. Run `make aws-model-coverage` to regenerate them and print a summary. Only `pages.json` and the `intro.md` files under `coverage/` are hand-written; every `README.md` there is generated and will be overwritten.
|
||||
|
||||
A published page names the operations Spinifex serves, and nothing else. It carries no percentage, no unimplemented operation and no operation the platform will never offer: the same run writes those to [`coverage/internal-report.md`](coverage/internal-report.md), which no slug in the docs site's configuration points at, so it stays internal. A `notApplicable` declaration in `pages.json` therefore publishes nothing; it keeps a permanent absence out of the report's candidate work.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
|
||||
@@ -314,15 +314,16 @@ On first load, your browser will show a TLS warning — Spinifex generates a sel
|
||||
|
||||
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: *Advanced → Proceed to ...*, Firefox: *Advanced → Accept the Risk and Continue*).
|
||||
2. On the login page, click **Download Certificate** and save `spinifex-ca.pem` to your machine.
|
||||
3. Install the certificate as a **trusted root** on your workstation:
|
||||
|
||||
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
|
||||
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
|
||||
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
|
||||
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
|
||||
|
||||
3. Install the certificate as a **trusted root** on your workstation, following the steps for your platform below.
|
||||
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
|
||||
|
||||
Step 3, by platform:
|
||||
|
||||
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
|
||||
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
|
||||
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
|
||||
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
|
||||
|
||||
> **Why this is required:** the UI logs in by reading your AWS credentials through a trusted TLS channel. Browsers refuse to send credentials over an untrusted connection, so the certificate must be installed as trusted — temporary "Proceed anyway" exceptions won't work for login.
|
||||
|
||||
### Log In with AWS Credentials
|
||||
|
||||
@@ -37,20 +37,6 @@ For operators who want to review migrations before they are applied, a manual up
|
||||
> [!WARNING]
|
||||
> **Swapping the `spx` binary alone is not an upgrade.** Systemd unit files (`KillMode`, `TimeoutStopSec`, drain ordering, and similar) are written once at install time and never re-asserted just because a new binary is in place — a node "upgraded" by replacing `/usr/local/bin/spx` directly keeps running whatever units it was first installed with, which can silently disagree with the new binary's behaviour. Re-running the installer always reinstalls units unconditionally, so it is unaffected. `spx admin upgrade` now reconciles units too, so it closes this gap for operators who update the binary by hand. See [Checking for Unit Drift](#checking-for-unit-drift).
|
||||
|
||||
> [!WARNING]
|
||||
> **Object storage is exempt when upgrading from v1.15.0 or earlier to v1.16.0.** Predastore's configuration schema and on-disk layout changed with the object storage cutover in v1.16.0, and no migration converts an installation from before it. `spx admin upgrade` will not report anything pending for `predastore.toml`, and updating the binary over such an installation leaves Predastore unable to start. Those clusters have to be re-initialised from scratch, which discards their stored objects — export anything you need first. Upgrades between v1.16.0 and later releases are unaffected.
|
||||
|
||||
> [!WARNING]
|
||||
> **AMI metadata is exempt when upgrading from v1.16.0 or earlier to the release carrying the EBS-provider decoupling.** AMI metadata moved to `ebsmetadata` documents, and the legacy path that read it from `ami-<id>/config.json` was removed rather than migrated. There is no prefix scan, no fallback and no backfill at daemon start, so an AMI imported before the change becomes invisible to the control plane afterwards: `describe-images --image-ids` answers `InvalidAMIID.NotFound` and launches fail with `AMI has no snapshot ID, cannot perform zero-copy clone`. As with object storage above, `spx admin upgrade` reports nothing pending, because the gap is in stored data rather than in a config file.
|
||||
>
|
||||
> **Re-import affected AMIs after upgrading**, using [`spx admin images import`](/docs/spinifex-admin-cli), which writes metadata in the new location. If the source images are no longer available, the installation has to be re-initialised. Verify before you rely on it — an AMI is only healthy if it resolves by ID, not merely if it appears in the list:
|
||||
>
|
||||
> ```bash
|
||||
> aws ec2 describe-images --image-ids <ami-id>
|
||||
> ```
|
||||
>
|
||||
> This warning covers AMI metadata specifically, which is what has been observed. Check any other imported state you depend on before upgrading a cluster you cannot rebuild.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Re-run the Installer
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
# AWS model operation coverage
|
||||
|
||||
Generated from the cached `aws-sdk-go v1.55.8` `api-2.json` models and Spinifex's authoritative gateway dispatch tables.
|
||||
|
||||
Implemented means a modelled operation is registered to a real handler. Stub and deliberately unsupported handlers are reported separately. Shape conformance does not imply behavioural conformance.
|
||||
|
||||
| Service | API version | Modelled | Registered | Implemented | Stubbed | Unsupported | Missing | Extra |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| acm | 2015-12-08 | 15 | 9 | 9 | 0 | 0 | 6 | 0 |
|
||||
| ec2 | 2016-11-15 | 625 | 119 | 119 | 0 | 0 | 506 | 0 |
|
||||
| ecr | 2015-09-21 | 47 | 39 | 19 | 13 | 7 | 11 | 3 |
|
||||
| ecs | 2014-11-13 | 56 | 39 | 31 | 5 | 0 | 20 | 3 |
|
||||
| elasticloadbalancingv2 | 2015-12-01 | 46 | 37 | 33 | 0 | 0 | 13 | 4 |
|
||||
| iam | 2010-05-08 | 159 | 75 | 75 | 0 | 0 | 84 | 0 |
|
||||
| rds | 2014-10-31 | 162 | 43 | 26 | 0 | 12 | 124 | 5 |
|
||||
| s3 | 2006-03-01 | 99 | — | — | — | — | — | — |
|
||||
| sts | 2011-06-15 | 8 | 4 | 4 | 0 | 0 | 4 | 0 |
|
||||
|
||||
## acm
|
||||
|
||||
Implements **9 of 15** modelled operations (60.0%).
|
||||
|
||||
<details><summary>Implemented (9)</summary>
|
||||
|
||||
`AddTagsToCertificate`, `DeleteCertificate`, `DescribeCertificate`, `GetCertificate`, `ImportCertificate`, `ListCertificates`, `ListTagsForCertificate`, `RemoveTagsFromCertificate`
|
||||
|
||||
`RequestCertificate`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (6)</summary>
|
||||
|
||||
`ExportCertificate`, `GetAccountConfiguration`, `PutAccountConfiguration`, `RenewCertificate`, `ResendValidationEmail`, `UpdateCertificateOptions`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## ec2
|
||||
|
||||
Implements **119 of 625** modelled operations (19.0%).
|
||||
|
||||
<details><summary>Implemented (119)</summary>
|
||||
|
||||
`AllocateAddress`, `AssociateAddress`, `AssociateIamInstanceProfile`, `AssociateRouteTable`, `AttachInternetGateway`, `AttachNetworkInterface`, `AttachVolume`, `AuthorizeSecurityGroupEgress`
|
||||
|
||||
`AuthorizeSecurityGroupIngress`, `CancelCapacityReservation`, `CancelSpotInstanceRequests`, `CopyImage`, `CopySnapshot`, `CreateCapacityReservation`, `CreateEgressOnlyInternetGateway`, `CreateImage`
|
||||
|
||||
`CreateInternetGateway`, `CreateKeyPair`, `CreateLaunchTemplate`, `CreateLaunchTemplateVersion`, `CreateNatGateway`, `CreateNetworkInterface`, `CreatePlacementGroup`, `CreateRoute`
|
||||
|
||||
`CreateRouteTable`, `CreateSecurityGroup`, `CreateSnapshot`, `CreateSubnet`, `CreateTags`, `CreateVolume`, `CreateVpc`, `DeleteEgressOnlyInternetGateway`
|
||||
|
||||
`DeleteInternetGateway`, `DeleteKeyPair`, `DeleteLaunchTemplate`, `DeleteLaunchTemplateVersions`, `DeleteNatGateway`, `DeleteNetworkInterface`, `DeletePlacementGroup`, `DeleteRoute`
|
||||
|
||||
`DeleteRouteTable`, `DeleteSecurityGroup`, `DeleteSnapshot`, `DeleteSubnet`, `DeleteTags`, `DeleteVolume`, `DeleteVpc`, `DeregisterImage`
|
||||
|
||||
`DescribeAccountAttributes`, `DescribeAddresses`, `DescribeAddressesAttribute`, `DescribeAvailabilityZones`, `DescribeCapacityReservations`, `DescribeEgressOnlyInternetGateways`, `DescribeIamInstanceProfileAssociations`, `DescribeImageAttribute`
|
||||
|
||||
`DescribeImages`, `DescribeInstanceAttribute`, `DescribeInstanceCreditSpecifications`, `DescribeInstanceStatus`, `DescribeInstanceTypes`, `DescribeInstances`, `DescribeInternetGateways`, `DescribeKeyPairs`
|
||||
|
||||
`DescribeLaunchTemplateVersions`, `DescribeLaunchTemplates`, `DescribeNatGateways`, `DescribeNetworkInterfaces`, `DescribePlacementGroups`, `DescribeRegions`, `DescribeRouteTables`, `DescribeSecurityGroupRules`
|
||||
|
||||
`DescribeSecurityGroups`, `DescribeSnapshots`, `DescribeSpotInstanceRequests`, `DescribeSubnets`, `DescribeTags`, `DescribeVolumeStatus`, `DescribeVolumes`, `DescribeVolumesModifications`
|
||||
|
||||
`DescribeVpcAttribute`, `DescribeVpcs`, `DetachInternetGateway`, `DetachNetworkInterface`, `DetachVolume`, `DisableEbsEncryptionByDefault`, `DisableSerialConsoleAccess`, `DisassociateAddress`
|
||||
|
||||
`DisassociateIamInstanceProfile`, `DisassociateRouteTable`, `EnableEbsEncryptionByDefault`, `EnableSerialConsoleAccess`, `GetConsoleOutput`, `GetEbsEncryptionByDefault`, `GetPasswordData`, `GetSerialConsoleAccessStatus`
|
||||
|
||||
`ImportKeyPair`, `ModifyImageAttribute`, `ModifyInstanceAttribute`, `ModifyInstanceMetadataOptions`, `ModifyLaunchTemplate`, `ModifyNetworkInterfaceAttribute`, `ModifySubnetAttribute`, `ModifyVolume`
|
||||
|
||||
`ModifyVpcAttribute`, `RebootInstances`, `RegisterImage`, `ReleaseAddress`, `ReplaceIamInstanceProfileAssociation`, `ReplaceRoute`, `ReplaceRouteTableAssociation`, `RequestSpotInstances`
|
||||
|
||||
`ResetImageAttribute`, `RevokeSecurityGroupEgress`, `RevokeSecurityGroupIngress`, `RunInstances`, `StartInstances`, `StopInstances`, `TerminateInstances`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (506)</summary>
|
||||
|
||||
`AcceptAddressTransfer`, `AcceptReservedInstancesExchangeQuote`, `AcceptTransitGatewayMulticastDomainAssociations`, `AcceptTransitGatewayPeeringAttachment`, `AcceptTransitGatewayVpcAttachment`, `AcceptVpcEndpointConnections`, `AcceptVpcPeeringConnection`, `AdvertiseByoipCidr`
|
||||
|
||||
`AllocateHosts`, `AllocateIpamPoolCidr`, `ApplySecurityGroupsToClientVpnTargetNetwork`, `AssignIpv6Addresses`, `AssignPrivateIpAddresses`, `AssignPrivateNatGatewayAddress`, `AssociateClientVpnTargetNetwork`, `AssociateDhcpOptions`
|
||||
|
||||
`AssociateEnclaveCertificateIamRole`, `AssociateInstanceEventWindow`, `AssociateIpamByoasn`, `AssociateIpamResourceDiscovery`, `AssociateNatGatewayAddress`, `AssociateSubnetCidrBlock`, `AssociateTransitGatewayMulticastDomain`, `AssociateTransitGatewayPolicyTable`
|
||||
|
||||
`AssociateTransitGatewayRouteTable`, `AssociateTrunkInterface`, `AssociateVpcCidrBlock`, `AttachClassicLinkVpc`, `AttachVerifiedAccessTrustProvider`, `AttachVpnGateway`, `AuthorizeClientVpnIngress`, `BundleInstance`
|
||||
|
||||
`CancelBundleTask`, `CancelCapacityReservationFleets`, `CancelConversionTask`, `CancelExportTask`, `CancelImageLaunchPermission`, `CancelImportTask`, `CancelReservedInstancesListing`, `CancelSpotFleetRequests`
|
||||
|
||||
`ConfirmProductInstance`, `CopyFpgaImage`, `CreateCapacityReservationFleet`, `CreateCarrierGateway`, `CreateClientVpnEndpoint`, `CreateClientVpnRoute`, `CreateCoipCidr`, `CreateCoipPool`
|
||||
|
||||
`CreateCustomerGateway`, `CreateDefaultSubnet`, `CreateDefaultVpc`, `CreateDhcpOptions`, `CreateFleet`, `CreateFlowLogs`, `CreateFpgaImage`, `CreateInstanceConnectEndpoint`
|
||||
|
||||
`CreateInstanceEventWindow`, `CreateInstanceExportTask`, `CreateIpam`, `CreateIpamExternalResourceVerificationToken`, `CreateIpamPool`, `CreateIpamResourceDiscovery`, `CreateIpamScope`, `CreateLocalGatewayRoute`
|
||||
|
||||
`CreateLocalGatewayRouteTable`, `CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `CreateLocalGatewayRouteTableVpcAssociation`, `CreateManagedPrefixList`, `CreateNetworkAcl`, `CreateNetworkAclEntry`, `CreateNetworkInsightsAccessScope`, `CreateNetworkInsightsPath`
|
||||
|
||||
`CreateNetworkInterfacePermission`, `CreatePublicIpv4Pool`, `CreateReplaceRootVolumeTask`, `CreateReservedInstancesListing`, `CreateRestoreImageTask`, `CreateSnapshots`, `CreateSpotDatafeedSubscription`, `CreateStoreImageTask`
|
||||
|
||||
`CreateSubnetCidrReservation`, `CreateTrafficMirrorFilter`, `CreateTrafficMirrorFilterRule`, `CreateTrafficMirrorSession`, `CreateTrafficMirrorTarget`, `CreateTransitGateway`, `CreateTransitGatewayConnect`, `CreateTransitGatewayConnectPeer`
|
||||
|
||||
`CreateTransitGatewayMulticastDomain`, `CreateTransitGatewayPeeringAttachment`, `CreateTransitGatewayPolicyTable`, `CreateTransitGatewayPrefixListReference`, `CreateTransitGatewayRoute`, `CreateTransitGatewayRouteTable`, `CreateTransitGatewayRouteTableAnnouncement`, `CreateTransitGatewayVpcAttachment`
|
||||
|
||||
`CreateVerifiedAccessEndpoint`, `CreateVerifiedAccessGroup`, `CreateVerifiedAccessInstance`, `CreateVerifiedAccessTrustProvider`, `CreateVpcEndpoint`, `CreateVpcEndpointConnectionNotification`, `CreateVpcEndpointServiceConfiguration`, `CreateVpcPeeringConnection`
|
||||
|
||||
`CreateVpnConnection`, `CreateVpnConnectionRoute`, `CreateVpnGateway`, `DeleteCarrierGateway`, `DeleteClientVpnEndpoint`, `DeleteClientVpnRoute`, `DeleteCoipCidr`, `DeleteCoipPool`
|
||||
|
||||
`DeleteCustomerGateway`, `DeleteDhcpOptions`, `DeleteFleets`, `DeleteFlowLogs`, `DeleteFpgaImage`, `DeleteInstanceConnectEndpoint`, `DeleteInstanceEventWindow`, `DeleteIpam`
|
||||
|
||||
`DeleteIpamExternalResourceVerificationToken`, `DeleteIpamPool`, `DeleteIpamResourceDiscovery`, `DeleteIpamScope`, `DeleteLocalGatewayRoute`, `DeleteLocalGatewayRouteTable`, `DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `DeleteLocalGatewayRouteTableVpcAssociation`
|
||||
|
||||
`DeleteManagedPrefixList`, `DeleteNetworkAcl`, `DeleteNetworkAclEntry`, `DeleteNetworkInsightsAccessScope`, `DeleteNetworkInsightsAccessScopeAnalysis`, `DeleteNetworkInsightsAnalysis`, `DeleteNetworkInsightsPath`, `DeleteNetworkInterfacePermission`
|
||||
|
||||
`DeletePublicIpv4Pool`, `DeleteQueuedReservedInstances`, `DeleteSpotDatafeedSubscription`, `DeleteSubnetCidrReservation`, `DeleteTrafficMirrorFilter`, `DeleteTrafficMirrorFilterRule`, `DeleteTrafficMirrorSession`, `DeleteTrafficMirrorTarget`
|
||||
|
||||
`DeleteTransitGateway`, `DeleteTransitGatewayConnect`, `DeleteTransitGatewayConnectPeer`, `DeleteTransitGatewayMulticastDomain`, `DeleteTransitGatewayPeeringAttachment`, `DeleteTransitGatewayPolicyTable`, `DeleteTransitGatewayPrefixListReference`, `DeleteTransitGatewayRoute`
|
||||
|
||||
`DeleteTransitGatewayRouteTable`, `DeleteTransitGatewayRouteTableAnnouncement`, `DeleteTransitGatewayVpcAttachment`, `DeleteVerifiedAccessEndpoint`, `DeleteVerifiedAccessGroup`, `DeleteVerifiedAccessInstance`, `DeleteVerifiedAccessTrustProvider`, `DeleteVpcEndpointConnectionNotifications`
|
||||
|
||||
`DeleteVpcEndpointServiceConfigurations`, `DeleteVpcEndpoints`, `DeleteVpcPeeringConnection`, `DeleteVpnConnection`, `DeleteVpnConnectionRoute`, `DeleteVpnGateway`, `DeprovisionByoipCidr`, `DeprovisionIpamByoasn`
|
||||
|
||||
`DeprovisionIpamPoolCidr`, `DeprovisionPublicIpv4PoolCidr`, `DeregisterInstanceEventNotificationAttributes`, `DeregisterTransitGatewayMulticastGroupMembers`, `DeregisterTransitGatewayMulticastGroupSources`, `DescribeAddressTransfers`, `DescribeAggregateIdFormat`, `DescribeAwsNetworkPerformanceMetricSubscriptions`
|
||||
|
||||
`DescribeBundleTasks`, `DescribeByoipCidrs`, `DescribeCapacityBlockOfferings`, `DescribeCapacityReservationFleets`, `DescribeCarrierGateways`, `DescribeClassicLinkInstances`, `DescribeClientVpnAuthorizationRules`, `DescribeClientVpnConnections`
|
||||
|
||||
`DescribeClientVpnEndpoints`, `DescribeClientVpnRoutes`, `DescribeClientVpnTargetNetworks`, `DescribeCoipPools`, `DescribeConversionTasks`, `DescribeCustomerGateways`, `DescribeDhcpOptions`, `DescribeElasticGpus`
|
||||
|
||||
`DescribeExportImageTasks`, `DescribeExportTasks`, `DescribeFastLaunchImages`, `DescribeFastSnapshotRestores`, `DescribeFleetHistory`, `DescribeFleetInstances`, `DescribeFleets`, `DescribeFlowLogs`
|
||||
|
||||
`DescribeFpgaImageAttribute`, `DescribeFpgaImages`, `DescribeHostReservationOfferings`, `DescribeHostReservations`, `DescribeHosts`, `DescribeIdFormat`, `DescribeIdentityIdFormat`, `DescribeImportImageTasks`
|
||||
|
||||
`DescribeImportSnapshotTasks`, `DescribeInstanceConnectEndpoints`, `DescribeInstanceEventNotificationAttributes`, `DescribeInstanceEventWindows`, `DescribeInstanceTopology`, `DescribeInstanceTypeOfferings`, `DescribeIpamByoasn`, `DescribeIpamExternalResourceVerificationTokens`
|
||||
|
||||
`DescribeIpamPools`, `DescribeIpamResourceDiscoveries`, `DescribeIpamResourceDiscoveryAssociations`, `DescribeIpamScopes`, `DescribeIpams`, `DescribeIpv6Pools`, `DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociations`, `DescribeLocalGatewayRouteTableVpcAssociations`
|
||||
|
||||
`DescribeLocalGatewayRouteTables`, `DescribeLocalGatewayVirtualInterfaceGroups`, `DescribeLocalGatewayVirtualInterfaces`, `DescribeLocalGateways`, `DescribeLockedSnapshots`, `DescribeMacHosts`, `DescribeManagedPrefixLists`, `DescribeMovingAddresses`
|
||||
|
||||
`DescribeNetworkAcls`, `DescribeNetworkInsightsAccessScopeAnalyses`, `DescribeNetworkInsightsAccessScopes`, `DescribeNetworkInsightsAnalyses`, `DescribeNetworkInsightsPaths`, `DescribeNetworkInterfaceAttribute`, `DescribeNetworkInterfacePermissions`, `DescribePrefixLists`
|
||||
|
||||
`DescribePrincipalIdFormat`, `DescribePublicIpv4Pools`, `DescribeReplaceRootVolumeTasks`, `DescribeReservedInstances`, `DescribeReservedInstancesListings`, `DescribeReservedInstancesModifications`, `DescribeReservedInstancesOfferings`, `DescribeScheduledInstanceAvailability`
|
||||
|
||||
`DescribeScheduledInstances`, `DescribeSecurityGroupReferences`, `DescribeSnapshotAttribute`, `DescribeSnapshotTierStatus`, `DescribeSpotDatafeedSubscription`, `DescribeSpotFleetInstances`, `DescribeSpotFleetRequestHistory`, `DescribeSpotFleetRequests`
|
||||
|
||||
`DescribeSpotPriceHistory`, `DescribeStaleSecurityGroups`, `DescribeStoreImageTasks`, `DescribeTrafficMirrorFilterRules`, `DescribeTrafficMirrorFilters`, `DescribeTrafficMirrorSessions`, `DescribeTrafficMirrorTargets`, `DescribeTransitGatewayAttachments`
|
||||
|
||||
`DescribeTransitGatewayConnectPeers`, `DescribeTransitGatewayConnects`, `DescribeTransitGatewayMulticastDomains`, `DescribeTransitGatewayPeeringAttachments`, `DescribeTransitGatewayPolicyTables`, `DescribeTransitGatewayRouteTableAnnouncements`, `DescribeTransitGatewayRouteTables`, `DescribeTransitGatewayVpcAttachments`
|
||||
|
||||
`DescribeTransitGateways`, `DescribeTrunkInterfaceAssociations`, `DescribeVerifiedAccessEndpoints`, `DescribeVerifiedAccessGroups`, `DescribeVerifiedAccessInstanceLoggingConfigurations`, `DescribeVerifiedAccessInstances`, `DescribeVerifiedAccessTrustProviders`, `DescribeVolumeAttribute`
|
||||
|
||||
`DescribeVpcClassicLink`, `DescribeVpcClassicLinkDnsSupport`, `DescribeVpcEndpointConnectionNotifications`, `DescribeVpcEndpointConnections`, `DescribeVpcEndpointServiceConfigurations`, `DescribeVpcEndpointServicePermissions`, `DescribeVpcEndpointServices`, `DescribeVpcEndpoints`
|
||||
|
||||
`DescribeVpcPeeringConnections`, `DescribeVpnConnections`, `DescribeVpnGateways`, `DetachClassicLinkVpc`, `DetachVerifiedAccessTrustProvider`, `DetachVpnGateway`, `DisableAddressTransfer`, `DisableAwsNetworkPerformanceMetricSubscription`
|
||||
|
||||
`DisableFastLaunch`, `DisableFastSnapshotRestores`, `DisableImage`, `DisableImageBlockPublicAccess`, `DisableImageDeprecation`, `DisableImageDeregistrationProtection`, `DisableIpamOrganizationAdminAccount`, `DisableSnapshotBlockPublicAccess`
|
||||
|
||||
`DisableTransitGatewayRouteTablePropagation`, `DisableVgwRoutePropagation`, `DisableVpcClassicLink`, `DisableVpcClassicLinkDnsSupport`, `DisassociateClientVpnTargetNetwork`, `DisassociateEnclaveCertificateIamRole`, `DisassociateInstanceEventWindow`, `DisassociateIpamByoasn`
|
||||
|
||||
`DisassociateIpamResourceDiscovery`, `DisassociateNatGatewayAddress`, `DisassociateSubnetCidrBlock`, `DisassociateTransitGatewayMulticastDomain`, `DisassociateTransitGatewayPolicyTable`, `DisassociateTransitGatewayRouteTable`, `DisassociateTrunkInterface`, `DisassociateVpcCidrBlock`
|
||||
|
||||
`EnableAddressTransfer`, `EnableAwsNetworkPerformanceMetricSubscription`, `EnableFastLaunch`, `EnableFastSnapshotRestores`, `EnableImage`, `EnableImageBlockPublicAccess`, `EnableImageDeprecation`, `EnableImageDeregistrationProtection`
|
||||
|
||||
`EnableIpamOrganizationAdminAccount`, `EnableReachabilityAnalyzerOrganizationSharing`, `EnableSnapshotBlockPublicAccess`, `EnableTransitGatewayRouteTablePropagation`, `EnableVgwRoutePropagation`, `EnableVolumeIO`, `EnableVpcClassicLink`, `EnableVpcClassicLinkDnsSupport`
|
||||
|
||||
`ExportClientVpnClientCertificateRevocationList`, `ExportClientVpnClientConfiguration`, `ExportImage`, `ExportTransitGatewayRoutes`, `GetAssociatedEnclaveCertificateIamRoles`, `GetAssociatedIpv6PoolCidrs`, `GetAwsNetworkPerformanceData`, `GetCapacityReservationUsage`
|
||||
|
||||
`GetCoipPoolUsage`, `GetConsoleScreenshot`, `GetDefaultCreditSpecification`, `GetEbsDefaultKmsKeyId`, `GetFlowLogsIntegrationTemplate`, `GetGroupsForCapacityReservation`, `GetHostReservationPurchasePreview`, `GetImageBlockPublicAccessState`
|
||||
|
||||
`GetInstanceMetadataDefaults`, `GetInstanceTpmEkPub`, `GetInstanceTypesFromInstanceRequirements`, `GetInstanceUefiData`, `GetIpamAddressHistory`, `GetIpamDiscoveredAccounts`, `GetIpamDiscoveredPublicAddresses`, `GetIpamDiscoveredResourceCidrs`
|
||||
|
||||
`GetIpamPoolAllocations`, `GetIpamPoolCidrs`, `GetIpamResourceCidrs`, `GetLaunchTemplateData`, `GetManagedPrefixListAssociations`, `GetManagedPrefixListEntries`, `GetNetworkInsightsAccessScopeAnalysisFindings`, `GetNetworkInsightsAccessScopeContent`
|
||||
|
||||
`GetReservedInstancesExchangeQuote`, `GetSecurityGroupsForVpc`, `GetSnapshotBlockPublicAccessState`, `GetSpotPlacementScores`, `GetSubnetCidrReservations`, `GetTransitGatewayAttachmentPropagations`, `GetTransitGatewayMulticastDomainAssociations`, `GetTransitGatewayPolicyTableAssociations`
|
||||
|
||||
`GetTransitGatewayPolicyTableEntries`, `GetTransitGatewayPrefixListReferences`, `GetTransitGatewayRouteTableAssociations`, `GetTransitGatewayRouteTablePropagations`, `GetVerifiedAccessEndpointPolicy`, `GetVerifiedAccessGroupPolicy`, `GetVpnConnectionDeviceSampleConfiguration`, `GetVpnConnectionDeviceTypes`
|
||||
|
||||
`GetVpnTunnelReplacementStatus`, `ImportClientVpnClientCertificateRevocationList`, `ImportImage`, `ImportInstance`, `ImportSnapshot`, `ImportVolume`, `ListImagesInRecycleBin`, `ListSnapshotsInRecycleBin`
|
||||
|
||||
`LockSnapshot`, `ModifyAddressAttribute`, `ModifyAvailabilityZoneGroup`, `ModifyCapacityReservation`, `ModifyCapacityReservationFleet`, `ModifyClientVpnEndpoint`, `ModifyDefaultCreditSpecification`, `ModifyEbsDefaultKmsKeyId`
|
||||
|
||||
`ModifyFleet`, `ModifyFpgaImageAttribute`, `ModifyHosts`, `ModifyIdFormat`, `ModifyIdentityIdFormat`, `ModifyInstanceCapacityReservationAttributes`, `ModifyInstanceCreditSpecification`, `ModifyInstanceEventStartTime`
|
||||
|
||||
`ModifyInstanceEventWindow`, `ModifyInstanceMaintenanceOptions`, `ModifyInstanceMetadataDefaults`, `ModifyInstancePlacement`, `ModifyIpam`, `ModifyIpamPool`, `ModifyIpamResourceCidr`, `ModifyIpamResourceDiscovery`
|
||||
|
||||
`ModifyIpamScope`, `ModifyLocalGatewayRoute`, `ModifyManagedPrefixList`, `ModifyPrivateDnsNameOptions`, `ModifyReservedInstances`, `ModifySecurityGroupRules`, `ModifySnapshotAttribute`, `ModifySnapshotTier`
|
||||
|
||||
`ModifySpotFleetRequest`, `ModifyTrafficMirrorFilterNetworkServices`, `ModifyTrafficMirrorFilterRule`, `ModifyTrafficMirrorSession`, `ModifyTransitGateway`, `ModifyTransitGatewayPrefixListReference`, `ModifyTransitGatewayVpcAttachment`, `ModifyVerifiedAccessEndpoint`
|
||||
|
||||
`ModifyVerifiedAccessEndpointPolicy`, `ModifyVerifiedAccessGroup`, `ModifyVerifiedAccessGroupPolicy`, `ModifyVerifiedAccessInstance`, `ModifyVerifiedAccessInstanceLoggingConfiguration`, `ModifyVerifiedAccessTrustProvider`, `ModifyVolumeAttribute`, `ModifyVpcEndpoint`
|
||||
|
||||
`ModifyVpcEndpointConnectionNotification`, `ModifyVpcEndpointServiceConfiguration`, `ModifyVpcEndpointServicePayerResponsibility`, `ModifyVpcEndpointServicePermissions`, `ModifyVpcPeeringConnectionOptions`, `ModifyVpcTenancy`, `ModifyVpnConnection`, `ModifyVpnConnectionOptions`
|
||||
|
||||
`ModifyVpnTunnelCertificate`, `ModifyVpnTunnelOptions`, `MonitorInstances`, `MoveAddressToVpc`, `MoveByoipCidrToIpam`, `ProvisionByoipCidr`, `ProvisionIpamByoasn`, `ProvisionIpamPoolCidr`
|
||||
|
||||
`ProvisionPublicIpv4PoolCidr`, `PurchaseCapacityBlock`, `PurchaseHostReservation`, `PurchaseReservedInstancesOffering`, `PurchaseScheduledInstances`, `RegisterInstanceEventNotificationAttributes`, `RegisterTransitGatewayMulticastGroupMembers`, `RegisterTransitGatewayMulticastGroupSources`
|
||||
|
||||
`RejectTransitGatewayMulticastDomainAssociations`, `RejectTransitGatewayPeeringAttachment`, `RejectTransitGatewayVpcAttachment`, `RejectVpcEndpointConnections`, `RejectVpcPeeringConnection`, `ReleaseHosts`, `ReleaseIpamPoolAllocation`, `ReplaceNetworkAclAssociation`
|
||||
|
||||
`ReplaceNetworkAclEntry`, `ReplaceTransitGatewayRoute`, `ReplaceVpnTunnel`, `ReportInstanceStatus`, `RequestSpotFleet`, `ResetAddressAttribute`, `ResetEbsDefaultKmsKeyId`, `ResetFpgaImageAttribute`
|
||||
|
||||
`ResetInstanceAttribute`, `ResetNetworkInterfaceAttribute`, `ResetSnapshotAttribute`, `RestoreAddressToClassic`, `RestoreImageFromRecycleBin`, `RestoreManagedPrefixListVersion`, `RestoreSnapshotFromRecycleBin`, `RestoreSnapshotTier`
|
||||
|
||||
`RevokeClientVpnIngress`, `RunScheduledInstances`, `SearchLocalGatewayRoutes`, `SearchTransitGatewayMulticastGroups`, `SearchTransitGatewayRoutes`, `SendDiagnosticInterrupt`, `StartNetworkInsightsAccessScopeAnalysis`, `StartNetworkInsightsAnalysis`
|
||||
|
||||
`StartVpcEndpointServicePrivateDnsVerification`, `TerminateClientVpnConnections`, `UnassignIpv6Addresses`, `UnassignPrivateIpAddresses`, `UnassignPrivateNatGatewayAddress`, `UnlockSnapshot`, `UnmonitorInstances`, `UpdateSecurityGroupRuleDescriptionsEgress`
|
||||
|
||||
`UpdateSecurityGroupRuleDescriptionsIngress`, `WithdrawByoipCidr`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## ecr
|
||||
|
||||
Implements **19 of 47** modelled operations (40.4%).
|
||||
|
||||
<details><summary>Implemented (19)</summary>
|
||||
|
||||
`BatchDeleteImage`, `BatchGetImage`, `CreateRepository`, `DeleteLifecyclePolicy`, `DeleteRepository`, `DeleteRepositoryPolicy`, `DescribeImages`, `DescribeRepositories`
|
||||
|
||||
`GetAuthorizationToken`, `GetLifecyclePolicy`, `GetLifecyclePolicyPreview`, `GetRepositoryPolicy`, `ListImages`, `ListTagsForResource`, `PutImage`, `PutImageTagMutability`
|
||||
|
||||
`PutLifecyclePolicy`, `SetRepositoryPolicy`, `StartLifecyclePolicyPreview`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (11)</summary>
|
||||
|
||||
`CreatePullThroughCacheRule`, `CreateRepositoryCreationTemplate`, `DeletePullThroughCacheRule`, `DeleteRegistryPolicy`, `DeleteRepositoryCreationTemplate`, `DescribeImageReplicationStatus`, `DescribePullThroughCacheRules`, `DescribeRepositoryCreationTemplates`
|
||||
|
||||
`UpdatePullThroughCacheRule`, `UpdateRepositoryCreationTemplate`, `ValidatePullThroughCacheRule`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (13)</summary>
|
||||
|
||||
`BatchCheckLayerAvailability`, `CompleteLayerUpload`, `DescribeRegistry`, `GetDownloadUrlForLayer`, `GetRegistryPolicy`, `InitiateLayerUpload`, `ListRepositories`, `PutRegistryPolicy`
|
||||
|
||||
`PutReplicationConfiguration`, `ReplicateImage`, `TagResource`, `UntagResource`, `UploadLayerPart`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (7)</summary>
|
||||
|
||||
`BatchGetRepositoryScanningConfiguration`, `DescribeImageScanFindings`, `GetImageScanningConfiguration`, `GetRegistryScanningConfiguration`, `PutImageScanningConfiguration`, `PutRegistryScanningConfiguration`, `StartImageScan`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (3)</summary>
|
||||
|
||||
`GetImageScanningConfiguration`, `ListRepositories`, `ReplicateImage`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## ecs
|
||||
|
||||
Implements **31 of 56** modelled operations (55.4%).
|
||||
|
||||
<details><summary>Implemented (31)</summary>
|
||||
|
||||
`CreateCapacityProvider`, `CreateCluster`, `CreateService`, `DeleteCapacityProvider`, `DeleteCluster`, `DeleteService`, `DeregisterContainerInstance`, `DeregisterTaskDefinition`
|
||||
|
||||
`DescribeCapacityProviders`, `DescribeClusters`, `DescribeContainerInstances`, `DescribeServices`, `DescribeTaskDefinition`, `DescribeTasks`, `ListClusters`, `ListContainerInstances`
|
||||
|
||||
`ListServices`, `ListTagsForResource`, `ListTaskDefinitions`, `ListTasks`, `PutClusterCapacityProviders`, `RegisterContainerInstance`, `RegisterTaskDefinition`, `RunTask`
|
||||
|
||||
`StartTask`, `StopTask`, `SubmitTaskStateChange`, `TagResource`, `UntagResource`, `UpdateContainerInstancesState`, `UpdateService`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (20)</summary>
|
||||
|
||||
`CreateTaskSet`, `DeleteAccountSetting`, `DeleteAttributes`, `DeleteTaskDefinitions`, `DeleteTaskSet`, `DescribeTaskSets`, `DiscoverPollEndpoint`, `ExecuteCommand`
|
||||
|
||||
`GetTaskProtection`, `ListAttributes`, `PutAccountSettingDefault`, `PutAttributes`, `SubmitAttachmentStateChanges`, `SubmitContainerStateChange`, `UpdateCapacityProvider`, `UpdateClusterSettings`
|
||||
|
||||
`UpdateContainerAgent`, `UpdateServicePrimaryTaskSet`, `UpdateTaskProtection`, `UpdateTaskSet`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (5)</summary>
|
||||
|
||||
`ListAccountSettings`, `ListServicesByNamespace`, `ListTaskDefinitionFamilies`, `PutAccountSetting`, `UpdateCluster`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (3)</summary>
|
||||
|
||||
`PollAssignments`, `ProvisionCapacity`, `ReportTaskGPU`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## elasticloadbalancingv2
|
||||
|
||||
Implements **33 of 46** modelled operations (71.7%).
|
||||
|
||||
<details><summary>Implemented (33)</summary>
|
||||
|
||||
`AddListenerCertificates`, `AddTags`, `CreateListener`, `CreateLoadBalancer`, `CreateRule`, `CreateTargetGroup`, `DeleteListener`, `DeleteLoadBalancer`
|
||||
|
||||
`DeleteRule`, `DeleteTargetGroup`, `DeregisterTargets`, `DescribeListenerCertificates`, `DescribeListeners`, `DescribeLoadBalancerAttributes`, `DescribeLoadBalancers`, `DescribeRules`
|
||||
|
||||
`DescribeSSLPolicies`, `DescribeTags`, `DescribeTargetGroupAttributes`, `DescribeTargetGroups`, `DescribeTargetHealth`, `ModifyListener`, `ModifyLoadBalancerAttributes`, `ModifyRule`
|
||||
|
||||
`ModifyTargetGroup`, `ModifyTargetGroupAttributes`, `RegisterTargets`, `RemoveListenerCertificates`, `RemoveTags`, `SetIpAddressType`, `SetRulePriorities`, `SetSecurityGroups`
|
||||
|
||||
`SetSubnets`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (13)</summary>
|
||||
|
||||
`AddTrustStoreRevocations`, `CreateTrustStore`, `DeleteSharedTrustStoreAssociation`, `DeleteTrustStore`, `DescribeAccountLimits`, `DescribeTrustStoreAssociations`, `DescribeTrustStoreRevocations`, `DescribeTrustStores`
|
||||
|
||||
`GetResourcePolicy`, `GetTrustStoreCaCertificatesBundle`, `GetTrustStoreRevocationContent`, `ModifyTrustStore`, `RemoveTrustStoreRevocations`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (4)</summary>
|
||||
|
||||
`DescribeListenerAttributes`, `GetLBConfig`, `LBAgentHeartbeat`, `ModifyListenerAttributes`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## iam
|
||||
|
||||
Implements **75 of 159** modelled operations (47.2%).
|
||||
|
||||
<details><summary>Implemented (75)</summary>
|
||||
|
||||
`AddRoleToInstanceProfile`, `AddUserToGroup`, `AttachGroupPolicy`, `AttachRolePolicy`, `AttachUserPolicy`, `CreateAccessKey`, `CreateGroup`, `CreateInstanceProfile`
|
||||
|
||||
`CreateOpenIDConnectProvider`, `CreatePolicy`, `CreateRole`, `CreateUser`, `DeleteAccessKey`, `DeleteGroup`, `DeleteGroupPolicy`, `DeleteInstanceProfile`
|
||||
|
||||
`DeleteOpenIDConnectProvider`, `DeletePolicy`, `DeleteRole`, `DeleteRolePolicy`, `DeleteUser`, `DeleteUserPolicy`, `DetachGroupPolicy`, `DetachRolePolicy`
|
||||
|
||||
`DetachUserPolicy`, `GetAccountSummary`, `GetGroup`, `GetGroupPolicy`, `GetInstanceProfile`, `GetOpenIDConnectProvider`, `GetPolicy`, `GetPolicyVersion`
|
||||
|
||||
`GetRole`, `GetRolePolicy`, `GetUser`, `GetUserPolicy`, `ListAccessKeys`, `ListAttachedGroupPolicies`, `ListAttachedRolePolicies`, `ListAttachedUserPolicies`
|
||||
|
||||
`ListGroupPolicies`, `ListGroups`, `ListGroupsForUser`, `ListInstanceProfileTags`, `ListInstanceProfiles`, `ListInstanceProfilesForRole`, `ListOpenIDConnectProviderTags`, `ListOpenIDConnectProviders`
|
||||
|
||||
`ListPolicies`, `ListPolicyTags`, `ListPolicyVersions`, `ListRolePolicies`, `ListRoleTags`, `ListRoles`, `ListUserPolicies`, `ListUserTags`
|
||||
|
||||
`ListUsers`, `PutGroupPolicy`, `PutRolePolicy`, `PutUserPolicy`, `RemoveRoleFromInstanceProfile`, `RemoveUserFromGroup`, `TagInstanceProfile`, `TagOpenIDConnectProvider`
|
||||
|
||||
`TagPolicy`, `TagRole`, `TagUser`, `UntagInstanceProfile`, `UntagOpenIDConnectProvider`, `UntagPolicy`, `UntagRole`, `UntagUser`
|
||||
|
||||
`UpdateAccessKey`, `UpdateAssumeRolePolicy`, `UpdateRole`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (84)</summary>
|
||||
|
||||
`AddClientIDToOpenIDConnectProvider`, `ChangePassword`, `CreateAccountAlias`, `CreateLoginProfile`, `CreatePolicyVersion`, `CreateSAMLProvider`, `CreateServiceLinkedRole`, `CreateServiceSpecificCredential`
|
||||
|
||||
`CreateVirtualMFADevice`, `DeactivateMFADevice`, `DeleteAccountAlias`, `DeleteAccountPasswordPolicy`, `DeleteLoginProfile`, `DeletePolicyVersion`, `DeleteRolePermissionsBoundary`, `DeleteSAMLProvider`
|
||||
|
||||
`DeleteSSHPublicKey`, `DeleteServerCertificate`, `DeleteServiceLinkedRole`, `DeleteServiceSpecificCredential`, `DeleteSigningCertificate`, `DeleteUserPermissionsBoundary`, `DeleteVirtualMFADevice`, `EnableMFADevice`
|
||||
|
||||
`GenerateCredentialReport`, `GenerateOrganizationsAccessReport`, `GenerateServiceLastAccessedDetails`, `GetAccessKeyLastUsed`, `GetAccountAuthorizationDetails`, `GetAccountPasswordPolicy`, `GetContextKeysForCustomPolicy`, `GetContextKeysForPrincipalPolicy`
|
||||
|
||||
`GetCredentialReport`, `GetLoginProfile`, `GetMFADevice`, `GetOrganizationsAccessReport`, `GetSAMLProvider`, `GetSSHPublicKey`, `GetServerCertificate`, `GetServiceLastAccessedDetails`
|
||||
|
||||
`GetServiceLastAccessedDetailsWithEntities`, `GetServiceLinkedRoleDeletionStatus`, `ListAccountAliases`, `ListEntitiesForPolicy`, `ListMFADeviceTags`, `ListMFADevices`, `ListPoliciesGrantingServiceAccess`, `ListSAMLProviderTags`
|
||||
|
||||
`ListSAMLProviders`, `ListSSHPublicKeys`, `ListServerCertificateTags`, `ListServerCertificates`, `ListServiceSpecificCredentials`, `ListSigningCertificates`, `ListVirtualMFADevices`, `PutRolePermissionsBoundary`
|
||||
|
||||
`PutUserPermissionsBoundary`, `RemoveClientIDFromOpenIDConnectProvider`, `ResetServiceSpecificCredential`, `ResyncMFADevice`, `SetDefaultPolicyVersion`, `SetSecurityTokenServicePreferences`, `SimulateCustomPolicy`, `SimulatePrincipalPolicy`
|
||||
|
||||
`TagMFADevice`, `TagSAMLProvider`, `TagServerCertificate`, `UntagMFADevice`, `UntagSAMLProvider`, `UntagServerCertificate`, `UpdateAccountPasswordPolicy`, `UpdateGroup`
|
||||
|
||||
`UpdateLoginProfile`, `UpdateOpenIDConnectProviderThumbprint`, `UpdateRoleDescription`, `UpdateSAMLProvider`, `UpdateSSHPublicKey`, `UpdateServerCertificate`, `UpdateServiceSpecificCredential`, `UpdateSigningCertificate`
|
||||
|
||||
`UpdateUser`, `UploadSSHPublicKey`, `UploadServerCertificate`, `UploadSigningCertificate`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## rds
|
||||
|
||||
Implements **26 of 162** modelled operations (16.0%).
|
||||
|
||||
<details><summary>Implemented (26)</summary>
|
||||
|
||||
`AddTagsToResource`, `CreateDBInstance`, `CreateDBParameterGroup`, `CreateDBSnapshot`, `CreateDBSubnetGroup`, `DeleteDBInstance`, `DeleteDBParameterGroup`, `DeleteDBSnapshot`
|
||||
|
||||
`DeleteDBSubnetGroup`, `DescribeDBEngineVersions`, `DescribeDBInstanceAutomatedBackups`, `DescribeDBInstances`, `DescribeDBParameterGroups`, `DescribeDBParameters`, `DescribeDBSnapshots`, `DescribeDBSubnetGroups`
|
||||
|
||||
`DescribeEvents`, `DescribeOrderableDBInstanceOptions`, `ListTagsForResource`, `ModifyDBInstance`, `ModifyDBParameterGroup`, `RebootDBInstance`, `RemoveTagsFromResource`, `RestoreDBInstanceFromDBSnapshot`
|
||||
|
||||
`StartDBInstance`, `StopDBInstance`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (124)</summary>
|
||||
|
||||
`AddRoleToDBCluster`, `AddRoleToDBInstance`, `AddSourceIdentifierToSubscription`, `ApplyPendingMaintenanceAction`, `AuthorizeDBSecurityGroupIngress`, `BacktrackDBCluster`, `CancelExportTask`, `CopyDBClusterParameterGroup`
|
||||
|
||||
`CopyDBClusterSnapshot`, `CopyDBParameterGroup`, `CopyDBSnapshot`, `CopyOptionGroup`, `CreateBlueGreenDeployment`, `CreateCustomDBEngineVersion`, `CreateDBClusterEndpoint`, `CreateDBClusterParameterGroup`
|
||||
|
||||
`CreateDBClusterSnapshot`, `CreateDBProxy`, `CreateDBProxyEndpoint`, `CreateDBSecurityGroup`, `CreateDBShardGroup`, `CreateEventSubscription`, `CreateGlobalCluster`, `CreateIntegration`
|
||||
|
||||
`CreateTenantDatabase`, `DeleteBlueGreenDeployment`, `DeleteCustomDBEngineVersion`, `DeleteDBClusterAutomatedBackup`, `DeleteDBClusterEndpoint`, `DeleteDBClusterParameterGroup`, `DeleteDBClusterSnapshot`, `DeleteDBInstanceAutomatedBackup`
|
||||
|
||||
`DeleteDBProxy`, `DeleteDBProxyEndpoint`, `DeleteDBSecurityGroup`, `DeleteDBShardGroup`, `DeleteEventSubscription`, `DeleteGlobalCluster`, `DeleteIntegration`, `DeleteTenantDatabase`
|
||||
|
||||
`DeregisterDBProxyTargets`, `DescribeAccountAttributes`, `DescribeBlueGreenDeployments`, `DescribeCertificates`, `DescribeDBClusterAutomatedBackups`, `DescribeDBClusterBacktracks`, `DescribeDBClusterEndpoints`, `DescribeDBClusterParameterGroups`
|
||||
|
||||
`DescribeDBClusterParameters`, `DescribeDBClusterSnapshotAttributes`, `DescribeDBClusterSnapshots`, `DescribeDBLogFiles`, `DescribeDBProxies`, `DescribeDBProxyEndpoints`, `DescribeDBProxyTargetGroups`, `DescribeDBProxyTargets`
|
||||
|
||||
`DescribeDBRecommendations`, `DescribeDBSecurityGroups`, `DescribeDBShardGroups`, `DescribeDBSnapshotAttributes`, `DescribeDBSnapshotTenantDatabases`, `DescribeEngineDefaultClusterParameters`, `DescribeEngineDefaultParameters`, `DescribeEventCategories`
|
||||
|
||||
`DescribeEventSubscriptions`, `DescribeExportTasks`, `DescribeGlobalClusters`, `DescribeIntegrations`, `DescribeOptionGroupOptions`, `DescribePendingMaintenanceActions`, `DescribeReservedDBInstances`, `DescribeReservedDBInstancesOfferings`
|
||||
|
||||
`DescribeSourceRegions`, `DescribeTenantDatabases`, `DescribeValidDBInstanceModifications`, `DisableHttpEndpoint`, `DownloadDBLogFilePortion`, `EnableHttpEndpoint`, `FailoverGlobalCluster`, `ModifyActivityStream`
|
||||
|
||||
`ModifyCertificates`, `ModifyCurrentDBClusterCapacity`, `ModifyCustomDBEngineVersion`, `ModifyDBClusterEndpoint`, `ModifyDBClusterParameterGroup`, `ModifyDBClusterSnapshotAttribute`, `ModifyDBProxy`, `ModifyDBProxyEndpoint`
|
||||
|
||||
`ModifyDBProxyTargetGroup`, `ModifyDBRecommendation`, `ModifyDBShardGroup`, `ModifyDBSnapshot`, `ModifyDBSnapshotAttribute`, `ModifyDBSubnetGroup`, `ModifyEventSubscription`, `ModifyGlobalCluster`
|
||||
|
||||
`ModifyIntegration`, `ModifyTenantDatabase`, `PromoteReadReplicaDBCluster`, `PurchaseReservedDBInstancesOffering`, `RebootDBCluster`, `RebootDBShardGroup`, `RegisterDBProxyTargets`, `RemoveFromGlobalCluster`
|
||||
|
||||
`RemoveRoleFromDBCluster`, `RemoveRoleFromDBInstance`, `RemoveSourceIdentifierFromSubscription`, `ResetDBClusterParameterGroup`, `ResetDBParameterGroup`, `RestoreDBClusterFromS3`, `RestoreDBClusterFromSnapshot`, `RestoreDBClusterToPointInTime`
|
||||
|
||||
`RestoreDBInstanceFromS3`, `RevokeDBSecurityGroupIngress`, `StartActivityStream`, `StartDBCluster`, `StartDBInstanceAutomatedBackupsReplication`, `StartExportTask`, `StopActivityStream`, `StopDBCluster`
|
||||
|
||||
`StopDBInstanceAutomatedBackupsReplication`, `SwitchoverBlueGreenDeployment`, `SwitchoverGlobalCluster`, `SwitchoverReadReplica`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (12)</summary>
|
||||
|
||||
`CreateDBCluster`, `CreateDBInstanceReadReplica`, `CreateOptionGroup`, `DeleteDBCluster`, `DeleteOptionGroup`, `DescribeDBClusters`, `DescribeOptionGroups`, `FailoverDBCluster`
|
||||
|
||||
`ModifyDBCluster`, `ModifyOptionGroup`, `PromoteReadReplica`, `RestoreDBInstanceToPointInTime`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (5)</summary>
|
||||
|
||||
`AcknowledgeDBBootstrap`, `GetDBBootstrapConfig`, `PollDBCommands`, `RegisterDBInstance`, `SubmitDBStateChange`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## s3
|
||||
|
||||
Operation coverage is not enumerable: Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.
|
||||
|
||||
## sts
|
||||
|
||||
Implements **4 of 8** modelled operations (50.0%).
|
||||
|
||||
<details><summary>Implemented (4)</summary>
|
||||
|
||||
`AssumeRole`, `AssumeRoleWithWebIdentity`, `GetCallerIdentity`, `GetSessionToken`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (4)</summary>
|
||||
|
||||
`AssumeRoleWithSAML`, `DecodeAuthorizationMessage`, `GetAccessKeyInfo`, `GetFederationToken`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -795,6 +795,158 @@ aws ec2 run-instances --image-id $AMI --instance-type t3.small \
|
||||
|
||||
Rule changes take effect immediately — no instance restart needed.
|
||||
|
||||
## Platform Default Egress Restrictions (Outbound SMTP)
|
||||
|
||||
Like AWS, Spinifex blocks outbound SMTP from instances **by default** so a
|
||||
compromised guest cannot turn a fresh account into a spam relay. Connections to
|
||||
the mail ports are dropped at the OVS datapath before they reach the wire:
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
| --- | --- | --- |
|
||||
| 25 | TCP | SMTP relay |
|
||||
| 465 | TCP | SMTP over implicit TLS |
|
||||
| 587 | TCP | SMTP submission |
|
||||
|
||||
This is a **platform default, not a security-group rule**. It is enforced as an
|
||||
OVN egress ACL on every guest's port group at a priority **above** tenant SG
|
||||
allows, so a tenant **cannot** open these ports by adding a security-group rule —
|
||||
matching AWS, where lifting the block is an operator action, not a tenant one.
|
||||
Only **public** destinations are blocked; mail to private ranges (`10/8`,
|
||||
`172.16/12`, `192.168/16`, `100.64/10`, link-local) is exempt, so an in-VPC or
|
||||
on-prem relay still works. Dropped attempts are logged for abuse triage.
|
||||
|
||||
It sits alongside the security-group ACLs and the host firewall (the `nft`
|
||||
policy that scopes the node's own ports) as a third datapath control — this one
|
||||
applies to guest egress specifically.
|
||||
|
||||
**Operator controls** (cluster-wide, in `spinifex.toml`):
|
||||
|
||||
```toml
|
||||
[network]
|
||||
# Omit for the default [25, 465, 587]; set [] to disable entirely.
|
||||
blocked_ports_wan = [25, 465, 587]
|
||||
|
||||
# Workaround to let a specific tenant/VPC send mail, until per-account
|
||||
# exceptions exist: list the VPC IDs to exempt from the block.
|
||||
egress_block_exempt_vpcs = ["vpc-0abc123..."]
|
||||
```
|
||||
|
||||
Exempting a VPC removes the block for **all** guests in that VPC, so scope it
|
||||
narrowly. Add every VPC ID you want exempt to the one list —
|
||||
`egress_block_exempt_vpcs = ["vpc-a", "vpc-b", ...]`; the match is by VPC ID, so
|
||||
one entry covers every security group in that VPC.
|
||||
|
||||
### Multi-node clusters — keep the value identical on every node, and restart vpcd
|
||||
|
||||
`[network]` is a cluster-wide *setting*, but it physically lives in each node's
|
||||
own `spinifex.toml`, and there is no live distribution of it. Two properties of
|
||||
the current implementation make the operator responsible for consistency:
|
||||
|
||||
- **One node writes the ACLs.** SG reconcile runs on a single CAS-elected vpcd leader, which programs the shared OVN northbound DB. Whichever node holds the lease is the one whose `blocked_ports_wan` / `egress_block_exempt_vpcs` is in force. Leadership moves on restart or crash, so if the node configs disagree, the effective policy **changes when the leader changes** and the drift pass flaps the ACLs between the two states. Edit the value **identically on every node**.
|
||||
- **It is read at vpcd startup, not hot-reloaded.** The policy is built once when vpcd starts; editing the TOML does nothing until vpcd restarts. Deploy the config change to all nodes and restart vpcd cluster-wide (take the target down and confirm no `spx` process survives — a selective single-service restart is not reliable on the shared binary).
|
||||
|
||||
This is the current implementation and is deliberately minimal. A future revision
|
||||
will move the exemption into shared cluster state (so a single edit propagates
|
||||
and cannot drift between nodes) and make it a per-account control rather than a
|
||||
per-VPC operator edit; until then, the two rules above are load-bearing.
|
||||
|
||||
## The Instance-to-Host Plane (Metadata and VPC DNS)
|
||||
|
||||
Security groups govern traffic **between instances** and **to the outside**. There
|
||||
is a third path they do not touch, and it is the one most easily left exposed:
|
||||
traffic from an instance to the **host it runs on**.
|
||||
|
||||
Every instance reaches two link-local addresses served by the hypervisor:
|
||||
|
||||
| Address | Service | Port |
|
||||
| --- | --- | --- |
|
||||
| `169.254.169.254` | Instance metadata (IMDS) — cloud-init, instance-role credentials | TCP 80 |
|
||||
| `169.254.169.253` | VPC DNS resolver | UDP/TCP 53 |
|
||||
|
||||
These are not guest addresses and not OVN routed. Each instance's tap has a
|
||||
capture rule on the hypervisor that intercepts any packet addressed to `.254` or
|
||||
`.253` and delivers it to a per-ENI internal port in the host network namespace
|
||||
(named `ime-*`). That interception is **by destination address only — it does not
|
||||
match the port.** A packet to `169.254.169.254:22` is delivered to the host just
|
||||
as readily as one to `169.254.169.254:80`.
|
||||
|
||||
That matters because host services — SSH, the AWS gateway on 9999, the console on
|
||||
3000, the DNS server — bind the wildcard address, so they also answer on these
|
||||
link-local addresses. Without a control on the `ime-*` path, an instance can
|
||||
reach every one of them, with none of the source scoping that protects the same
|
||||
services on the network. A resolver query sent straight to the host's DNS port
|
||||
this way also bypasses the per-instance DNS rate limit.
|
||||
|
||||
### This plane is invisible to a network firewall
|
||||
|
||||
The critical point for anyone securing a deployment: **this traffic never crosses
|
||||
the physical NIC.** It is intra-host, on the OVS bridge between the instance's tap
|
||||
and the `ime-*` port. A cloud security group, an upstream firewall, `ufw`, or any
|
||||
rule written against the WAN interface is not in this path and cannot filter it.
|
||||
The only place to enforce it is on the `ime-*` interface in the host itself.
|
||||
|
||||
### How it is protected
|
||||
|
||||
Spinifex's host firewall carries a rule that scopes the `ime-*` path to exactly
|
||||
the two legitimate endpoints and ports — IMDS on `.254:80`, DNS on `.253:53` —
|
||||
and drops everything else. Whether you have that rule depends entirely on whether
|
||||
the host firewall is on:
|
||||
|
||||
| Install path | Host firewall | This plane |
|
||||
| --- | --- | --- |
|
||||
| **From the ISO** | on | protected |
|
||||
| **Binary installer** (`curl \| bash`) or `setup.sh` | **off** | **exposed** |
|
||||
| **Binary installer with `--firewall=on`** | on | protected |
|
||||
|
||||
The ISO ships the firewall armed, so ISO deployments are protected out of the box.
|
||||
**The binary installer ships it off** — deliberately, because it runs on servers
|
||||
that may already have services the installer knows nothing about, and a
|
||||
default-deny policy could cut them off. The consequence is that a binary install
|
||||
left at its default has this plane wide open, and so does every host service on
|
||||
the WAN besides.
|
||||
|
||||
If you install any way other than the ISO, turn the host firewall on. At install
|
||||
time:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on
|
||||
# or, from a source checkout:
|
||||
sudo /usr/local/share/spinifex/setup.sh --firewall=on
|
||||
```
|
||||
|
||||
or afterwards, in `/etc/spinifex/spinifex.toml`, followed by a daemon restart:
|
||||
|
||||
```toml
|
||||
[network]
|
||||
firewall_enabled = true
|
||||
```
|
||||
|
||||
Before enabling it, check what else the machine is serving — anything listening
|
||||
outside the public port group stops accepting new connections. See
|
||||
[Firewall and Cluster Membership](/docs/install-multi-node#firewall-and-cluster-membership)
|
||||
for the full port policy and the cluster-formation steps.
|
||||
|
||||
### Verifying it
|
||||
|
||||
On the hypervisor, the `ime-*` accept should be scoped, not blanket:
|
||||
|
||||
```bash
|
||||
sudo nft list chain inet spinifex_filter input | grep ime
|
||||
# Expect three rules naming 169.254.169.254 tcp dport 80 and 169.254.169.253
|
||||
# udp/tcp dport 53 — not a bare `iifname "ime-*" accept`.
|
||||
```
|
||||
|
||||
From an instance, the two service ports work and nothing else does:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/ # 401 (IMDSv2)
|
||||
dig +short @169.254.169.253 example.com A # resolves
|
||||
nc -vz 169.254.169.254 22 # must fail
|
||||
```
|
||||
|
||||
A missing `spinifex_filter` table means the host firewall is off and this plane
|
||||
is unprotected regardless of the rule above.
|
||||
|
||||
## Elastic IPs
|
||||
|
||||
Elastic IPs are static public IPs that persist across instance stop/start cycles. Unlike auto-assigned public IPs (which change on stop/start), an Elastic IP stays with your instance.
|
||||
|
||||
@@ -82,12 +82,11 @@ Before creating a cluster you need the supporting infrastructure in place. The T
|
||||
- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and `kubectl` installed.
|
||||
- **The `eks-node` image** registered on the cluster. The console blocks cluster creation until it is present.
|
||||
- **A VPC with at least one subnet.** The control plane launches in your first subnet; workers run in the subnets you pass to the node group.
|
||||
- **Egress for the workers.** Workers must reach the internet to pull container images:
|
||||
- **Public workers** — an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`) and public IPs.
|
||||
- **Private workers** — a **NAT Gateway** in a public subnet with a default route (`0.0.0.0/0 → NAT`) from the private subnets.
|
||||
- **Two IAM roles:**
|
||||
- A **cluster role** trusted by `eks.amazonaws.com` with `AmazonEKSClusterPolicy` attached.
|
||||
- A **node role** trusted by `ec2.amazonaws.com` with `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly` attached (the last lets workers pull images from [ECR](/docs/ecr)).
|
||||
- **Egress for the workers.** Workers must reach the internet to pull container images.
|
||||
- **Public workers** need an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`) and public IPs.
|
||||
- **Private workers** need a **NAT Gateway** in a public subnet with a default route (`0.0.0.0/0 → NAT`) from the private subnets.
|
||||
- **A cluster role** trusted by `eks.amazonaws.com` with `AmazonEKSClusterPolicy` attached.
|
||||
- **A node role** trusted by `ec2.amazonaws.com` with `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly` attached (the last lets workers pull images from [ECR](/docs/ecr)).
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -273,4 +272,4 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
|
||||
|
||||
A complete end-to-end example of Spinifex EKS in production: a GPU-accelerated AI inference platform running on a Supermicro X14 2U with two NVIDIA RTX Pro 6000 Blackwell GPUs. The guide provisions an EKS cluster with two GPU worker nodes via VFIO PCIe passthrough, deploys an OpenAI-compatible LLM API (llama.cpp / Llama 3.2 3B) and a real-time YOLO object-detection stream on separate GPU nodes, and wires them together behind an ALB using IAM, ECR, ACM, and EBS — all with standard AWS tooling and a single `AWS_PROFILE` swap.
|
||||
|
||||
→ [EKS AI Platform Reference Architecture](/docs/RTX-Pro-6000-EKS)
|
||||
→ [EKS AI Platform Reference Architecture](/hardware/supermicro/rtx-pro-6000)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "AWS API Coverage"
|
||||
seoTitle: "Which AWS API Operations Spinifex Implements — Spinifex Docs"
|
||||
description: "The AWS API operations Spinifex serves, generated from its gateway dispatch tables and the pinned AWS SDK service models on each build, service by service."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- api
|
||||
- operations
|
||||
---
|
||||
|
||||
# AWS API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
The platform serves **381 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
|
||||
| Service | Operations |
|
||||
|---|---:|
|
||||
| [ACM](/coverage/acm) | 9 |
|
||||
| [EC2](/coverage/ec2) | 125 |
|
||||
| [ECR](/coverage/ecr) | 22 |
|
||||
| [ECS](/coverage/ecs) | 31 |
|
||||
| [EKS](/coverage/eks) | 34 |
|
||||
| [ELBv2](/coverage/elbv2) | 34 |
|
||||
| [IAM](/coverage/iam) | 76 |
|
||||
| [RDS](/coverage/rds) | 26 |
|
||||
| [S3](/coverage/s3) | 19 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **381** |
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "ACM API Coverage"
|
||||
seoTitle: "AWS Certificate Manager API Coverage — Spinifex Docs"
|
||||
description: "The AWS Certificate Manager API operations Spinifex implements, generated from the gateway dispatch tables and the pinned AWS service model on each build."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- acm
|
||||
- certificates
|
||||
- tls
|
||||
---
|
||||
|
||||
# ACM API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **9 operations** in the ACM `2015-12-08` API model.
|
||||
|
||||
### Import and issuance
|
||||
|
||||
Spinifex both stores externally-issued certificates and issues its own for load balancer listener references. Certificates are account-scoped, and a delete is refused while any listener still references the ARN — there is no force flag, matching AWS.
|
||||
|
||||
`RequestCertificate` mints an ARN immediately and returns `PENDING_VALIDATION`; it never issues inline except against a tenant private CA, which has no domain to validate. The validation mode is derived from deployment state rather than configured: the DNS provider API where a credential exists, a manual TXT record where the platform hosts the zone, and a private CA otherwise — the only option for a deployment with no publicly delegated domain.
|
||||
|
||||
Terraform's canonical certificate, DNS record and validation flow works unmodified in every mode. Where Spinifex owns the record write it emits no `ResourceRecord`, so iterating the validation options yields zero records and the validation resource still blocks correctly by polling until the certificate is issued.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AddTagsToCertificate` |
|
||||
| `DeleteCertificate` |
|
||||
| `DescribeCertificate` |
|
||||
| `GetCertificate` |
|
||||
| `ImportCertificate` |
|
||||
| `ListCertificates` |
|
||||
| `ListTagsForCertificate` |
|
||||
| `RemoveTagsFromCertificate` |
|
||||
| `RequestCertificate` |
|
||||
@@ -0,0 +1,7 @@
|
||||
### Import and issuance
|
||||
|
||||
Spinifex both stores externally-issued certificates and issues its own for load balancer listener references. Certificates are account-scoped, and a delete is refused while any listener still references the ARN — there is no force flag, matching AWS.
|
||||
|
||||
`RequestCertificate` mints an ARN immediately and returns `PENDING_VALIDATION`; it never issues inline except against a tenant private CA, which has no domain to validate. The validation mode is derived from deployment state rather than configured: the DNS provider API where a credential exists, a manual TXT record where the platform hosts the zone, and a private CA otherwise — the only option for a deployment with no publicly delegated domain.
|
||||
|
||||
Terraform's canonical certificate, DNS record and validation flow works unmodified in every mode. Where Spinifex owns the record write it emits no `ResourceRecord`, so iterating the validation options yields zero records and the validation resource still blocks correctly by polling until the certificate is issued.
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: "EC2 API Coverage"
|
||||
seoTitle: "Amazon EC2 API Coverage on Spinifex — Spinifex Docs"
|
||||
description: "The Amazon EC2 API operations Spinifex implements, covering instances, EBS volumes, VPC networking, tags, security groups and the rest of the compute surface."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- ec2
|
||||
- compute
|
||||
- vpc
|
||||
---
|
||||
|
||||
# EC2 API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **125 operations** in the EC2 `2016-11-15` API model.
|
||||
|
||||
### Spot Instances Are a Mock
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AllocateAddress` |
|
||||
| `AssociateAddress` |
|
||||
| `AssociateIamInstanceProfile` |
|
||||
| `AssociateRouteTable` |
|
||||
| `AttachInternetGateway` |
|
||||
| `AttachNetworkInterface` |
|
||||
| `AttachVolume` |
|
||||
| `AuthorizeSecurityGroupEgress` |
|
||||
| `AuthorizeSecurityGroupIngress` |
|
||||
| `CancelCapacityReservation` |
|
||||
| `CancelSpotInstanceRequests` |
|
||||
| `CopyImage` |
|
||||
| `CopySnapshot` |
|
||||
| `CreateCapacityReservation` |
|
||||
| `CreateEgressOnlyInternetGateway` |
|
||||
| `CreateImage` |
|
||||
| `CreateInternetGateway` |
|
||||
| `CreateKeyPair` |
|
||||
| `CreateLaunchTemplate` |
|
||||
| `CreateLaunchTemplateVersion` |
|
||||
| `CreateNatGateway` |
|
||||
| `CreateNetworkInterface` |
|
||||
| `CreatePlacementGroup` |
|
||||
| `CreateRoute` |
|
||||
| `CreateRouteTable` |
|
||||
| `CreateSecurityGroup` |
|
||||
| `CreateSnapshot` |
|
||||
| `CreateSubnet` |
|
||||
| `CreateTags` |
|
||||
| `CreateVolume` |
|
||||
| `CreateVpc` |
|
||||
| `DeleteEgressOnlyInternetGateway` |
|
||||
| `DeleteInternetGateway` |
|
||||
| `DeleteKeyPair` |
|
||||
| `DeleteLaunchTemplate` |
|
||||
| `DeleteLaunchTemplateVersions` |
|
||||
| `DeleteNatGateway` |
|
||||
| `DeleteNetworkInterface` |
|
||||
| `DeletePlacementGroup` |
|
||||
| `DeleteRoute` |
|
||||
| `DeleteRouteTable` |
|
||||
| `DeleteSecurityGroup` |
|
||||
| `DeleteSnapshot` |
|
||||
| `DeleteSubnet` |
|
||||
| `DeleteTags` |
|
||||
| `DeleteVolume` |
|
||||
| `DeleteVpc` |
|
||||
| `DeregisterImage` |
|
||||
| `DescribeAccountAttributes` |
|
||||
| `DescribeAddresses` |
|
||||
| `DescribeAddressesAttribute` |
|
||||
| `DescribeAvailabilityZones` |
|
||||
| `DescribeCapacityReservations` |
|
||||
| `DescribeEgressOnlyInternetGateways` |
|
||||
| `DescribeIamInstanceProfileAssociations` |
|
||||
| `DescribeImageAttribute` |
|
||||
| `DescribeImages` |
|
||||
| `DescribeInstanceAttribute` |
|
||||
| `DescribeInstanceCreditSpecifications` |
|
||||
| `DescribeInstanceStatus` |
|
||||
| `DescribeInstanceTypeOfferings` |
|
||||
| `DescribeInstanceTypes` |
|
||||
| `DescribeInstances` |
|
||||
| `DescribeInternetGateways` |
|
||||
| `DescribeKeyPairs` |
|
||||
| `DescribeLaunchTemplateVersions` |
|
||||
| `DescribeLaunchTemplates` |
|
||||
| `DescribeNatGateways` |
|
||||
| `DescribeNetworkInterfaces` |
|
||||
| `DescribePlacementGroups` |
|
||||
| `DescribeRegions` |
|
||||
| `DescribeRouteTables` |
|
||||
| `DescribeSecurityGroupRules` |
|
||||
| `DescribeSecurityGroups` |
|
||||
| `DescribeSnapshots` |
|
||||
| `DescribeSpotInstanceRequests` |
|
||||
| `DescribeSubnets` |
|
||||
| `DescribeTags` |
|
||||
| `DescribeVolumeStatus` |
|
||||
| `DescribeVolumes` |
|
||||
| `DescribeVolumesModifications` |
|
||||
| `DescribeVpcAttribute` |
|
||||
| `DescribeVpcs` |
|
||||
| `DetachInternetGateway` |
|
||||
| `DetachNetworkInterface` |
|
||||
| `DetachVolume` |
|
||||
| `DisableEbsEncryptionByDefault` |
|
||||
| `DisableSerialConsoleAccess` |
|
||||
| `DisassociateAddress` |
|
||||
| `DisassociateIamInstanceProfile` |
|
||||
| `DisassociateRouteTable` |
|
||||
| `EnableEbsEncryptionByDefault` |
|
||||
| `EnableSerialConsoleAccess` |
|
||||
| `GetConsoleOutput` |
|
||||
| `GetEbsEncryptionByDefault` |
|
||||
| `GetPasswordData` |
|
||||
| `GetSecurityGroupsForVpc` |
|
||||
| `GetSerialConsoleAccessStatus` |
|
||||
| `ImportKeyPair` |
|
||||
| `ModifyImageAttribute` |
|
||||
| `ModifyInstanceAttribute` |
|
||||
| `ModifyInstanceMetadataOptions` |
|
||||
| `ModifyLaunchTemplate` |
|
||||
| `ModifyNetworkInterfaceAttribute` |
|
||||
| `ModifySubnetAttribute` |
|
||||
| `ModifyVolume` |
|
||||
| `ModifyVpcAttribute` |
|
||||
| `MonitorInstances` |
|
||||
| `RebootInstances` |
|
||||
| `RegisterImage` |
|
||||
| `ReleaseAddress` |
|
||||
| `ReplaceIamInstanceProfileAssociation` |
|
||||
| `ReplaceRoute` |
|
||||
| `ReplaceRouteTableAssociation` |
|
||||
| `RequestSpotInstances` |
|
||||
| `ResetImageAttribute` |
|
||||
| `RevokeSecurityGroupEgress` |
|
||||
| `RevokeSecurityGroupIngress` |
|
||||
| `RunInstances` |
|
||||
| `StartInstances` |
|
||||
| `StopInstances` |
|
||||
| `TerminateInstances` |
|
||||
| `UnmonitorInstances` |
|
||||
| `UpdateSecurityGroupRuleDescriptionsEgress` |
|
||||
| `UpdateSecurityGroupRuleDescriptionsIngress` |
|
||||
@@ -0,0 +1,3 @@
|
||||
### Spot Instances Are a Mock
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "ECR API Coverage"
|
||||
seoTitle: "Elastic Container Registry API Coverage — Spinifex Docs"
|
||||
description: "The Amazon ECR API operations Spinifex implements, alongside the OCI distribution endpoint that carries the image layers for every repository it serves."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- ecr
|
||||
- containers
|
||||
- registry
|
||||
---
|
||||
|
||||
# ECR API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **22 operations** in the ECR `2015-09-21` API model.
|
||||
|
||||
### Two endpoints, one registry
|
||||
|
||||
Repository metadata is served over the AWS API on the gateway endpoint. Image data moves over the OCI Distribution `/v2/` endpoint on that same host, authenticated by the bearer token `GetAuthorizationToken` mints for `docker login`.
|
||||
|
||||
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
|
||||
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `BatchDeleteImage` |
|
||||
| `BatchGetImage` |
|
||||
| `CreateRepository` |
|
||||
| `DeleteLifecyclePolicy` |
|
||||
| `DeleteRepository` |
|
||||
| `DeleteRepositoryPolicy` |
|
||||
| `DescribeImages` |
|
||||
| `DescribeRepositories` |
|
||||
| `GetAuthorizationToken` |
|
||||
| `GetLifecyclePolicy` |
|
||||
| `GetLifecyclePolicyPreview` |
|
||||
| `GetRepositoryPolicy` |
|
||||
| `ListImages` |
|
||||
| `ListTagsForResource` |
|
||||
| `PutImage` |
|
||||
| `PutImageScanningConfiguration` |
|
||||
| `PutImageTagMutability` |
|
||||
| `PutLifecyclePolicy` |
|
||||
| `SetRepositoryPolicy` |
|
||||
| `StartLifecyclePolicyPreview` |
|
||||
| `TagResource` |
|
||||
| `UntagResource` |
|
||||
@@ -0,0 +1,7 @@
|
||||
### Two endpoints, one registry
|
||||
|
||||
Repository metadata is served over the AWS API on the gateway endpoint. Image data moves over the OCI Distribution `/v2/` endpoint on that same host, authenticated by the bearer token `GetAuthorizationToken` mints for `docker login`.
|
||||
|
||||
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
|
||||
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "ECS API Coverage"
|
||||
seoTitle: "Elastic Container Service API Coverage — Spinifex Docs"
|
||||
description: "The Amazon ECS API operations Spinifex implements, covering clusters, services, tasks, container instances and the task definitions they are launched from."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- ecs
|
||||
- containers
|
||||
- orchestration
|
||||
---
|
||||
|
||||
# ECS API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **31 operations** in the ECS `2014-11-13` API model.
|
||||
|
||||
### No Fargate
|
||||
|
||||
Clusters, services and tasks run on EC2 container instances. There is no Fargate launch type, and no operation below provides one.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `CreateCapacityProvider` |
|
||||
| `CreateCluster` |
|
||||
| `CreateService` |
|
||||
| `DeleteCapacityProvider` |
|
||||
| `DeleteCluster` |
|
||||
| `DeleteService` |
|
||||
| `DeregisterContainerInstance` |
|
||||
| `DeregisterTaskDefinition` |
|
||||
| `DescribeCapacityProviders` |
|
||||
| `DescribeClusters` |
|
||||
| `DescribeContainerInstances` |
|
||||
| `DescribeServices` |
|
||||
| `DescribeTaskDefinition` |
|
||||
| `DescribeTasks` |
|
||||
| `ListClusters` |
|
||||
| `ListContainerInstances` |
|
||||
| `ListServices` |
|
||||
| `ListTagsForResource` |
|
||||
| `ListTaskDefinitions` |
|
||||
| `ListTasks` |
|
||||
| `PutClusterCapacityProviders` |
|
||||
| `RegisterContainerInstance` |
|
||||
| `RegisterTaskDefinition` |
|
||||
| `RunTask` |
|
||||
| `StartTask` |
|
||||
| `StopTask` |
|
||||
| `SubmitTaskStateChange` |
|
||||
| `TagResource` |
|
||||
| `UntagResource` |
|
||||
| `UpdateContainerInstancesState` |
|
||||
| `UpdateService` |
|
||||
@@ -0,0 +1,3 @@
|
||||
### No Fargate
|
||||
|
||||
Clusters, services and tasks run on EC2 container instances. There is no Fargate launch type, and no operation below provides one.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "EKS API Coverage"
|
||||
seoTitle: "Elastic Kubernetes Service API Coverage — Spinifex Docs"
|
||||
description: "The Amazon EKS API operations Spinifex implements, covering clusters, nodegroups, add-ons, access entries and the identity providers they authenticate."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- eks
|
||||
- kubernetes
|
||||
- containers
|
||||
---
|
||||
|
||||
# EKS API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AssociateAccessPolicy` |
|
||||
| `AssociateIdentityProviderConfig` |
|
||||
| `CreateAccessEntry` |
|
||||
| `CreateAddon` |
|
||||
| `CreateCluster` |
|
||||
| `CreateNodegroup` |
|
||||
| `DeleteAccessEntry` |
|
||||
| `DeleteAddon` |
|
||||
| `DeleteCluster` |
|
||||
| `DeleteNodegroup` |
|
||||
| `DescribeAccessEntry` |
|
||||
| `DescribeAddon` |
|
||||
| `DescribeAddonVersions` |
|
||||
| `DescribeCluster` |
|
||||
| `DescribeIdentityProviderConfig` |
|
||||
| `DescribeNodegroup` |
|
||||
| `DisassociateAccessPolicy` |
|
||||
| `DisassociateIdentityProviderConfig` |
|
||||
| `ListAccessEntries` |
|
||||
| `ListAccessPolicies` |
|
||||
| `ListAddons` |
|
||||
| `ListAssociatedAccessPolicies` |
|
||||
| `ListClusters` |
|
||||
| `ListIdentityProviderConfigs` |
|
||||
| `ListNodegroups` |
|
||||
| `ListTagsForResource` |
|
||||
| `TagResource` |
|
||||
| `UntagResource` |
|
||||
| `UpdateAccessEntry` |
|
||||
| `UpdateAddon` |
|
||||
| `UpdateClusterConfig` |
|
||||
| `UpdateClusterVersion` |
|
||||
| `UpdateNodegroupConfig` |
|
||||
| `UpdateNodegroupVersion` |
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "ELBv2 API Coverage"
|
||||
seoTitle: "Elastic Load Balancing v2 API Coverage — Spinifex Docs"
|
||||
description: "The Elastic Load Balancing v2 API operations Spinifex implements, for both the Application and Network Load Balancers it serves, listeners and target groups."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- elbv2
|
||||
- load balancing
|
||||
- networking
|
||||
---
|
||||
|
||||
# ELBv2 API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **34 operations** in the ELBv2 `2015-12-01` API model.
|
||||
|
||||
### Two data planes
|
||||
|
||||
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AddListenerCertificates` |
|
||||
| `AddTags` |
|
||||
| `CreateListener` |
|
||||
| `CreateLoadBalancer` |
|
||||
| `CreateRule` |
|
||||
| `CreateTargetGroup` |
|
||||
| `DeleteListener` |
|
||||
| `DeleteLoadBalancer` |
|
||||
| `DeleteRule` |
|
||||
| `DeleteTargetGroup` |
|
||||
| `DeregisterTargets` |
|
||||
| `DescribeAccountLimits` |
|
||||
| `DescribeListenerCertificates` |
|
||||
| `DescribeListeners` |
|
||||
| `DescribeLoadBalancerAttributes` |
|
||||
| `DescribeLoadBalancers` |
|
||||
| `DescribeRules` |
|
||||
| `DescribeSSLPolicies` |
|
||||
| `DescribeTags` |
|
||||
| `DescribeTargetGroupAttributes` |
|
||||
| `DescribeTargetGroups` |
|
||||
| `DescribeTargetHealth` |
|
||||
| `ModifyListener` |
|
||||
| `ModifyLoadBalancerAttributes` |
|
||||
| `ModifyRule` |
|
||||
| `ModifyTargetGroup` |
|
||||
| `ModifyTargetGroupAttributes` |
|
||||
| `RegisterTargets` |
|
||||
| `RemoveListenerCertificates` |
|
||||
| `RemoveTags` |
|
||||
| `SetIpAddressType` |
|
||||
| `SetRulePriorities` |
|
||||
| `SetSecurityGroups` |
|
||||
| `SetSubnets` |
|
||||
@@ -0,0 +1,3 @@
|
||||
### Two data planes
|
||||
|
||||
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "IAM API Coverage"
|
||||
seoTitle: "AWS IAM API Operation Coverage on Spinifex — Spinifex Docs"
|
||||
description: "The AWS IAM API operations Spinifex implements, covering users, roles, policies, groups, instance profiles and the access keys that authenticate them."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- iam
|
||||
- identity
|
||||
- policies
|
||||
---
|
||||
|
||||
# IAM API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **76 operations** in the IAM `2010-05-08` API model.
|
||||
|
||||
### Scope
|
||||
|
||||
All IAM operations are account-scoped. The root user of an account bypasses policy evaluation entirely, as it does on AWS.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AddRoleToInstanceProfile` |
|
||||
| `AddUserToGroup` |
|
||||
| `AttachGroupPolicy` |
|
||||
| `AttachRolePolicy` |
|
||||
| `AttachUserPolicy` |
|
||||
| `CreateAccessKey` |
|
||||
| `CreateGroup` |
|
||||
| `CreateInstanceProfile` |
|
||||
| `CreateOpenIDConnectProvider` |
|
||||
| `CreatePolicy` |
|
||||
| `CreateRole` |
|
||||
| `CreateUser` |
|
||||
| `DeleteAccessKey` |
|
||||
| `DeleteGroup` |
|
||||
| `DeleteGroupPolicy` |
|
||||
| `DeleteInstanceProfile` |
|
||||
| `DeleteOpenIDConnectProvider` |
|
||||
| `DeletePolicy` |
|
||||
| `DeleteRole` |
|
||||
| `DeleteRolePolicy` |
|
||||
| `DeleteUser` |
|
||||
| `DeleteUserPolicy` |
|
||||
| `DetachGroupPolicy` |
|
||||
| `DetachRolePolicy` |
|
||||
| `DetachUserPolicy` |
|
||||
| `GetAccountSummary` |
|
||||
| `GetGroup` |
|
||||
| `GetGroupPolicy` |
|
||||
| `GetInstanceProfile` |
|
||||
| `GetOpenIDConnectProvider` |
|
||||
| `GetPolicy` |
|
||||
| `GetPolicyVersion` |
|
||||
| `GetRole` |
|
||||
| `GetRolePolicy` |
|
||||
| `GetUser` |
|
||||
| `GetUserPolicy` |
|
||||
| `ListAccessKeys` |
|
||||
| `ListAttachedGroupPolicies` |
|
||||
| `ListAttachedRolePolicies` |
|
||||
| `ListAttachedUserPolicies` |
|
||||
| `ListEntitiesForPolicy` |
|
||||
| `ListGroupPolicies` |
|
||||
| `ListGroups` |
|
||||
| `ListGroupsForUser` |
|
||||
| `ListInstanceProfileTags` |
|
||||
| `ListInstanceProfiles` |
|
||||
| `ListInstanceProfilesForRole` |
|
||||
| `ListOpenIDConnectProviderTags` |
|
||||
| `ListOpenIDConnectProviders` |
|
||||
| `ListPolicies` |
|
||||
| `ListPolicyTags` |
|
||||
| `ListPolicyVersions` |
|
||||
| `ListRolePolicies` |
|
||||
| `ListRoleTags` |
|
||||
| `ListRoles` |
|
||||
| `ListUserPolicies` |
|
||||
| `ListUserTags` |
|
||||
| `ListUsers` |
|
||||
| `PutGroupPolicy` |
|
||||
| `PutRolePolicy` |
|
||||
| `PutUserPolicy` |
|
||||
| `RemoveRoleFromInstanceProfile` |
|
||||
| `RemoveUserFromGroup` |
|
||||
| `TagInstanceProfile` |
|
||||
| `TagOpenIDConnectProvider` |
|
||||
| `TagPolicy` |
|
||||
| `TagRole` |
|
||||
| `TagUser` |
|
||||
| `UntagInstanceProfile` |
|
||||
| `UntagOpenIDConnectProvider` |
|
||||
| `UntagPolicy` |
|
||||
| `UntagRole` |
|
||||
| `UntagUser` |
|
||||
| `UpdateAccessKey` |
|
||||
| `UpdateAssumeRolePolicy` |
|
||||
| `UpdateRole` |
|
||||
@@ -0,0 +1,3 @@
|
||||
### Scope
|
||||
|
||||
All IAM operations are account-scoped. The root user of an account bypasses policy evaluation entirely, as it does on AWS.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"category": "Coverage",
|
||||
"basePath": "/coverage",
|
||||
"index": {
|
||||
"name": "All services",
|
||||
"title": "AWS API Coverage",
|
||||
"seoTitle": "Which AWS API Operations Spinifex Implements — Spinifex Docs",
|
||||
"description": "The AWS API operations Spinifex serves, generated from its gateway dispatch tables and the pinned AWS SDK service models on each build, service by service.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"api",
|
||||
"operations"
|
||||
]
|
||||
},
|
||||
"services": {
|
||||
"acm": {
|
||||
"slug": "acm",
|
||||
"name": "ACM",
|
||||
"title": "ACM API Coverage",
|
||||
"seoTitle": "AWS Certificate Manager API Coverage — Spinifex Docs",
|
||||
"description": "The AWS Certificate Manager API operations Spinifex implements, generated from the gateway dispatch tables and the pinned AWS service model on each build.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"acm",
|
||||
"certificates",
|
||||
"tls"
|
||||
]
|
||||
},
|
||||
"ec2": {
|
||||
"slug": "ec2",
|
||||
"name": "EC2",
|
||||
"title": "EC2 API Coverage",
|
||||
"seoTitle": "Amazon EC2 API Coverage on Spinifex — Spinifex Docs",
|
||||
"description": "The Amazon EC2 API operations Spinifex implements, covering instances, EBS volumes, VPC networking, tags, security groups and the rest of the compute surface.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"ec2",
|
||||
"compute",
|
||||
"vpc"
|
||||
],
|
||||
"notApplicable": {
|
||||
"AttachClassicLinkVpc": "ec2-classic",
|
||||
"CancelReservedInstancesListing": "ri-marketplace",
|
||||
"ConfirmProductInstance": "marketplace",
|
||||
"CopyFpgaImage": "fpga",
|
||||
"CreateCarrierGateway": "aws-edge",
|
||||
"CreateFpgaImage": "fpga",
|
||||
"CreateReservedInstancesListing": "ri-marketplace",
|
||||
"DeleteCarrierGateway": "aws-edge",
|
||||
"DeleteFpgaImage": "fpga",
|
||||
"DescribeAggregateIdFormat": "id-format",
|
||||
"DescribeAwsNetworkPerformanceMetricSubscriptions": "aws-backbone",
|
||||
"DescribeCarrierGateways": "aws-edge",
|
||||
"DescribeClassicLinkInstances": "ec2-classic",
|
||||
"DescribeElasticGpus": "retired-accelerators",
|
||||
"DescribeFpgaImageAttribute": "fpga",
|
||||
"DescribeFpgaImages": "fpga",
|
||||
"DescribeIdFormat": "id-format",
|
||||
"DescribeIdentityIdFormat": "id-format",
|
||||
"DescribeMovingAddresses": "ec2-classic",
|
||||
"DescribePrincipalIdFormat": "id-format",
|
||||
"DescribeReservedInstancesListings": "ri-marketplace",
|
||||
"DescribeSpotPriceHistory": "spot-price",
|
||||
"DescribeVpcClassicLink": "ec2-classic",
|
||||
"DescribeVpcClassicLinkDnsSupport": "ec2-classic",
|
||||
"DetachClassicLinkVpc": "ec2-classic",
|
||||
"DisableAwsNetworkPerformanceMetricSubscription": "aws-backbone",
|
||||
"DisableVpcClassicLink": "ec2-classic",
|
||||
"DisableVpcClassicLinkDnsSupport": "ec2-classic",
|
||||
"EnableAwsNetworkPerformanceMetricSubscription": "aws-backbone",
|
||||
"EnableVpcClassicLink": "ec2-classic",
|
||||
"EnableVpcClassicLinkDnsSupport": "ec2-classic",
|
||||
"GetAwsNetworkPerformanceData": "aws-backbone",
|
||||
"ModifyFpgaImageAttribute": "fpga",
|
||||
"ModifyIdFormat": "id-format",
|
||||
"ModifyIdentityIdFormat": "id-format",
|
||||
"MoveAddressToVpc": "ec2-classic",
|
||||
"ReportInstanceStatus": "aws-support",
|
||||
"ResetFpgaImageAttribute": "fpga",
|
||||
"RestoreAddressToClassic": "ec2-classic"
|
||||
},
|
||||
"notes": {
|
||||
"aws-backbone": "These report performance across AWS's own global network between its regions and zones.",
|
||||
"aws-edge": "Carrier gateways route to a telecommunications carrier's 5G network in an AWS Wavelength Zone.",
|
||||
"aws-support": "Reporting an instance as impaired files a report with AWS support about AWS hardware.",
|
||||
"ec2-classic": "EC2-Classic is the pre-VPC network AWS retired in 2022. Every instance here is in a VPC.",
|
||||
"fpga": "FPGA images are built and ingested through AWS's own AFI pipeline for its F1 instances.",
|
||||
"id-format": "These switched resource IDs between the short and long forms during AWS's 2016 migration, which has long since finished.",
|
||||
"marketplace": "Product codes belong to AWS Marketplace, which has no equivalent here.",
|
||||
"retired-accelerators": "Elastic Graphics was retired by AWS, and attached nothing but an AWS-hosted accelerator while it existed.",
|
||||
"ri-marketplace": "The Reserved Instance Marketplace resells a reservation to another AWS customer, so AWS is the counterparty.",
|
||||
"spot-price": "On owned hardware there is no spot-to-on-demand price differential, so any figure would be invented."
|
||||
}
|
||||
},
|
||||
"ecr": {
|
||||
"slug": "ecr",
|
||||
"name": "ECR",
|
||||
"title": "ECR API Coverage",
|
||||
"seoTitle": "Elastic Container Registry API Coverage — Spinifex Docs",
|
||||
"description": "The Amazon ECR API operations Spinifex implements, alongside the OCI distribution endpoint that carries the image layers for every repository it serves.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"ecr",
|
||||
"containers",
|
||||
"registry"
|
||||
]
|
||||
},
|
||||
"ecs": {
|
||||
"slug": "ecs",
|
||||
"name": "ECS",
|
||||
"title": "ECS API Coverage",
|
||||
"seoTitle": "Elastic Container Service API Coverage — Spinifex Docs",
|
||||
"description": "The Amazon ECS API operations Spinifex implements, covering clusters, services, tasks, container instances and the task definitions they are launched from.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"ecs",
|
||||
"containers",
|
||||
"orchestration"
|
||||
],
|
||||
"internal": [
|
||||
"PollAssignments",
|
||||
"ProvisionCapacity",
|
||||
"ReportTaskGPU"
|
||||
]
|
||||
},
|
||||
"eks": {
|
||||
"slug": "eks",
|
||||
"name": "EKS",
|
||||
"title": "EKS API Coverage",
|
||||
"seoTitle": "Elastic Kubernetes Service API Coverage — Spinifex Docs",
|
||||
"description": "The Amazon EKS API operations Spinifex implements, covering clusters, nodegroups, add-ons, access entries and the identity providers they authenticate.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"eks",
|
||||
"kubernetes",
|
||||
"containers"
|
||||
],
|
||||
"internal": [
|
||||
"GetRecoveryDirective",
|
||||
"ListInternalAddons",
|
||||
"PublishInternal",
|
||||
"WebhookTokenReview"
|
||||
]
|
||||
},
|
||||
"elasticloadbalancingv2": {
|
||||
"slug": "elbv2",
|
||||
"name": "ELBv2",
|
||||
"title": "ELBv2 API Coverage",
|
||||
"seoTitle": "Elastic Load Balancing v2 API Coverage — Spinifex Docs",
|
||||
"description": "The Elastic Load Balancing v2 API operations Spinifex implements, for both the Application and Network Load Balancers it serves, listeners and target groups.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"elbv2",
|
||||
"load balancing",
|
||||
"networking"
|
||||
],
|
||||
"internal": [
|
||||
"GetLBConfig",
|
||||
"LBAgentHeartbeat"
|
||||
]
|
||||
},
|
||||
"iam": {
|
||||
"slug": "iam",
|
||||
"name": "IAM",
|
||||
"title": "IAM API Coverage",
|
||||
"seoTitle": "AWS IAM API Operation Coverage on Spinifex — Spinifex Docs",
|
||||
"description": "The AWS IAM API operations Spinifex implements, covering users, roles, policies, groups, instance profiles and the access keys that authenticate them.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"iam",
|
||||
"identity",
|
||||
"policies"
|
||||
],
|
||||
"notApplicable": {
|
||||
"CreateServiceLinkedRole": "service-linked-roles",
|
||||
"CreateServiceSpecificCredential": "codecommit",
|
||||
"DeleteSSHPublicKey": "codecommit",
|
||||
"DeleteServiceLinkedRole": "service-linked-roles",
|
||||
"DeleteServiceSpecificCredential": "codecommit",
|
||||
"DeleteSigningCertificate": "signing-certificates",
|
||||
"GenerateOrganizationsAccessReport": "organizations",
|
||||
"GetOrganizationsAccessReport": "organizations",
|
||||
"GetSSHPublicKey": "codecommit",
|
||||
"GetServiceLinkedRoleDeletionStatus": "service-linked-roles",
|
||||
"ListSSHPublicKeys": "codecommit",
|
||||
"ListServiceSpecificCredentials": "codecommit",
|
||||
"ListSigningCertificates": "signing-certificates",
|
||||
"ResetServiceSpecificCredential": "codecommit",
|
||||
"UpdateSSHPublicKey": "codecommit",
|
||||
"UpdateServiceSpecificCredential": "codecommit",
|
||||
"UpdateSigningCertificate": "signing-certificates",
|
||||
"UploadSSHPublicKey": "codecommit",
|
||||
"UploadSigningCertificate": "signing-certificates"
|
||||
},
|
||||
"notes": {
|
||||
"codecommit": "CodeCommit credentials and SSH keys; the service does not exist here.",
|
||||
"organizations": "AWS Organizations is not offered, so there is no organization to report on.",
|
||||
"service-linked-roles": "Service-linked roles exist for AWS service principals, which this platform has none of.",
|
||||
"signing-certificates": "X.509 signing certificates are an EC2-Classic SOAP credential, retired by AWS."
|
||||
}
|
||||
},
|
||||
"rds": {
|
||||
"slug": "rds",
|
||||
"name": "RDS",
|
||||
"title": "RDS API Coverage",
|
||||
"seoTitle": "Relational Database Service API Coverage — Spinifex Docs",
|
||||
"description": "The Amazon RDS API operations Spinifex implements, for the managed PostgreSQL and MariaDB engines it offers, covering instances, snapshots and parameters.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"rds",
|
||||
"databases",
|
||||
"postgresql",
|
||||
"mariadb"
|
||||
],
|
||||
"internal": [
|
||||
"AcknowledgeDBBootstrap",
|
||||
"GetDBBootstrapConfig",
|
||||
"PollDBCommands",
|
||||
"RegisterDBInstance",
|
||||
"SubmitDBStateChange"
|
||||
],
|
||||
"notApplicable": {
|
||||
"CreateOptionGroup": "option-groups",
|
||||
"DeleteOptionGroup": "option-groups",
|
||||
"DescribeOptionGroups": "option-groups",
|
||||
"ModifyOptionGroup": "option-groups"
|
||||
},
|
||||
"notes": {
|
||||
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
|
||||
}
|
||||
},
|
||||
"s3": {
|
||||
"slug": "s3",
|
||||
"name": "S3",
|
||||
"title": "S3 API Coverage",
|
||||
"seoTitle": "Amazon S3 API Coverage on Spinifex — Spinifex Docs",
|
||||
"description": "The Amazon S3 API operations Predastore serves on the platform's S3 endpoint, covering buckets, objects, multipart uploads and the policies that guard them.",
|
||||
"implementedBy": "Predastore",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"s3",
|
||||
"storage",
|
||||
"objects"
|
||||
],
|
||||
"notApplicable": {
|
||||
"CreateSession": "express-one-zone",
|
||||
"GetBucketAccelerateConfiguration": "accelerate",
|
||||
"GetBucketRequestPayment": "request-payment",
|
||||
"PutBucketAccelerateConfiguration": "accelerate",
|
||||
"PutBucketRequestPayment": "request-payment",
|
||||
"WriteGetObjectResponse": "object-lambda"
|
||||
},
|
||||
"notes": {
|
||||
"accelerate": "Transfer acceleration routes uploads over AWS edge locations, which an on-premise deployment has none of.",
|
||||
"express-one-zone": "Sessions authenticate S3 Express One Zone directory buckets, a storage class this platform does not offer.",
|
||||
"object-lambda": "S3 Object Lambda rewrites a response from a Lambda function, and Lambda is not offered.",
|
||||
"request-payment": "Requester Pays shifts transfer charges to the caller, which needs AWS billing behind it."
|
||||
}
|
||||
},
|
||||
"sts": {
|
||||
"slug": "sts",
|
||||
"name": "STS",
|
||||
"title": "STS API Coverage",
|
||||
"seoTitle": "AWS Security Token Service API Coverage — Spinifex Docs",
|
||||
"description": "The AWS STS API operations Spinifex implements, covering role assumption, session tokens and web identity federation (IRSA), with those it does not offer.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"sts",
|
||||
"identity",
|
||||
"credentials"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: "RDS API Coverage"
|
||||
seoTitle: "Relational Database Service API Coverage — Spinifex Docs"
|
||||
description: "The Amazon RDS API operations Spinifex implements, for the managed PostgreSQL and MariaDB engines it offers, covering instances, snapshots and parameters."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- rds
|
||||
- databases
|
||||
- postgresql
|
||||
- mariadb
|
||||
---
|
||||
|
||||
# RDS API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **26 operations** in the RDS `2014-10-31` API model.
|
||||
|
||||
### Engines
|
||||
|
||||
Spinifex offers PostgreSQL and MariaDB. Each DB instance is one dedicated system-owned VM running the engine directly, launched from a platform AMI and hidden from the customer's EC2 API. `Engine` is fixed at create: there is no in-place engine change, no cross-engine snapshot restore and no migration between the two.
|
||||
|
||||
`mysql` is not an accepted engine and is not an alias for `mariadb`. MariaDB is offered under its own AWS engine name, exactly as AWS RDS offers it, so a client — including Terraform's `aws_db_instance` — must set `engine = "mariadb"`.
|
||||
|
||||
Engine versions are pinned per engine. An `EngineVersion` naming anything but the pin is rejected, including a narrower minor version, because the AMI makes no promise about which minor it carries.
|
||||
|
||||
### The endpoint is private
|
||||
|
||||
The engine is reached over a customer-account ENI injected into a subnet of the DB subnet group, so the endpoint is reachable from inside the VPC only. The DB VM has other NICs that no customer security group governs, and the engine binds none of them — the port is not open there at all, rather than open and gated.
|
||||
|
||||
### TLS is required by default
|
||||
|
||||
Both engines enforce encrypted connections by default: `rds.force_ssl` on PostgreSQL and `require_secure_transport` on MariaDB. For MariaDB this is a deliberate divergence from AWS, which leaves it off. Both are boolean, modifiable and dynamic, so setting either to `0` in a parameter group restores plaintext without a reboot.
|
||||
|
||||
### Rejected parameters
|
||||
|
||||
A parameter whose omission would create a false safety, security or availability guarantee is rejected with `InvalidParameterValue` rather than silently dropped.
|
||||
|
||||
| Parameter | Why it is rejected |
|
||||
|-----------|--------------------|
|
||||
| `MultiAZ=true` | Single-AZ platform; a standby would not exist |
|
||||
| `PubliclyAccessible=true` | The endpoint is a private VPC address |
|
||||
| `StorageEncrypted=false` | Unencrypted storage is not offered |
|
||||
| `EnableIAMDatabaseAuthentication` | IAM database authentication is not implemented |
|
||||
| `Iops`, `StorageThroughput`, `StorageType` ≠ `gp3` | Provisioned performance classes are not implemented |
|
||||
| `KmsKeyId`, `TdeCredentialArn` | Storage is encrypted with the cluster key, not a customer-managed one |
|
||||
| `AvailabilityZone` | The platform exposes a single zone |
|
||||
| `AvailabilityZoneGroup` (orderable options) | It selects a zone or local-zone group, and naming a zone is already refused |
|
||||
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
|
||||
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
|
||||
| `EnableCloudwatchLogsExports` | Log export is not implemented |
|
||||
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
|
||||
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
|
||||
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
|
||||
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
|
||||
| `MaxAllocatedStorage` | Storage autoscaling is not implemented |
|
||||
| `ManageMasterUserPassword`, `RotateMasterUserPassword` | Secrets Manager integration is not offered |
|
||||
| `CACertificateIdentifier` | The serving certificate is minted from the cluster CA |
|
||||
| `Domain`, `DomainFqdn` | Active Directory domain join is not offered |
|
||||
| `OptionGroupName` | Option groups are not offered |
|
||||
| `CustomIamInstanceProfile` | The DB VM's instance profile is platform-owned |
|
||||
| `EnableCustomerOwnedIp` | An Outposts feature |
|
||||
| `ForceFailover` (reboot) | No standby to fail over to |
|
||||
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AddTagsToResource` |
|
||||
| `CreateDBInstance` |
|
||||
| `CreateDBParameterGroup` |
|
||||
| `CreateDBSnapshot` |
|
||||
| `CreateDBSubnetGroup` |
|
||||
| `DeleteDBInstance` |
|
||||
| `DeleteDBParameterGroup` |
|
||||
| `DeleteDBSnapshot` |
|
||||
| `DeleteDBSubnetGroup` |
|
||||
| `DescribeDBEngineVersions` |
|
||||
| `DescribeDBInstanceAutomatedBackups` |
|
||||
| `DescribeDBInstances` |
|
||||
| `DescribeDBParameterGroups` |
|
||||
| `DescribeDBParameters` |
|
||||
| `DescribeDBSnapshots` |
|
||||
| `DescribeDBSubnetGroups` |
|
||||
| `DescribeEvents` |
|
||||
| `DescribeOrderableDBInstanceOptions` |
|
||||
| `ListTagsForResource` |
|
||||
| `ModifyDBInstance` |
|
||||
| `ModifyDBParameterGroup` |
|
||||
| `RebootDBInstance` |
|
||||
| `RemoveTagsFromResource` |
|
||||
| `RestoreDBInstanceFromDBSnapshot` |
|
||||
| `StartDBInstance` |
|
||||
| `StopDBInstance` |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user