After a restart the forge-account provider returned a plain error, which the helper writes as failed with its values cleared, so every consumer lost its forge entry until the keys loaded. provider.ErrNotReady keeps the entry serving, in waits (issue 1954).
783 lines
25 KiB
Go
783 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
|
"git.novox.be/novox/mesh-sdk/go/provisions"
|
|
|
|
"gitea/internal/forgehttp"
|
|
)
|
|
|
|
const (
|
|
pubA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
pubB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
|
)
|
|
|
|
// fakeBus is the bus as the provider reads and writes it.
|
|
type fakeBus struct {
|
|
mu sync.Mutex
|
|
buckets map[string]map[string][]byte
|
|
}
|
|
|
|
func newBus() *fakeBus { return &fakeBus{buckets: map[string]map[string][]byte{}} }
|
|
|
|
func (b *fakeBus) set(bucket, key string, v []byte) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.buckets[bucket] == nil {
|
|
b.buckets[bucket] = map[string][]byte{}
|
|
}
|
|
b.buckets[bucket][key] = v
|
|
}
|
|
func (b *fakeBus) Watch(_ context.Context, bucket string, h func(string, []byte)) error {
|
|
b.mu.Lock()
|
|
cur := map[string][]byte{}
|
|
for k, v := range b.buckets[bucket] {
|
|
cur[k] = v
|
|
}
|
|
b.mu.Unlock()
|
|
for k, v := range cur {
|
|
h(k, v)
|
|
}
|
|
return nil
|
|
}
|
|
func (b *fakeBus) Put(_ context.Context, bucket, key string, v []byte) error {
|
|
b.set(bucket, key, v)
|
|
return nil
|
|
}
|
|
func (b *fakeBus) Delete(_ context.Context, bucket, key string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
delete(b.buckets[bucket], key)
|
|
return nil
|
|
}
|
|
func (b *fakeBus) entry(t *testing.T, key string) provider.Entry {
|
|
t.Helper()
|
|
b.mu.Lock()
|
|
raw := b.buckets[provider.BucketOf("gitea")][key]
|
|
b.mu.Unlock()
|
|
if raw == nil {
|
|
t.Fatalf("no entry %s", key)
|
|
}
|
|
var e provider.Entry
|
|
if err := json.Unmarshal(raw, &e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// serves is whether gitea's entry under key serves its consumer: there, in state (provided when none is given) and with
|
|
// values (ADR 0570 clause 3: an entry the provider could not make is kept, failed, serving nothing; one it is not
|
|
// ready to make waits and keeps serving).
|
|
func (b *fakeBus) serves(key string, state ...string) bool {
|
|
want := provisions.Provided
|
|
if len(state) > 0 {
|
|
want = state[0]
|
|
}
|
|
b.mu.Lock()
|
|
raw, ok := b.buckets[provider.BucketOf("gitea")][key]
|
|
b.mu.Unlock()
|
|
var e provider.Entry
|
|
return ok && json.Unmarshal(raw, &e) == nil && e.State == want && len(e.Values) > 0
|
|
}
|
|
|
|
func (b *fakeBus) has(key string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, ok := b.buckets[provider.BucketOf("gitea")][key]
|
|
return ok
|
|
}
|
|
|
|
// bindClient makes ssh-client on node bind gitea's forge-account in the seat mesh-provisioner's plans.
|
|
func (b *fakeBus) bindClient(node string) {
|
|
b.bindPlan(node, "ssh-client", "mesh_"+node+"_ssh_client")
|
|
}
|
|
|
|
// bindPlan makes module on node bind gitea's forge-account as the seat mesh-provisioner keeps it: its plan, and the
|
|
// provision key asked of gitea on novox (novox/hq ADR 0570 clauses 1 to 3).
|
|
func (b *fakeBus) bindPlan(node, module, as string) {
|
|
b.set(provisions.PlansBucket, node+"."+module, []byte(fmt.Sprintf(
|
|
`{"v":1,"node":%q,"module":%q,"plan":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":%q}]}}`,
|
|
node, module, as)))
|
|
b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Asked, nil))
|
|
}
|
|
|
|
// retire is the operator's yes to the retiring of module's forge-account on node: its plan gone, its key retiring
|
|
// with the warrant (ADR 0570 clause 5), the only way the provider lets an entry go.
|
|
func (b *fakeBus) retire(node, module string) {
|
|
b.mu.Lock()
|
|
delete(b.buckets[provisions.PlansBucket], node+"."+module)
|
|
b.mu.Unlock()
|
|
b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Retiring,
|
|
&provisions.Warrant{Ask: "ask-1", Option: "yes", By: "operator", At: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)}))
|
|
}
|
|
|
|
func provisionKey(node, module, state string, w *provisions.Warrant) []byte {
|
|
pr := provisions.Provision{Provision: "forge-account", State: state, Warrant: w,
|
|
Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "gitea"}}
|
|
if state == provisions.Retiring {
|
|
pr.Why = "no want needs it"
|
|
}
|
|
raw, err := provisions.Encode(pr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// fakeForge is gitea's API as far as the provider uses it.
|
|
type fakeForge struct {
|
|
mu sync.Mutex
|
|
users map[string]bool
|
|
teams map[string]bool
|
|
members []string
|
|
held map[string][]map[string]any
|
|
next int64
|
|
}
|
|
|
|
func newForge() *fakeForge {
|
|
return &fakeForge{users: map[string]bool{}, held: map[string][]map[string]any{}}
|
|
}
|
|
|
|
var keyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
|
|
|
|
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
body, _ := io.ReadAll(r.Body)
|
|
var in map[string]any
|
|
_ = json.Unmarshal(body, &in)
|
|
reply := func(code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if v != nil {
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
}
|
|
p := r.URL.Path
|
|
if m := keyRe.FindStringSubmatch(p); m != nil {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
reply(200, f.held[m[1]])
|
|
case http.MethodPost:
|
|
for user, ks := range f.held {
|
|
for _, k := range ks {
|
|
if user != m[1] && fmt.Sprint(k["key"]) != "" && strings.Fields(fmt.Sprint(k["key"]))[1] == strings.Fields(fmt.Sprint(in["key"]))[1] {
|
|
reply(422, map[string]any{"message": "Key content has been used as non-deploy key"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
f.next++
|
|
f.held[m[1]] = append(f.held[m[1]], map[string]any{"id": f.next, "title": in["title"], "key": in["key"]})
|
|
reply(201, map[string]any{"id": f.next})
|
|
case http.MethodDelete:
|
|
var kept []map[string]any
|
|
for _, k := range f.held[m[1]] {
|
|
if fmt.Sprint(k["id"]) != m[2] {
|
|
kept = append(kept, k)
|
|
}
|
|
}
|
|
f.held[m[1]] = kept
|
|
reply(204, nil)
|
|
}
|
|
return
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"):
|
|
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
|
|
reply(404, map[string]any{"message": "no"})
|
|
return
|
|
}
|
|
reply(200, map[string]any{"active": true})
|
|
case r.Method == http.MethodPost && p == "/api/v1/admin/users":
|
|
f.users[in["username"].(string)] = true
|
|
reply(201, map[string]any{})
|
|
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet:
|
|
reply(200, []map[string]any{{"id": 5, "name": "agents"}})
|
|
case p == "/api/v1/teams/5" && r.Method == http.MethodPatch:
|
|
reply(200, map[string]any{})
|
|
case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodPut:
|
|
in := strings.TrimPrefix(p, "/api/v1/teams/5/members/")
|
|
for _, m := range f.members {
|
|
if m == in {
|
|
reply(204, nil)
|
|
return
|
|
}
|
|
}
|
|
f.members = append(f.members, in)
|
|
reply(204, nil)
|
|
case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodDelete:
|
|
gone := strings.TrimPrefix(p, "/api/v1/teams/5/members/")
|
|
var kept []string
|
|
for _, m := range f.members {
|
|
if m != gone {
|
|
kept = append(kept, m)
|
|
}
|
|
}
|
|
f.members = kept
|
|
reply(204, nil)
|
|
default:
|
|
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
|
}
|
|
}
|
|
|
|
func (f *fakeForge) titles(user string) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []string
|
|
for _, k := range f.held[user] {
|
|
out = append(out, k["title"].(string))
|
|
}
|
|
return out
|
|
}
|
|
|
|
type fakeRunner struct {
|
|
out string
|
|
err error
|
|
args []string
|
|
}
|
|
|
|
func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
r.args = append([]string{name}, args...)
|
|
return []byte(r.out), r.err
|
|
}
|
|
|
|
const scanned = "# 127.0.0.1:222 SSH-2.0-Go\n[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY\n"
|
|
|
|
type rig struct {
|
|
t *testing.T
|
|
bus *fakeBus
|
|
forge *fakeForge
|
|
svc *Service
|
|
runner *fakeRunner
|
|
}
|
|
|
|
func newRig(t *testing.T) *rig {
|
|
t.Helper()
|
|
forge := newForge()
|
|
srv := httptest.NewServer(forge)
|
|
t.Cleanup(srv.Close)
|
|
dir := t.TempDir()
|
|
pw := filepath.Join(dir, "pw")
|
|
host := filepath.Join(dir, "host")
|
|
origins := filepath.Join(dir, "origins")
|
|
for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} {
|
|
if err := os.WriteFile(p, []byte(c), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
runner := &fakeRunner{out: scanned}
|
|
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
|
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner,
|
|
Log: func(string, ...any) {}})
|
|
svc.loaded = true // the machines' keys are read; a test that means otherwise says so
|
|
return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
|
}
|
|
|
|
func (r *rig) pass() []provider.Outcome {
|
|
r.t.Helper()
|
|
p := r.svc.Provider(r.bus)
|
|
out, err := p.Pass(context.Background())
|
|
if err != nil {
|
|
r.t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *rig) key(node, account, public string) {
|
|
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": "machine", "public": public,
|
|
"fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"})
|
|
r.svc.KeyChanged(node+"."+account, b)
|
|
}
|
|
|
|
func TestAConsumerWithAKeyGetsItsEntryAndItsKeyIsRegistered(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
e := r.bus.entry(t, "g14.ssh-client.forge-account")
|
|
want := map[string]any{"user": "nox-agent", "ssh_host": "novox.internal", "ssh_port": float64(222),
|
|
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY", "ssh_alias": "mesh-forge",
|
|
"host_key": "ssh-ed25519 AAAAC3HOSTKEY", "ssh_base": "ssh://git@novox.internal:222/",
|
|
"https_base": "https://git.novox.be/"}
|
|
for k, v := range want {
|
|
if e.Values[k] != v {
|
|
t.Errorf("%s = %v, want %v", k, e.Values[k], v)
|
|
}
|
|
}
|
|
if e.VaultName != "" || e.User != "" {
|
|
t.Errorf("a public key is no secret; the entry names a credential: %+v", e)
|
|
}
|
|
if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "mesh:g14.nox" {
|
|
t.Errorf("registered keys = %v", got)
|
|
}
|
|
if !r.forge.users["nox-agent"] || len(r.forge.members) != 1 || r.forge.members[0] != "nox-agent" {
|
|
t.Errorf("account %v, team members %v", r.forge.users, r.forge.members)
|
|
}
|
|
if strings.Join(r.runner.args, " ") != "ssh-keyscan -t ed25519 -p 222 127.0.0.1" {
|
|
t.Errorf("scan = %v", r.runner.args)
|
|
}
|
|
}
|
|
|
|
func TestAConsumerWithoutAKeyGetsNoEntryAndNoAccount(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("ace", "nox", pubB) // another machine's key is not g14's
|
|
out := r.pass()
|
|
if r.bus.has("g14.ssh-client.forge-account") {
|
|
t.Fatal("an entry was written for a machine that reported no key")
|
|
}
|
|
if len(r.forge.users) != 0 {
|
|
t.Fatalf("an account was made: %v", r.forge.users)
|
|
}
|
|
if len(out) != 1 || !strings.Contains(out[0].Why, "no key") {
|
|
t.Fatalf("the wait should be said: %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestAKeyOfAnotherKindIsNotTheMachinesKey(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
b, _ := json.Marshal(map[string]string{"node": "g14", "account": "x", "kind": "session", "public": pubA})
|
|
r.svc.KeyChanged("g14.x", b)
|
|
r.pass()
|
|
if r.bus.has("g14.ssh-client.forge-account") {
|
|
t.Fatal("answered with a key that is not a machine's")
|
|
}
|
|
}
|
|
|
|
func TestAChangedKeyReplacesTheOldMeshKey(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
|
r.forge.mu.Unlock()
|
|
r.key("g14", "nox", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC nox@g14 (mesh)")
|
|
r.pass()
|
|
got := strings.Join(r.forge.titles("nox-agent"), ",")
|
|
if got != "my own,mesh:g14.nox" {
|
|
t.Fatalf("keys = %s", got)
|
|
}
|
|
for _, k := range r.forge.held["nox-agent"] {
|
|
if k["title"] == "mesh:g14.nox" && !strings.Contains(k["key"].(string), "CCCC") {
|
|
t.Fatalf("the old key stayed: %v", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
|
r.forge.mu.Unlock()
|
|
r.bus.retire("g14", "ssh-client")
|
|
r.pass()
|
|
if r.bus.has("g14.ssh-client.forge-account") {
|
|
t.Fatal("the entry stayed")
|
|
}
|
|
if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "my own" {
|
|
t.Fatalf("keys = %v", got)
|
|
}
|
|
if !r.forge.users["nox-agent"] {
|
|
t.Fatal("the account was removed")
|
|
}
|
|
}
|
|
|
|
func TestAKeyChangeNamesTheConsumersToAnswerAgain(t *testing.T) {
|
|
r := newRig(t)
|
|
var got []string
|
|
r.svc.OnKeys = func(node string) { got = append(got, node) }
|
|
r.key("g14", "nox", pubA)
|
|
if len(got) != 1 || got[0] != "g14" {
|
|
t.Fatalf("answered for %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAKeyThatIsNotReadableIsNotKept(t *testing.T) {
|
|
r := newRig(t)
|
|
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","kind":"machine"`))
|
|
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"PRIVATE KEY"}`))
|
|
r.bus.bindClient("g14")
|
|
r.pass()
|
|
if r.bus.has("g14.ssh-client.forge-account") {
|
|
t.Fatal("a value that is no public key was registered")
|
|
}
|
|
}
|
|
|
|
func TestTheForgesHostKeyUnreadableMeansNoEntry(t *testing.T) {
|
|
r := newRig(t)
|
|
r.runner.err = errors.New("connection refused")
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA)
|
|
r.pass()
|
|
if r.bus.has("g14.ssh-client.forge-account") {
|
|
t.Fatal("an entry without the forge's host key")
|
|
}
|
|
}
|
|
|
|
func TestKnownHostLineIsRewrittenToTheMeshName(t *testing.T) {
|
|
line, err := knownHostLine(scanned, "novox.internal", 222)
|
|
if err != nil || line != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
|
|
t.Fatalf("%q %v", line, err)
|
|
}
|
|
if _, err := knownHostLine("# nothing\n", "novox.internal", 222); err == nil {
|
|
t.Fatal("no key line was accepted")
|
|
}
|
|
}
|
|
|
|
func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) {
|
|
r := newRig(t)
|
|
src := &fakeKeys{failFirst: 2}
|
|
r.svc.Pause = func(time.Duration) {}
|
|
r.svc.loaded = false
|
|
passes := 0
|
|
r.svc.OnKeys = func(string) { passes++ }
|
|
if err := r.svc.WatchKeys(context.Background(), src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !r.svc.loaded {
|
|
t.Fatal("the keys are not loaded once the watch has answered")
|
|
}
|
|
if src.calls != 3 {
|
|
t.Fatalf("asked %d times", src.calls)
|
|
}
|
|
src.send("g14.nox", "put", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"`+pubA+`"}`), false)
|
|
if passes != 1 {
|
|
t.Fatalf("passes = %d", passes)
|
|
}
|
|
// What was there when the watch began is taken in, and one pass answers it, not one per key.
|
|
src.send("ace.nox", "put", []byte(`{"node":"ace","account":"nox","kind":"machine","public":"`+pubB+`"}`), true)
|
|
if passes != 1 {
|
|
t.Fatalf("a current value passed on its own: %d", passes)
|
|
}
|
|
src.send("g14.nox", "delete", nil, false)
|
|
if passes != 2 || len(r.svc.keysOf("g14")) != 0 {
|
|
t.Fatalf("passes = %d, keys = %v", passes, r.svc.keysOf("g14"))
|
|
}
|
|
}
|
|
|
|
type fakeKeys struct {
|
|
failFirst int
|
|
calls int
|
|
handle func(key, op string, value []byte, current bool)
|
|
}
|
|
|
|
func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
|
f.calls++
|
|
if f.calls <= f.failFirst {
|
|
return errors.New("not yet")
|
|
}
|
|
f.handle = handle
|
|
return nil
|
|
}
|
|
func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) }
|
|
|
|
func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
|
r.forge.mu.Unlock()
|
|
r.svc.KeyGone("g14.nox")
|
|
r.pass()
|
|
if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "my own" {
|
|
t.Fatalf("keys = %v", got)
|
|
}
|
|
if r.bus.serves("g14.ssh-client.forge-account") {
|
|
t.Fatal("the entry still serves a machine whose key is gone")
|
|
}
|
|
}
|
|
|
|
func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
|
r.forge.mu.Unlock()
|
|
// A restart: the keys are not read yet, so a node with none is not a node that reported none.
|
|
r.svc.mu.Lock()
|
|
r.svc.keys = map[string]machineKey{}
|
|
r.svc.loaded = false
|
|
r.svc.mu.Unlock()
|
|
out := r.pass()
|
|
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "mesh:g14.nox,my own" {
|
|
t.Fatalf("keys = %s", got)
|
|
}
|
|
if !r.bus.serves("g14.ssh-client.forge-account", provisions.Waits) {
|
|
t.Fatal("the entry stopped serving before the keys were read; it waits and keeps serving")
|
|
}
|
|
if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") {
|
|
t.Fatalf("the wait should be said: %+v", out)
|
|
}
|
|
// Loaded, and the node reports none: now its keys go.
|
|
r.svc.mu.Lock()
|
|
r.svc.loaded = true
|
|
r.svc.mu.Unlock()
|
|
r.pass()
|
|
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "my own" || r.bus.serves("g14.ssh-client.forge-account") {
|
|
t.Fatalf("keys = %s", got)
|
|
}
|
|
}
|
|
|
|
// Issue 955: a watch the runtime refuses (a grant that comes later) must not end the provider for good.
|
|
func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) {
|
|
r := newRig(t)
|
|
var pauses []time.Duration
|
|
var said []string
|
|
r.svc.Pause = func(d time.Duration) { pauses = append(pauses, d) }
|
|
r.svc.cfg.Log = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
|
ctx, stop := context.WithCancel(context.Background())
|
|
defer stop()
|
|
calls := 0
|
|
serving := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
r.svc.KeepRunning(ctx, func(ctx context.Context) error {
|
|
calls++
|
|
if calls <= 2 {
|
|
return errors.New(`watching mesh-provisioner_plans: gitea reads no state called "mesh-provisioner.plans"`)
|
|
}
|
|
close(serving)
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
})
|
|
}()
|
|
select {
|
|
case <-serving:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the provider was not started again after its watch was refused")
|
|
}
|
|
stop()
|
|
<-done
|
|
if calls != 3 {
|
|
t.Fatalf("run %d times", calls)
|
|
}
|
|
if len(pauses) != 2 || pauses[0] != 5*time.Second || pauses[1] != 10*time.Second {
|
|
t.Fatalf("the backoff is %v", pauses)
|
|
}
|
|
if len(said) == 0 || !strings.Contains(said[0], "mesh-provisioner.plans") {
|
|
t.Fatalf("a refusal is said loudly: %v", said)
|
|
}
|
|
loud := 0
|
|
for _, s := range said {
|
|
if strings.Contains(s, "stopped") {
|
|
loud++
|
|
}
|
|
}
|
|
if loud != 1 {
|
|
t.Fatalf("the same refusal is said %d times in one spell: %v", loud, said)
|
|
}
|
|
}
|
|
|
|
func TestKeepRunningBacksOffToTwoMinutes(t *testing.T) {
|
|
r := newRig(t)
|
|
var pauses []time.Duration
|
|
r.svc.Pause = func(d time.Duration) { pauses = append(pauses, d) }
|
|
ctx, stop := context.WithCancel(context.Background())
|
|
calls := 0
|
|
r.svc.KeepRunning(ctx, func(context.Context) error {
|
|
if calls++; calls == 9 {
|
|
stop()
|
|
}
|
|
return errors.New("refused")
|
|
})
|
|
last := pauses[len(pauses)-1]
|
|
if pauses[0] != 5*time.Second || last != 2*time.Minute {
|
|
t.Fatalf("the backoff is %v", pauses)
|
|
}
|
|
}
|
|
|
|
// The per-node users made before (ADR 0410 §3) are let go: their mesh keys and their team seat go, the user and the
|
|
// operator's own keys stay, and it is said once.
|
|
func TestThePerNodeUserMadeBeforeIsLetGoButNeverDeleted(t *testing.T) {
|
|
r := newRig(t)
|
|
var said []string
|
|
r.svc.cfg.Log = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.forge.mu.Lock()
|
|
r.forge.users["agent-g14"] = true
|
|
r.forge.members = []string{"agent-g14", "agent-ace"}
|
|
r.forge.held["agent-g14"] = []map[string]any{
|
|
{"id": int64(90), "title": "mesh:g14.nox", "key": pubA}, {"id": int64(91), "title": "my own", "key": pubB}}
|
|
r.forge.held["agent-ace"] = []map[string]any{{"id": int64(92), "title": "mesh:ace.nox", "key": pubB}}
|
|
r.forge.mu.Unlock()
|
|
r.pass()
|
|
r.pass()
|
|
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" {
|
|
t.Fatalf("the old user's keys = %v", got)
|
|
}
|
|
if got := r.forge.titles("agent-ace"); len(got) != 1 {
|
|
t.Fatalf("another machine's old user was touched: %v", got)
|
|
}
|
|
if got := strings.Join(r.forge.members, ","); got != "agent-g14,agent-ace,nox-agent" {
|
|
t.Fatalf("team = %s", got)
|
|
}
|
|
if !r.forge.users["agent-g14"] {
|
|
t.Fatal("the old user was deleted")
|
|
}
|
|
n := 0
|
|
for _, l := range said {
|
|
if strings.Contains(l, "agent-g14") && strings.Contains(l, "let go") {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("said %d times: %v", n, said)
|
|
}
|
|
}
|
|
|
|
// A key on nox-agent that is not titled `mesh:` survives every pass: key kept, key changed, key gone (ADR 0410 §2).
|
|
func TestAKeyOnTheAgentUserTitledOtherwiseSurvivesEveryPass(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.held["nox-agent"] = append(r.forge.held["nox-agent"], map[string]any{"id": int64(99), "title": "by hand", "key": pubB})
|
|
r.forge.mu.Unlock()
|
|
r.pass()
|
|
r.key("g14", "nox", pubB+" nox@g14 (mesh)") // the machine's key changed to the one held by hand
|
|
r.pass()
|
|
r.svc.KeyGone("g14.nox")
|
|
r.pass()
|
|
got := r.forge.titles("nox-agent")
|
|
if len(got) != 1 || got[0] != "by hand" {
|
|
t.Fatalf("keys = %v", got)
|
|
}
|
|
}
|
|
|
|
// Letting a per-node user go is said once per user across passes (ADR 0410 §3).
|
|
func TestLettingEachPerNodeUserGoIsSaidOnceAcrossPasses(t *testing.T) {
|
|
r := newRig(t)
|
|
var said []string
|
|
r.svc.cfg.Log = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
|
for n, pub := range map[string]string{"g14": pubA, "ace": pubB} {
|
|
r.bus.bindClient(n)
|
|
r.key(n, "nox", pub+" nox@"+n+" (mesh)")
|
|
r.forge.held["agent-"+n] = []map[string]any{{"id": int64(80 + len(n)), "title": "mesh:" + n + ".nox", "key": pub}}
|
|
}
|
|
r.pass()
|
|
r.pass()
|
|
r.pass()
|
|
count := map[string]int{}
|
|
for _, l := range said {
|
|
for _, u := range []string{"agent-g14", "agent-ace"} {
|
|
if strings.Contains(l, u) && strings.Contains(l, "let go") {
|
|
count[u]++
|
|
}
|
|
}
|
|
}
|
|
if count["agent-g14"] != 1 || count["agent-ace"] != 1 {
|
|
t.Fatalf("said %v: %v", count, said)
|
|
}
|
|
}
|
|
|
|
// Gitea holds one public key on one user only (ADR 0410 §3): the key leaves agent-<node> before it joins nox-agent.
|
|
func TestAMachinesKeyMovesFromItsOldUserToTheAgentUser(t *testing.T) {
|
|
r := newRig(t)
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.forge.held["agent-g14"] = []map[string]any{{"id": int64(90), "title": "mesh:g14.nox", "key": pubA}}
|
|
out := r.pass()
|
|
if got := r.forge.titles("agent-g14"); len(got) != 0 {
|
|
t.Fatalf("old user keys = %v (pass: %v)", got, out)
|
|
}
|
|
if got := r.forge.titles("nox-agent"); len(got) != 1 || got[0] != "mesh:g14.nox" {
|
|
t.Fatalf("nox-agent keys = %v (pass: %v)", got, out)
|
|
}
|
|
}
|
|
|
|
// The old user lets the key go only just before nox-agent takes it: a pass that fails earlier leaves the key where it
|
|
// is, so the machine is never on no user (ADR 0410 §3).
|
|
func TestAPassThatFailsBeforeTheKeyIsAddedLeavesTheOldUsersKey(t *testing.T) {
|
|
r := newRig(t)
|
|
r.runner.err = errors.New("sshd down")
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.forge.held["agent-g14"] = []map[string]any{{"id": int64(90), "title": "mesh:g14.nox", "key": pubA}}
|
|
r.pass()
|
|
if got := r.forge.titles("agent-g14"); len(got) != 1 {
|
|
t.Fatalf("old user keys = %v", got)
|
|
}
|
|
}
|
|
|
|
// Nothing is said for a user that never existed or held no mesh key of the machine's.
|
|
func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) {
|
|
r := newRig(t)
|
|
var said []string
|
|
r.svc.cfg.Log = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
|
r.bus.bindClient("g14")
|
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
for _, l := range said {
|
|
if strings.Contains(l, "let go") {
|
|
t.Fatalf("said %q", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The forge's own host key file is read first: no scan over the network is needed (issue 1939).
|
|
func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) {
|
|
r := newRig(t)
|
|
f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub")
|
|
if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.svc.cfg.HostKeyFile = f
|
|
r.runner.err = errors.New("sshd down")
|
|
got, err := r.svc.scan(context.Background())
|
|
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" {
|
|
t.Fatalf("scan = %q, %v", got, err)
|
|
}
|
|
if r.runner.args != nil {
|
|
t.Errorf("the forge's sshd was asked: %v", r.runner.args)
|
|
}
|
|
}
|
|
|
|
// A file that cannot be read falls back to the forge's sshd.
|
|
func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) {
|
|
r := newRig(t)
|
|
r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub")
|
|
got, err := r.svc.scan(context.Background())
|
|
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
|
|
t.Fatalf("scan = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed.
|
|
func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) {
|
|
r := newRig(t)
|
|
r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY"
|
|
r.runner.err = errors.New("connection refused")
|
|
got, err := r.svc.scan(context.Background())
|
|
if err != nil || got != r.svc.known {
|
|
t.Fatalf("scan = %q, %v", got, err)
|
|
}
|
|
}
|