Compare commits

..
Author SHA1 Message Date
jschoubben b2af3d654f Trust a trunk whose whitelist adds the operator's own login, never an agent's (ADR 0534 §2, §6)
The gitea module keeps the operator's login beside mesh-tools on every main so
the operator can merge by hand; an agent's account there is still refused.
2026-10-23 12:10:11 +02:00
jschoubben 7676e67c5e Trust a module's trunk for who can merge into it, not for a required status (ADR 0534 §6)
The forge requires no status to merge any more (ADR 0534 §1); a trunk is
trusted when it takes no push, its merge whitelist names mesh-tools alone and
no administrator merges past its rule, so only mesh-delivery lands a commit
there, on a passing mesh/check and the operator's Approve. The old rule failed
the 11:51 delivery plan once mesh-catalog's status was unticked.
2026-10-23 12:02:00 +02:00
7 changed files with 77 additions and 271 deletions
+29 -6
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
@@ -205,8 +206,9 @@ func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat str
type forgeFacts struct { type forgeFacts struct {
// ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name.
ID int64 ID int64
// Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one // Guarded is whether the branch is protected as a module's trunk must be: no direct push, a merge whitelist naming
// required status, and no administrator merging past one. Why says what is missing when it is not. // the tools' account and no agent's account, and no administrator merging past the rule (novox/hq ADR 0534 §6; no
// required status, which the forge no longer asks, §1). Why says what is missing when it is not.
Guarded bool Guarded bool
Why string Why string
} }
@@ -361,27 +363,48 @@ func askTheForgeOnce(ctx context.Context, owner, repo, branch string) (forgeFact
return facts, nil return facts, nil
} }
// forgeToolsAccount is the forge tools' account, which main's merge whitelist names for mesh-delivery (novox/hq ADR 0398
// §2, the gitea module's tools-account.ts TOOLS_ACCOUNT); beside it the whitelist names only people's own logins, the
// operator's (ADR 0534 §2), never an agent's account.
const forgeToolsAccount = "mesh-tools"
// isAgentAccount is whether a forge login is a node's agent's account, agent-<node> (novox/hq ADR 0398 §1): one that
// may never merge into a module's trunk by itself.
func isAgentAccount(login string) bool { return strings.HasPrefix(login, "agent-") }
// protectionRule is a branch protection rule as the forge's tool summarises it. // protectionRule is a branch protection rule as the forge's tool summarises it.
type protectionRule struct { type protectionRule struct {
Rule string `json:"rule"` Rule string `json:"rule"`
Push bool `json:"push"` Push bool `json:"push"`
RequiredStatuses []string `json:"required_statuses"` RequiredStatuses []string `json:"required_statuses"`
AdminMayOverride bool `json:"admin_may_override"` AdminMayOverride bool `json:"admin_may_override"`
// MergeWhitelist is who may merge into the branch; nil when the rule keeps no merge whitelist (anyone who writes).
MergeWhitelist []string `json:"merge_whitelist"`
} }
// judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the // judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the
// branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a // branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a
// later rule that happens to be stronger: the forge does not read it. // later rule that happens to be stronger: the forge does not read it. A trunk is trusted for who can land a commit on
// it, not for a status it requires (novox/hq ADR 0534 §6): no push, a merge whitelist naming the tools' account and
// beside it only people's own logins, never an agent's, and no administrator merging past the rule; then a commit lands
// there through mesh-delivery, on a passing mesh/check and the operator's Approve (ADR 0534 §4), or by the operator's
// own hand at the forge (§2).
func judgedRule(rules []protectionRule, branch string) forgeFacts { func judgedRule(rules []protectionRule, branch string) forgeFacts {
switch r := appliedRule(rules, branch); { switch r := appliedRule(rules, branch); {
case r == nil: case r == nil:
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)} return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
case r.Push: case r.Push:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)} return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)}
case len(r.RequiredStatuses) == 0: case r.MergeWhitelist == nil || len(r.MergeWhitelist) == 0:
return forgeFacts{Why: fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch)} return forgeFacts{Why: fmt.Sprintf("the rule %s keeps no merge whitelist naming %s alone for %s", r.Rule, forgeToolsAccount, branch)}
case !slices.Contains(r.MergeWhitelist, forgeToolsAccount):
return forgeFacts{Why: fmt.Sprintf("the rule %s lets %s merge into %s, and not %s", r.Rule,
strings.Join(r.MergeWhitelist, ", "), branch, forgeToolsAccount)}
case slices.ContainsFunc(r.MergeWhitelist, isAgentAccount):
return forgeFacts{Why: fmt.Sprintf("the rule %s lets an agent's account merge into %s: %s", r.Rule, branch,
strings.Join(r.MergeWhitelist, ", "))}
case r.AdminMayOverride: case r.AdminMayOverride:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch)} return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past it", r.Rule, branch)}
} }
return forgeFacts{Guarded: true} return forgeFacts{Guarded: true}
} }
+38 -4
View File
@@ -102,16 +102,17 @@ func init() {
// The forge applies one rule to a branch: the rule named for it, else the first glob that covers it. A stronger // The forge applies one rule to a branch: the rule named for it, else the first glob that covers it. A stronger
// rule later in the list is not what guards the branch, and is not read as if it were (the confirmation review). // rule later in the list is not what guards the branch, and is not read as if it were (the confirmation review).
func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) { func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) {
guarded := protectionRule{Rule: "*", RequiredStatuses: []string{"mesh/merge-gate"}} tools := []string{forgeToolsAccount}
open := protectionRule{Rule: "main", Push: true, RequiredStatuses: []string{"mesh/merge-gate"}} guarded := protectionRule{Rule: "*", MergeWhitelist: tools}
open := protectionRule{Rule: "main", Push: true, MergeWhitelist: tools}
if f := judgedRule([]protectionRule{guarded, open}, "main"); f.Guarded { if f := judgedRule([]protectionRule{guarded, open}, "main"); f.Guarded {
t.Error("an exact rule letting pushes was passed over for a glob that guards") t.Error("an exact rule letting pushes was passed over for a glob that guards")
} }
if f := judgedRule([]protectionRule{{Rule: "ma*", RequiredStatuses: nil}, {Rule: "*", RequiredStatuses: []string{"x"}}}, if f := judgedRule([]protectionRule{{Rule: "ma*", MergeWhitelist: nil}, {Rule: "*", MergeWhitelist: tools}},
"main"); f.Guarded || !strings.Contains(f.Why, "ma*") { "main"); f.Guarded || !strings.Contains(f.Why, "ma*") {
t.Errorf("the first glob covering the branch was not the one judged: %+v", f) t.Errorf("the first glob covering the branch was not the one judged: %+v", f)
} }
if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", RequiredStatuses: []string{"x"}}}, "main"); !f.Guarded { if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", MergeWhitelist: tools}}, "main"); !f.Guarded {
t.Errorf("the rule named for the branch was not judged: %+v", f) t.Errorf("the rule named for the branch was not judged: %+v", f)
} }
if f := judgedRule(nil, "main"); f.Guarded { if f := judgedRule(nil, "main"); f.Guarded {
@@ -119,6 +120,39 @@ func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) {
} }
} }
// **A module's trunk is trusted when it takes no push, only the tools' account and people's own logins merge into it,
// and no administrator merges past its rule** (novox/hq ADR 0534 §2, §6): the forge requires no status to merge (§1), so
// a rule requiring none is trusted; one with no merge whitelist, no tools' account in it, an agent's account in it, a
// push allowed or an administrator's override is not.
func TestATrunkIsTrustedForItsMergersNotForAStatus(t *testing.T) {
tools := []string{forgeToolsAccount}
for _, c := range []struct {
name string
rule protectionRule
want bool
why string
}{
{"no status, no push, the tools alone merge", protectionRule{Rule: "main", MergeWhitelist: tools}, true, ""},
{"a status required as well", protectionRule{Rule: "main", MergeWhitelist: tools, RequiredStatuses: []string{"mesh/check"}}, true, ""},
{"no merge whitelist", protectionRule{Rule: "main"}, false, "merge whitelist"},
{"an empty merge whitelist", protectionRule{Rule: "main", MergeWhitelist: []string{}}, false, "merge whitelist"},
{"the operator's own login beside the tools", protectionRule{Rule: "main", MergeWhitelist: []string{forgeToolsAccount, "jschoubben"}}, true, ""},
{"an agent's account in the whitelist", protectionRule{Rule: "main", MergeWhitelist: []string{forgeToolsAccount, "agent-ace"}}, false, "agent-ace"},
{"an agent's account alone", protectionRule{Rule: "main", MergeWhitelist: []string{"agent-ace"}}, false, "agent-ace"},
{"a person's login without the tools", protectionRule{Rule: "main", MergeWhitelist: []string{"jschoubben"}}, false, forgeToolsAccount},
{"a push allowed", protectionRule{Rule: "main", Push: true, MergeWhitelist: tools}, false, "push"},
{"an administrator's override", protectionRule{Rule: "main", MergeWhitelist: tools, AdminMayOverride: true}, false, "administrator"},
} {
f := judgedRule([]protectionRule{c.rule}, "main")
if f.Guarded != c.want || !strings.Contains(f.Why, c.why) {
t.Errorf("%s: %+v, want guarded %v saying %q", c.name, f, c.want, c.why)
}
if strings.Contains(f.Why, "requires no status") {
t.Errorf("%s: a trunk is still judged on a required status: %s", c.name, f.Why)
}
}
}
// An id the forge could not give keeps the id already recorded. // An id the forge could not give keeps the id already recorded.
// **The operator's Approve of a reviewed group registers a moved module from its new repository** (novox/hq ADR 0420 §7, // **The operator's Approve of a reviewed group registers a moved module from its new repository** (novox/hq ADR 0420 §7,
+3 -6
View File
@@ -448,12 +448,9 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
return nil return nil
case err != nil: case err != nil:
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err) fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
takeRefused(ctx, b.inv, result, manifest.Module, err.Error(), refusedOutcome{ if manifest.Module != "" {
built: func(module, why string) { planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error(), asked, result.ID)
planBuilt(ctx, b.open, module, result.Commit, why, asked, result.ID) }
},
failedBuild: func(failed link.BuildResult) { planFailedBuild(ctx, b.open, failed) },
})
return nil return nil
} }
fmt.Printf("%s: %s %s registered, built on %s from %s\n", fmt.Printf("%s: %s %s registered, built on %s from %s\n",
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"github.com/novox/mesh-controller/internal/link"
)
// refusalKeeper keeps a refused registration in the store (*inventory.Inventory, or a test's).
type refusalKeeper interface {
RecordRefused(ctx context.Context, id, module, why string) error
}
// refusedOutcome is how a refused build reaches the plans: built takes it in by its module (planBuilt), failedBuild
// by the id the plan asked it under (planFailedBuild).
type refusedOutcome struct {
built func(module, why string)
failedBuild func(result link.BuildResult)
}
// takeRefused takes in a build heard and recorded, and not registered (novox/hq issue 1713).
//
// **The refusal is kept by the build's id, whatever the plans' state**: in the store (RecordRefused) and in this
// process (keepRefusalFor). Taking it into the plans keeps it only when a plan is open or the plans are held, and a
// tier's first failure fails its plan, which no outcome reaches after that: the build record says the builder's words,
// which worked, and a Retry settled from it sent the module's old version and reported the plan done (on 2026-10-23
// 12:01, plan-1792748906937927069 sent mesh-build-go, mesh-build-node, mosquitto and ssh-client so).
//
// **A build whose module is not known fails the plan that asked it by its id** (planFailedBuild, moduleAskedAs), as
// a failed build that names no module does: a manifest that could not be read is no reason for the plan not to hear.
func takeRefused(ctx context.Context, keeper refusalKeeper, result link.BuildResult, module, why string,
plans refusedOutcome) {
if result.ID != "" {
if err := keeper.RecordRefused(ctx, result.ID, module, why); err != nil {
fmt.Printf("%s: the refusal could not be kept in the store, only in this controller: %v\n", result.ID, err)
}
keepRefusalFor(result.ID, why, time.Now())
}
if module != "" {
plans.built(module, why)
return
}
failed := result
failed.Failed = why
plans.failedBuild(failed)
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"context"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// keptRefusals is a store that keeps refusals, by id.
type keptRefusals map[string]string
func (k keptRefusals) RecordRefused(_ context.Context, id, _, why string) error {
if _, ok := k[id]; !ok {
k[id] = why
}
return nil
}
// failedPlanOf is a plan already failed on gitea, its tier also asking module under id: open() does not count it, so
// no outcome taken in reaches it (as plan-1792748906937927069 on 2026-10-23 11:51).
func failedPlanOf(module, id string, asked time.Time) inventory.Plan {
return inventory.Plan{ID: "plan-1713", State: inventory.PlanFailed, Tier: 0,
Tiers: [][]string{{"gitea", module}}, Note: "gitea failed to build in tier 0",
Modules: map[string]*inventory.PlanModule{
"gitea": {State: "failed", AskedAt: &asked, Build: "build-1713-gitea", Why: "not registered"},
module: {State: "asked", AskedAt: &asked, Build: id},
}}
}
// retried is what a Retry of the plan asks again, its records read as recordsOfAsked reads them: the build record of
// id (which says the builder's words, worked) with every refusal kept for it in the store and in this process.
func retried(p *inventory.Plan, store keptRefusals, id, module string, at time.Time) []string {
byID := map[string]inventory.Build{id: {ID: id, Module: module, Commit: "6c0024e4", At: at}}
refused := map[string]string{}
for k, why := range store {
refused[k] = why
}
for k, why := range refusalsKeptFor([]string{id}) {
refused[k] = why
}
overlayRefusals(nil, byID, refused)
return toRetry(p, nil, byID)
}
// 1713/AC1: given a build of a module heard and recorded and not registered while its plan is failed and the plans are
// not held, when the controller takes it in, then the refusal is kept by id, and a Retry asks the module again instead
// of settling it built and sending its old version (plan-1792748906937927069 sent mosquitto's on 2026-10-23 12:01).
func TestARefusalWhileThePlanIsFailedIsKeptAndRetried(t *testing.T) {
asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC)
id := "build-1713-ac1-mosquitto"
p := failedPlanOf("mosquitto", id, asked)
store := keptRefusals{}
var took []string
takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "mosquitto",
"the trunk requires no status", refusedOutcome{
built: func(module, why string) { took = append(took, module+": "+why) },
failedBuild: func(link.BuildResult) { t.Fatal("a refusal with its module was taken by id") },
})
if !reflect.DeepEqual(took, []string{"mosquitto: the trunk requires no status"}) {
t.Fatalf("the plans were told %v", took)
}
if store[id] == "" {
t.Fatal("the refusal was not kept in the store")
}
if got := retried(&p, keptRefusals{}, id, "mosquitto", asked.Add(time.Minute)); !reflect.DeepEqual(got,
[]string{"gitea", "mosquitto"}) {
t.Fatalf("a Retry, the refusal kept only in this controller, asks %v; mosquitto was settled %q",
got, p.Modules["mosquitto"].State)
}
}
// 1713/AC2: given a build that came back without a readable manifest and was not registered, when the controller takes
// it in, then the asking plan is failed by the build's id and the refusal is kept by id, so a Retry asks it again.
func TestARefusalWithoutAModuleFailsItsPlanByIDAndIsRetried(t *testing.T) {
asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC)
id := "build-1713-ac2-ssh-client"
p := failedPlanOf("ssh-client", id, asked)
store := keptRefusals{}
var failed []link.BuildResult
takeRefused(t.Context(), store, link.BuildResult{ID: id, Commit: "6c0024e4"}, "",
"what came back is not a manifest", refusedOutcome{
built: func(module, _ string) { t.Fatalf("a refusal without a module was taken as %q", module) },
failedBuild: func(r link.BuildResult) { failed = append(failed, r) },
})
if len(failed) != 1 || failed[0].ID != id || !strings.Contains(failed[0].Failed, "not a manifest") {
t.Fatalf("the asking plan was not failed by the build's id: %+v", failed)
}
if store[id] == "" {
t.Fatal("the refusal was not kept in the store")
}
if got := retried(&p, store, id, "", asked.Add(time.Minute)); !reflect.DeepEqual(got,
[]string{"gitea", "ssh-client"}) {
t.Fatalf("a Retry asks %v; ssh-client was settled %q", got, p.Modules["ssh-client"].State)
}
}
// 1713/AC3: given a refused build kept by id, when the records settle a building plan's tier, then the module and the
// plan are failed, never built: the plan never reports done for it.
func TestAPlanNeverSettlesARefusedBuildBuilt(t *testing.T) {
asked := time.Date(2026, 10, 23, 11, 51, 0, 0, time.UTC)
id := "build-1713-ac3-mesh-build-go"
takeRefused(t.Context(), keptRefusals{}, link.BuildResult{ID: id}, "mesh-build-go", "not registered",
refusedOutcome{built: func(string, string) {}, failedBuild: func(link.BuildResult) {}})
p := inventory.Plan{ID: "plan-1713-ac3", State: inventory.PlanBuilding, Tiers: [][]string{{"mesh-build-go"}},
Modules: map[string]*inventory.PlanModule{"mesh-build-go": {State: "asked", AskedAt: &asked, Build: id}}}
byID := map[string]inventory.Build{id: {ID: id, Module: "mesh-build-go", At: asked.Add(time.Minute)}}
overlayRefusals(nil, byID, refusalsKeptFor([]string{id}))
settleFromRecords(&p, p.Tiers[0], nil, byID)
if s := p.Modules["mesh-build-go"]; s.State != "failed" || p.State != inventory.PlanFailed {
t.Fatalf("a refused build settled as %q, the plan %q", s.State, p.State)
}
}
+7 -23
View File
@@ -45,37 +45,21 @@ func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused")) return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
} }
// waitedFor is whether an error is one a build waits out: a refused connection, or a lookup of the registry's name // waitForRegistry runs try, and while it fails because the registry at address refuses connections,
// that the resolver did not answer (novox/hq issue 1748; failedLookup, from issue 1773, says which). // tries again with a growing pause until registryWait has passed. what names the call, for the log.
func waitedFor(err error) bool { return refused(err) || failedLookup(err) }
// waitForRegistry runs try, and while it fails because the registry at address refuses connections, or the
// resolver does not answer a lookup of its name, tries again with a growing pause until registryWait has passed. what names the call, for the log.
func waitForRegistry(ctx context.Context, address, what string, try func() error) error { func waitForRegistry(ctx context.Context, address, what string, try func() error) error {
err := try() err := try()
if !waitedFor(err) { if !refused(err) {
return err return err
} }
started := time.Now() started := time.Now()
pause := registryFirstPause pause := registryFirstPause
if failedLookup(err) { tell("registry", "%s: refused; the build waits for the registry at %s, for up to %s — it is held still while "+
tell("registry", "%s: the resolver did not answer a lookup of the registry's name (%v); the build waits, "+ "the store's nightly collection runs, about a minute and a half (novox/hq issue 457)",
"for up to %s (novox/hq issue 1748)", what, err, registryWait) what, address, registryWait)
} else {
tell("registry", "%s: refused; the build waits for the registry at %s, for up to %s — it is held still while "+
"the store's nightly collection runs, about a minute and a half (novox/hq issue 457)",
what, address, registryWait)
}
for { for {
left := registryWait - time.Since(started) left := registryWait - time.Since(started)
if left <= 0 { if left <= 0 {
if failedLookup(err) {
tell("registry", "%s: the resolver still did not answer a lookup of the registry at %s after %s; the build fails",
what, address, time.Since(started).Round(time.Second))
return fmt.Errorf("the resolver did not answer a lookup of the registry at %s for %s, so it is the "+
"resolver, not the registry, that is down: %w",
address, time.Since(started).Round(time.Millisecond), err)
}
tell("registry", "%s: the registry at %s still refuses after %s; the build fails", what, address, tell("registry", "%s: the registry at %s still refuses after %s; the build fails", what, address,
time.Since(started).Round(time.Second)) time.Since(started).Round(time.Second))
return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly "+ return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly "+
@@ -89,7 +73,7 @@ func waitForRegistry(ctx context.Context, address, what string, try func() error
case <-time.After(wait): case <-time.After(wait):
} }
pause = min(pause*2, registryMostPause) pause = min(pause*2, registryMostPause)
if err = try(); !waitedFor(err) { if err = try(); !refused(err) {
// Said as it is: the registry answering is only the build going on when the call worked. // Said as it is: the registry answering is only the build going on when the call worked.
if err == nil { if err == nil {
tell("registry", "%s: the registry at %s answers again after %s; the build goes on", what, address, tell("registry", "%s: the registry at %s answers again after %s; the build goes on", what, address,
-65
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"net" "net"
"net/http" "net/http"
"strings" "strings"
@@ -153,67 +152,3 @@ func TestAnImagePushWaitsForARegistryHeldStill(t *testing.T) {
type errorString string type errorString string
func (e errorString) Error() string { return string(e) } func (e errorString) Error() string { return string(e) }
// A resolver that does not answer for a moment (novox/hq issue 1748): a lookup of the registry's name that
// timed out failed the build at once, though the same name resolved a second later.
const lookupTimeout = "Head \"http://novox.internal:5100/v2/x/blobs/sha256:1\": dial tcp: lookup novox.internal on 10.10.0.3:53: " +
"read udp 10.10.0.3:44790->10.10.0.3:53: i/o timeout"
type lookupTimedOut struct{}
func (lookupTimedOut) Error() string { return lookupTimeout }
func TestABuildWaitsOutAResolverThatDoesNotAnswerAndGoesOn(t *testing.T) { // 1748/AC1
waitingFor(t, 5*time.Second)
said := saying(t)
calls := 0
err := waitForRegistry(context.Background(), "novox.internal:5100", "HEAD /v2/x", func() error {
calls++
if calls < 3 {
return lookupTimedOut{}
}
return nil
})
if err != nil {
t.Fatalf("a lookup timing out twice failed the build: %v", err)
}
if calls != 3 {
t.Errorf("tried %d times, want 3", calls)
}
log := strings.Join(said(), "\n")
if !strings.Contains(log, "resolver did not answer") || !strings.Contains(log, "answers again") {
t.Errorf("the build's log does not say the resolver was waited for, and that it came back:\n%s", log)
}
}
func TestABuildFailsNamingTheResolverWhenItDoesNotAnswerPastTheBound(t *testing.T) { // 1748/AC2
waitingFor(t, 100*time.Millisecond)
saying(t)
started := time.Now()
err := waitForRegistry(context.Background(), "novox.internal:5100", "HEAD /v2/x", func() error { return lookupTimedOut{} })
if err == nil {
t.Fatal("a resolver that never answered let the build go on")
}
if !strings.Contains(err.Error(), "resolver did not answer") || !strings.Contains(err.Error(), "i/o timeout") {
t.Errorf("the failure does not name the resolver: %v", err)
}
if strings.Contains(err.Error(), "refused every connection") {
t.Errorf("the failure blames a refusal: %v", err)
}
if waited := time.Since(started); waited < 100*time.Millisecond {
t.Errorf("failed after %s, before the bound", waited)
}
}
func TestAnErrorThatIsNeitherARefusalNorALookupFailsAtOnce(t *testing.T) { // 1748/AC3
waitingFor(t, 5*time.Second)
calls := 0
err := waitForRegistry(context.Background(), "novox.internal:5100", "HEAD /v2/x", func() error {
calls++
return errors.New("401 unauthorized")
})
if err == nil || calls != 1 {
t.Fatalf("err %v after %d calls, want a failure at once", err, calls)
}
}