mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-12 11:14:40 +00:00
393a229de9
This PR implements the synchronization mechanics for TKA: generating a SyncOffer, processing a SyncOffer to find an intersection, and computing the set of AUMs that should be transmitted to effect convergence. This is the final PR implementing core mechanics for TKA. Signed-off-by: Tom DNetto <tom@tailscale.com>
374 lines
9.7 KiB
Go
374 lines
9.7 KiB
Go
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package tka
|
|
|
|
import (
|
|
"bytes"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
)
|
|
|
|
func TestSyncOffer(t *testing.T) {
|
|
c := newTestchain(t, `
|
|
A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10
|
|
A10 -> A11 -> A12 -> A13 -> A14 -> A15 -> A16 -> A17 -> A18
|
|
A18 -> A19 -> A20 -> A21 -> A22 -> A23 -> A24 -> A25
|
|
`)
|
|
a, err := Open(c.Chonk())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := a.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A SyncOffer includes a selection of AUMs going backwards in the tree,
|
|
// progressively skipping more and more each iteration.
|
|
want := SyncOffer{
|
|
Head: c.AUMHashes["A25"],
|
|
Ancestors: []AUMHash{
|
|
c.AUMHashes["A"+strconv.Itoa(25-ancestorsSkipStart)],
|
|
c.AUMHashes["A"+strconv.Itoa(25-ancestorsSkipStart<<ancestorsSkipShift)],
|
|
c.AUMHashes["A1"],
|
|
},
|
|
}
|
|
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("SyncOffer diff (-want, +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestComputeSyncIntersection_FastForward(t *testing.T) {
|
|
// Node 1 has: A1 -> A2
|
|
// Node 2 has: A1 -> A2 -> A3 -> A4
|
|
c := newTestchain(t, `
|
|
A1 -> A2 -> A3 -> A4
|
|
`)
|
|
a1H, a2H := c.AUMHashes["A1"], c.AUMHashes["A2"]
|
|
|
|
chonk1 := c.ChonkWith("A1", "A2")
|
|
n1, err := Open(chonk1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer1, err := n1.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chonk2 := c.Chonk() // All AUMs
|
|
n2, err := Open(chonk2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer2, err := n2.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Node 1 only knows about the first two nodes, so the head of n2 is
|
|
// alien to it.
|
|
t.Run("n1", func(t *testing.T) {
|
|
got, err := computeSyncIntersection(n1, offer1, offer2)
|
|
if err != nil {
|
|
t.Fatalf("computeSyncIntersection() failed: %v", err)
|
|
}
|
|
want := &intersection{
|
|
tailIntersection: &a1H,
|
|
}
|
|
if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" {
|
|
t.Errorf("intersection diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
// Node 2 knows about the full chain, so it can see that the head of n1
|
|
// intersects with a subset of its chain (a Head Intersection).
|
|
t.Run("n2", func(t *testing.T) {
|
|
got, err := computeSyncIntersection(n2, offer2, offer1)
|
|
if err != nil {
|
|
t.Fatalf("computeSyncIntersection() failed: %v", err)
|
|
}
|
|
want := &intersection{
|
|
headIntersection: &a2H,
|
|
}
|
|
if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" {
|
|
t.Errorf("intersection diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestComputeSyncIntersection_ForkSmallDiff(t *testing.T) {
|
|
// The number of nodes in the chain is longer than ancestorSkipStart,
|
|
// so that during sync both nodes are able to find a common ancestor
|
|
// which was later than A1.
|
|
|
|
c := newTestchain(t, `
|
|
A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10
|
|
| -> F1
|
|
// Make F1 different to A9.
|
|
// hashSeed is chosen such that the hash is higher than A9.
|
|
F1.hashSeed = 7
|
|
`)
|
|
// Node 1 has: A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> F1
|
|
// Node 2 has: A1 -> A2 -> A3 -> A4 -> A5 -> A6 -> A7 -> A8 -> A9 -> A10
|
|
f1H, a9H := c.AUMHashes["F1"], c.AUMHashes["A9"]
|
|
|
|
if bytes.Compare(f1H[:], a9H[:]) < 0 {
|
|
t.Fatal("failed assert: h(a9) > h(f1H)\nTweak hashSeed till this passes")
|
|
}
|
|
|
|
n1, err := Open(c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "F1"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer1, err := n1.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if diff := cmp.Diff(SyncOffer{
|
|
Head: c.AUMHashes["F1"],
|
|
Ancestors: []AUMHash{
|
|
c.AUMHashes["A"+strconv.Itoa(9-ancestorsSkipStart)],
|
|
c.AUMHashes["A1"],
|
|
},
|
|
}, offer1); diff != "" {
|
|
t.Errorf("offer1 diff (-want, +got):\n%s", diff)
|
|
}
|
|
|
|
n2, err := Open(c.ChonkWith("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer2, err := n2.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if diff := cmp.Diff(SyncOffer{
|
|
Head: c.AUMHashes["A10"],
|
|
Ancestors: []AUMHash{
|
|
c.AUMHashes["A"+strconv.Itoa(10-ancestorsSkipStart)],
|
|
c.AUMHashes["A1"],
|
|
},
|
|
}, offer2); diff != "" {
|
|
t.Errorf("offer2 diff (-want, +got):\n%s", diff)
|
|
}
|
|
|
|
// Node 1 only knows about the first eight nodes, so the head of n2 is
|
|
// alien to it.
|
|
t.Run("n1", func(t *testing.T) {
|
|
// n2 has 10 nodes, so the first common ancestor should be 10-ancestorsSkipStart
|
|
wantIntersection := c.AUMHashes["A"+strconv.Itoa(10-ancestorsSkipStart)]
|
|
|
|
got, err := computeSyncIntersection(n1, offer1, offer2)
|
|
if err != nil {
|
|
t.Fatalf("computeSyncIntersection() failed: %v", err)
|
|
}
|
|
want := &intersection{
|
|
tailIntersection: &wantIntersection,
|
|
}
|
|
if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" {
|
|
t.Errorf("intersection diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
// Node 2 knows about the full chain but doesn't recognize the head.
|
|
t.Run("n2", func(t *testing.T) {
|
|
// n1 has 9 nodes, so the first common ancestor should be 9-ancestorsSkipStart
|
|
wantIntersection := c.AUMHashes["A"+strconv.Itoa(9-ancestorsSkipStart)]
|
|
|
|
got, err := computeSyncIntersection(n2, offer2, offer1)
|
|
if err != nil {
|
|
t.Fatalf("computeSyncIntersection() failed: %v", err)
|
|
}
|
|
want := &intersection{
|
|
tailIntersection: &wantIntersection,
|
|
}
|
|
if diff := cmp.Diff(want, got, cmp.AllowUnexported(intersection{})); diff != "" {
|
|
t.Errorf("intersection diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMissingAUMs_FastForward(t *testing.T) {
|
|
// Node 1 has: A1 -> A2
|
|
// Node 2 has: A1 -> A2 -> A3 -> A4
|
|
c := newTestchain(t, `
|
|
A1 -> A2 -> A3 -> A4
|
|
A1.hashSeed = 1
|
|
A2.hashSeed = 2
|
|
A3.hashSeed = 3
|
|
A4.hashSeed = 4
|
|
`)
|
|
|
|
chonk1 := c.ChonkWith("A1", "A2")
|
|
n1, err := Open(chonk1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer1, err := n1.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chonk2 := c.Chonk() // All AUMs
|
|
n2, err := Open(chonk2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer2, err := n2.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Node 1 only knows about the first two nodes, so the head of n2 is
|
|
// alien to it. As such, it should send history from the newest ancestor,
|
|
// A1 (if the chain was longer there would be one in the middle).
|
|
t.Run("n1", func(t *testing.T) {
|
|
got, err := n1.MissingAUMs(offer2)
|
|
if err != nil {
|
|
t.Fatalf("MissingAUMs() failed: %v", err)
|
|
}
|
|
|
|
// Both sides have A1, so the only AUM that n2 might not have is
|
|
// A2.
|
|
want := []AUM{c.AUMs["A2"]}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
// Node 2 knows about the full chain, so it can see that the head of n1
|
|
// intersects with a subset of its chain (a Head Intersection).
|
|
t.Run("n2", func(t *testing.T) {
|
|
got, err := n2.MissingAUMs(offer1)
|
|
if err != nil {
|
|
t.Fatalf("MissingAUMs() failed: %v", err)
|
|
}
|
|
|
|
want := []AUM{
|
|
c.AUMs["A3"],
|
|
c.AUMs["A4"],
|
|
}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMissingAUMs_Fork(t *testing.T) {
|
|
// Node 1 has: A1 -> A2 -> A3 -> F1
|
|
// Node 2 has: A1 -> A2 -> A3 -> A4
|
|
c := newTestchain(t, `
|
|
A1 -> A2 -> A3 -> A4
|
|
| -> F1
|
|
A1.hashSeed = 1
|
|
A2.hashSeed = 2
|
|
A3.hashSeed = 3
|
|
A4.hashSeed = 4
|
|
`)
|
|
|
|
chonk1 := c.ChonkWith("A1", "A2", "A3", "F1")
|
|
n1, err := Open(chonk1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer1, err := n1.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chonk2 := c.ChonkWith("A1", "A2", "A3", "A4")
|
|
n2, err := Open(chonk2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offer2, err := n2.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Run("n1", func(t *testing.T) {
|
|
got, err := n1.MissingAUMs(offer2)
|
|
if err != nil {
|
|
t.Fatalf("MissingAUMs() failed: %v", err)
|
|
}
|
|
|
|
// Both sides have A1, so n1 will send everything it knows from
|
|
// there to head.
|
|
want := []AUM{
|
|
c.AUMs["A2"],
|
|
c.AUMs["A3"],
|
|
c.AUMs["F1"],
|
|
}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
|
|
t.Run("n2", func(t *testing.T) {
|
|
got, err := n2.MissingAUMs(offer1)
|
|
if err != nil {
|
|
t.Fatalf("MissingAUMs() failed: %v", err)
|
|
}
|
|
|
|
// Both sides have A1, so n2 will send everything it knows from
|
|
// there to head.
|
|
want := []AUM{
|
|
c.AUMs["A2"],
|
|
c.AUMs["A3"],
|
|
c.AUMs["A4"],
|
|
}
|
|
if diff := cmp.Diff(want, got); diff != "" {
|
|
t.Errorf("MissingAUMs diff (-want, +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSyncSimpleE2E(t *testing.T) {
|
|
pub, priv := testingKey25519(t, 1)
|
|
key := Key{Kind: Key25519, Public: pub, Votes: 2}
|
|
|
|
c := newTestchain(t, `
|
|
G1 -> L1 -> L2 -> L3
|
|
G1.template = genesis
|
|
`,
|
|
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
|
|
Keys: []Key{key},
|
|
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
|
|
}}),
|
|
optKey("key", key, priv),
|
|
optSignAllUsing("key"))
|
|
|
|
node, err := Bootstrap(&Mem{}, c.AUMs["G1"])
|
|
if err != nil {
|
|
t.Fatalf("node Bootstrap() failed: %v", err)
|
|
}
|
|
control, err := Open(c.Chonk())
|
|
if err != nil {
|
|
t.Fatalf("control Open() failed: %v", err)
|
|
}
|
|
|
|
// Control knows the full chain, node only knows the genesis. Lets see
|
|
// if they can sync.
|
|
nodeOffer, err := node.SyncOffer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
controlAUMs, err := control.MissingAUMs(nodeOffer)
|
|
if err != nil {
|
|
t.Fatalf("control.MissingAUMs(%v) failed: %v", nodeOffer, err)
|
|
}
|
|
if err := node.Inform(controlAUMs); err != nil {
|
|
t.Fatalf("node.Inform(%v) failed: %v", controlAUMs, err)
|
|
}
|
|
|
|
if cHash, nHash := control.Head(), node.Head(); cHash != nHash {
|
|
t.Errorf("node & control are not synced: c=%x, n=%x", cHash, nHash)
|
|
}
|
|
}
|