internal/transport/fieldnumbers_test.go
Ref: Size: 12.2 KiB History
package transport
import (
"fmt"
"testing"
"github.com/a73x/eitri/internal/pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
)
// A proto field's NUMBER is its wire identity: it is what a peer that predates a
// field reads past, and what an agent one release behind matches its known
// fields against. Renaming a field is a source change; RENUMBERING one is a wire
// change that no compiler and no `make proto-check` can see — the code builds,
// the generated Go regenerates cleanly, and every fielded agent silently reads
// the moved field as absent (see contract_test.go for what "absent" then means:
// a dropped exposure set, an uncertified host key, a guest stranded ephemeral).
//
// These two tests make a renumber loud. The first LOCKS every field number in
// the wire schema against a written-down table, so no number can move — or field
// appear or vanish — without this table and a human moving with it. The second
// proves the table is not lying by exercising every field through a real
// round-trip, so a field the wire never actually carries cannot hide behind a
// table entry.
// wireSchema is the committed truth: every message in proto/eitri/v1/sync.proto,
// every field, and the number it is pinned to. Reserved numbers are deliberately
// absent — a reserved slot carries nothing, and re-using one is exactly the
// mistake this table exists to catch (a new field on an old number reads, on a
// fielded agent, as whatever used to live there). Update this table ONLY in the
// same change that edits the .proto, and only ever by adding — never by moving.
var wireSchema = map[string]map[string]protoreflect.FieldNumber{
"AgentMessage": {
"hello": 1,
"report": 2,
"console_opened": 3,
"tcp_opened": 4,
},
"ServerMessage": {
"snapshot": 1,
"console_open": 2,
"tcp_open": 3,
},
"Hello": {
"host_id": 1,
"hostname": 2,
"os": 3,
"arch": 4,
"provisioner": 5,
"last_seen_epoch": 7,
"capacity": 8,
"credential": 9,
"facts": 10,
"host_networks": 11,
},
"Capacity": {
"vcpus": 1,
"mem_mb": 2,
"disk_gb": 3,
},
"HostFacts": {
"os_id": 1,
"os_pretty": 2,
"os_version": 3,
"kernel": 4,
"cpu_model": 5,
"virt": 6,
"agent_version": 7,
},
"HostMetrics": {
"uptime_s": 1,
"mem_used_mb": 2,
"mem_available_mb": 3,
"load1": 4,
"load5": 5,
"load15": 6,
"disk_used_gb": 7,
"disk_free_gb": 8,
},
"VMStatus": {
"vm_id": 1,
"power_state": 2,
"phase": 3,
"ip": 4,
"last_error": 5,
"ssh_host_pubkey": 6,
"status_detail": 7,
"network_ip": 8,
},
"QuarantinedVM": {
"vm_id": 1,
"name": 2,
"vmspec_json": 3,
"destroy_at_unix": 4,
},
"Report": {
"vms": 1,
"destroyed": 2,
"quarantined": 3,
"capacity": 4,
"fence_violation": 5,
"last_seen_epoch": 6,
"metrics": 7,
"guest_cidr": 8,
"exposures": 9,
"host_uplink_addr": 10,
"volumes": 11,
},
"VMSpec": {
"vm_id": 1,
"name": 2,
"image_url": 3,
"image_sha256": 4,
"cloud_init": 5,
"vcpus": 6,
"mem_mb": 7,
"disk_gb": 8,
"persistent": 9,
"power_state": 10,
"tombstoned": 11,
"ssh_authorized_key": 12,
"ssh_host_cert": 17,
"ssh_user_ca_authorized_keys": 18,
"host_cert_required": 19,
"network": 20,
"volume_ids": 21,
},
"Snapshot": {
"epoch": 1,
"vms": 2,
"agent_upgrade": 3,
"exposures": 4,
"volumes": 5,
"min_agent_version": 6,
},
"AgentUpgrade": {
"version": 1,
"url": 2,
"sha256": 3,
},
"ConsoleOpen": {
"vm_id": 1,
},
"ConsoleOpened": {
"ok": 1,
"error": 2,
},
"TCPOpen": {
"vm_id": 1,
"port": 2,
},
"TCPOpened": {
"ok": 1,
"error": 2,
},
"ExposureSpec": {
"id": 1,
"vm_id": 2,
"guest_port": 3,
"host_port": 4,
"protocol": 5,
},
"ExposureStatus": {
"id": 1,
"state": 2,
"reason": 3,
"sessions": 4,
},
"ExposureSessions": {
"active": 1,
"refused": 2,
"dropped": 3,
},
"VolumeSpec": {
"volume_id": 1,
"size_gb": 2,
"tombstoned": 3,
},
"VolumeStatus": {
"volume_id": 1,
"present": 2,
"size_gb": 3,
},
}
// TestWireFieldNumbersAreLocked walks every message in the compiled wire schema
// and asserts its field numbers, exactly, against wireSchema. It fails three
// ways, each the tripwire it is meant to be: a field whose number moved, a field
// added to the .proto without a table entry, and a table entry for a field the
// .proto no longer has. Because it enumerates the descriptors rather than the
// table, no message and no field can slip the check by being left out.
func TestWireFieldNumbersAreLocked(t *testing.T) {
fd := pb.File_proto_eitri_v1_sync_proto
require.NotNil(t, fd, "wire file descriptor is not registered")
msgs := fd.Messages()
seen := make(map[string]bool, msgs.Len())
for i := 0; i < msgs.Len(); i++ {
md := msgs.Get(i)
name := string(md.Name())
seen[name] = true
want, ok := wireSchema[name]
if !assert.Truef(t, ok, "message %s is in the wire schema but not in wireSchema — add its field numbers to the table", name) {
continue
}
fields := md.Fields()
got := make(map[string]protoreflect.FieldNumber, fields.Len())
for j := 0; j < fields.Len(); j++ {
f := fields.Get(j)
got[string(f.Name())] = f.Number()
}
for fname, wantNum := range want {
gotNum, present := got[fname]
if !assert.Truef(t, present, "%s.%s is in wireSchema but not in the .proto", name, fname) {
continue
}
assert.Equalf(t, wantNum, gotNum, "%s.%s field number moved: schema says %d, wire says %d", name, fname, wantNum, gotNum)
}
for fname := range got {
assert.Containsf(t, want, fname, "%s.%s is a new field with no wireSchema entry — pin its number in the table", name, fname)
}
}
for name := range wireSchema {
assert.Truef(t, seen[name], "wireSchema has message %s that the .proto no longer defines", name)
}
}
// TestEveryFieldSurvivesARoundTrip is the completeness half: it proves wireSchema
// is not a comfortable fiction. contract_test.go round-trips hand-written
// literals, so a field simply left out of a literal round-trips trivially and is
// never tested — the very gap that let a renumber pass unseen. Here, for every
// message and every field, we set that ONE field to a distinct non-zero sentinel
// by reflection, marshal it through the real wire codec, read it back, and assert
// the field came back set and equal. A field wired to a number no peer expects
// would decode as absent and fail proto.Equal; a field the schema forgot cannot
// be forgotten here because we iterate the descriptors, not a list.
//
// One field at a time (rather than every field at once) is deliberate: it sets
// oneof members without them evicting each other, it needs no per-message
// knowledge of which fields conflict, and it pins each number independently, so
// two same-typed fields swapping numbers is caught by distinct sentinels rather
// than masked by a shared value.
func TestEveryFieldSurvivesARoundTrip(t *testing.T) {
fd := pb.File_proto_eitri_v1_sync_proto
msgs := fd.Messages()
for i := 0; i < msgs.Len(); i++ {
md := msgs.Get(i)
fields := md.Fields()
for j := 0; j < fields.Len(); j++ {
f := fields.Get(j)
t.Run(fmt.Sprintf("%s/%s", md.Name(), f.Name()), func(t *testing.T) {
in := newMessage(t, md)
setSentinel(t, in, f)
out := newMessage(t, md)
roundTrip(t, in.Interface(), out.Interface())
assert.Truef(t, out.Has(f),
"%s.%s did not survive the round-trip — its wire number is not the one the codec reads",
md.Name(), f.Name())
assert.Truef(t, proto.Equal(in.Interface(), out.Interface()),
"%s.%s round-tripped to a different value:\n in=%v\nout=%v",
md.Name(), f.Name(), in.Interface(), out.Interface())
})
}
}
}
// newMessage makes a fresh, empty message for a descriptor, using its registered
// Go type so proto.Marshal/Unmarshal and proto.Equal all operate on the real
// generated types rather than a dynamic stand-in.
//
//nolint:ireturn // protoreflect.Message IS the interface the reflection API returns; there is no concrete type here.
func newMessage(t *testing.T, md protoreflect.MessageDescriptor) protoreflect.Message {
t.Helper()
mt, err := protoregistry.GlobalTypes.FindMessageByName(md.FullName())
require.NoErrorf(t, err, "no registered Go type for %s", md.FullName())
return mt.New()
}
// setSentinel sets exactly field f of m to a distinct, non-zero value. For a
// message or repeated field it builds a minimally-populated value so the field
// is present on the wire; the point is that the NUMBER carries, not that nested
// content is exhaustive (each nested message is exercised in full as its own
// top-level case).
func setSentinel(t *testing.T, m protoreflect.Message, f protoreflect.FieldDescriptor) {
t.Helper()
switch {
case f.IsList():
list := m.NewField(f).List()
list.Append(scalarSentinel(t, f))
m.Set(f, protoreflect.ValueOfList(list))
case f.IsMap():
// No map fields exist in this schema; fail loudly if one is added so
// this guard is extended rather than silently skipping the field.
t.Fatalf("%s is a map field — extend setSentinel to cover maps", f.FullName())
default:
m.Set(f, scalarSentinel(t, f))
}
}
// scalarSentinel returns a distinct non-zero value for one (non-list) element of
// f's element type.
func scalarSentinel(t *testing.T, f protoreflect.FieldDescriptor) protoreflect.Value {
t.Helper()
switch f.Kind() {
case protoreflect.BoolKind:
return protoreflect.ValueOfBool(true)
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
return protoreflect.ValueOfInt32(int32(f.Number()) + 1)
case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
return protoreflect.ValueOfInt64(int64(f.Number()) + 1)
case protoreflect.Uint32Kind, protoreflect.Fixed32Kind:
return protoreflect.ValueOfUint32(uint32(f.Number()) + 1)
case protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
return protoreflect.ValueOfUint64(uint64(f.Number()) + 1)
case protoreflect.FloatKind:
return protoreflect.ValueOfFloat32(float32(f.Number()) + 1.5)
case protoreflect.DoubleKind:
return protoreflect.ValueOfFloat64(float64(f.Number()) + 1.5)
case protoreflect.StringKind:
return protoreflect.ValueOfString(fmt.Sprintf("sentinel-%s-%d", f.Name(), f.Number()))
case protoreflect.BytesKind:
return protoreflect.ValueOfBytes([]byte(fmt.Sprintf("sentinel-%s-%d", f.Name(), f.Number())))
case protoreflect.EnumKind:
// No enums in this schema; pick the first non-zero value if one is ever
// added, else fail so the guard is extended deliberately.
vals := f.Enum().Values()
if vals.Len() < 2 {
t.Fatalf("%s is an enum with no non-zero value — extend scalarSentinel", f.FullName())
}
return protoreflect.ValueOfEnum(vals.Get(1).Number())
case protoreflect.MessageKind, protoreflect.GroupKind:
nested := newMessage(t, f.Message())
populateOne(t, nested)
return protoreflect.ValueOfMessage(nested)
default:
t.Fatalf("%s has unhandled kind %v — extend scalarSentinel", f.FullName(), f.Kind())
return protoreflect.Value{}
}
}
// populateOne sets a single non-zero field on a nested message so that the
// message is non-empty on the wire (an all-zero nested message would still marshal
// to a present-but-empty field, but a populated one is a stronger witness). It is
// intentionally shallow: full field coverage of every message comes from that
// message's own top-level cases.
func populateOne(t *testing.T, m protoreflect.Message) {
t.Helper()
fields := m.Descriptor().Fields()
if fields.Len() == 0 {
return
}
setSentinel(t, m, fields.Get(0))
}