a73x

internal/transport/wirecompat_test.go

Ref:   Size: 12.5 KiB   History

package transport

import (
	"encoding/hex"
	"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"
)

// The renames in "wire: the spec/status vocabulary the loop always meant"
// (VMDesired -> VMSpec, ActualVM -> VMStatus, ExposureDesired/Actual ->
// ExposureSpec/Status, power -> power_state, and the two envelopes) rested on
// an argument: protobuf serialises field numbers, not names, so an agent built
// against the old .proto still talks to a server built against the new one.
// fieldnumbers_test.go locks the numbers, but both halves of every round-trip
// there are built from TODAY's descriptors — a coordinated rename plus renumber
// plus table edit would pass it, and every other test in this package, while
// breaking every fielded agent.
//
// These fixtures close that. They are real wire bytes, produced from the
// descriptor set of proto/eitri/v1/sync.proto AS IT STOOD AT TAG v0.0.7 — the
// last release before the rename — with every field in the tree populated, and
// asserted here to decode into today's generated types losing nothing.
//
// They are a CAPTURE, not a golden file: never regenerate them to make a test
// pass. A diff here is the wire moving under a fielded agent, which is the one
// thing this package exists to prevent. Adding a field is invisible to them by
// design — old bytes simply do not carry it — but moving or reusing a number is
// not, and neither is narrowing a type.
//
// Reproducing the capture, to audit it rather than to update it: build a
// FileDescriptorSet from `git show v0.0.7:proto/eitri/v1/sync.proto`, populate
// every field of eitri.v1.AgentMessage{report} / ServerMessage{snapshot}
// through dynamicpb with the values below (strings are the field's full name
// plus "#<index>", int64 is 1000+10*number, uint64 2000+10*number, doubles
// number+0.5, bytes the field's full name, bools true, repeated fields two
// elements), and marshal deterministically.

// v007ReportFrame is a v0.0.7 AgentMessage{ActualStateReport}, every field set.
const v007ReportFrame = "" +
	"12F8080AF1010A1965697472692E76312E41637475616C564D2E766D5F696423" +
	"30121965697472692E76312E41637475616C564D2E706F77657223301A196569" +
	"7472692E76312E41637475616C564D2E70686173652330221665697472692E76" +
	"312E41637475616C564D2E697023302A1E65697472692E76312E41637475616C" +
	"564D2E6C6173745F6572726F722330322365697472692E76312E41637475616C" +
	"564D2E7373685F686F73745F7075626B657923303A2165697472692E76312E41" +
	"637475616C564D2E7374617475735F64657461696C2330421E65697472692E76" +
	"312E41637475616C564D2E6E6574776F726B5F697023300AF1010A1965697472" +
	"692E76312E41637475616C564D2E766D5F69642330121965697472692E76312E" +
	"41637475616C564D2E706F77657223301A1965697472692E76312E4163747561" +
	"6C564D2E70686173652330221665697472692E76312E41637475616C564D2E69" +
	"7023302A1E65697472692E76312E41637475616C564D2E6C6173745F6572726F" +
	"722330322365697472692E76312E41637475616C564D2E7373685F686F73745F" +
	"7075626B657923303A2165697472692E76312E41637475616C564D2E73746174" +
	"75735F64657461696C2330421E65697472692E76312E41637475616C564D2E6E" +
	"6574776F726B5F69702330122665697472692E76312E41637475616C53746174" +
	"655265706F72742E64657374726F7965642330122665697472692E76312E4163" +
	"7475616C53746174655265706F72742E64657374726F79656423311A660A1E65" +
	"697472692E76312E51756172616E74696E6564564D2E766D5F69642330121D65" +
	"697472692E76312E51756172616E74696E6564564D2E6E616D6523301A226569" +
	"7472692E76312E51756172616E74696E6564564D2E766D737065635F6A736F6E" +
	"2090081A660A1E65697472692E76312E51756172616E74696E6564564D2E766D" +
	"5F69642330121D65697472692E76312E51756172616E74696E6564564D2E6E61" +
	"6D6523301A2265697472692E76312E51756172616E74696E6564564D2E766D73" +
	"7065635F6A736F6E209008220908F20710FC071886082801308C103A2A08F207" +
	"10FC07188608210000000000001240290000000000001640310000000000001A" +
	"4038AE0840B808422765697472692E76312E41637475616C5374617465526570" +
	"6F72742E67756573745F6369647223304A6C0A1C65697472692E76312E457870" +
	"6F7375726541637475616C2E69642330121F65697472692E76312E4578706F73" +
	"75726541637475616C2E737461746523301A2065697472692E76312E4578706F" +
	"7375726541637475616C2E726561736F6E2330220908F20710FC071886084A6C" +
	"0A1C65697472692E76312E4578706F7375726541637475616C2E69642330121F" +
	"65697472692E76312E4578706F7375726541637475616C2E737461746523301A" +
	"2065697472692E76312E4578706F7375726541637475616C2E726561736F6E23" +
	"30220908F20710FC07188608522D65697472692E76312E41637475616C537461" +
	"74655265706F72742E686F73745F75706C696E6B5F616464722330"

// v007SnapshotFrame is a v0.0.7 ServerMessage{DesiredStateSnapshot}, every
// field set.
const v007SnapshotFrame = "" +
	"0A850908DA0F12A0030A1A65697472692E76312E564D446573697265642E766D" +
	"5F69642330121965697472692E76312E564D446573697265642E6E616D652330" +
	"1A1E65697472692E76312E564D446573697265642E696D6167655F75726C2330" +
	"222165697472692E76312E564D446573697265642E696D6167655F7368613235" +
	"3623302A1F65697472692E76312E564D446573697265642E636C6F75645F696E" +
	"6974233030A40838AE0840B8084801522065697472692E76312E564D44657369" +
	"7265642E706F7765725F737461746523305801622765697472692E76312E564D" +
	"446573697265642E7373685F617574686F72697A65645F6B657923308A012265" +
	"697472692E76312E564D446573697265642E7373685F686F73745F6365727423" +
	"3092013065697472692E76312E564D446573697265642E7373685F757365725F" +
	"63615F617574686F72697A65645F6B657973233092013065697472692E76312E" +
	"564D446573697265642E7373685F757365725F63615F617574686F72697A6564" +
	"5F6B6579732331980101A2011C65697472692E76312E564D446573697265642E" +
	"6E6574776F726B233012A0030A1A65697472692E76312E564D44657369726564" +
	"2E766D5F69642330121965697472692E76312E564D446573697265642E6E616D" +
	"6523301A1E65697472692E76312E564D446573697265642E696D6167655F7572" +
	"6C2330222165697472692E76312E564D446573697265642E696D6167655F7368" +
	"6132353623302A1F65697472692E76312E564D446573697265642E636C6F7564" +
	"5F696E6974233030A40838AE0840B8084801522065697472692E76312E564D44" +
	"6573697265642E706F7765725F737461746523305801622765697472692E7631" +
	"2E564D446573697265642E7373685F617574686F72697A65645F6B657923308A" +
	"012265697472692E76312E564D446573697265642E7373685F686F73745F6365" +
	"7274233092013065697472692E76312E564D446573697265642E7373685F7573" +
	"65725F63615F617574686F72697A65645F6B657973233092013065697472692E" +
	"76312E564D446573697265642E7373685F757365725F63615F617574686F7269" +
	"7A65645F6B6579732331980101A2011C65697472692E76312E564D4465736972" +
	"65642E6E6574776F726B23301A5E0A1F65697472692E76312E4167656E745570" +
	"67726164652E76657273696F6E2330121B65697472692E76312E4167656E7455" +
	"7067726164652E75726C23301A1E65697472692E76312E4167656E7455706772" +
	"6164652E7368613235362330226C0A1D65697472692E76312E4578706F737572" +
	"65446573697265642E69642330122065697472692E76312E4578706F73757265" +
	"446573697265642E766D5F6964233018AE0320B8032A2365697472692E76312E" +
	"4578706F73757265446573697265642E70726F746F636F6C2330226C0A1D6569" +
	"7472692E76312E4578706F73757265446573697265642E696423301220656974" +
	"72692E76312E4578706F73757265446573697265642E766D5F6964233018AE03" +
	"20B8032A2365697472692E76312E4578706F73757265446573697265642E7072" +
	"6F746F636F6C2330"

func TestV007AgentFrameDecodesIntoTodaysReport(t *testing.T) {
	var m pb.AgentMessage
	require.NoError(t, proto.Unmarshal(mustHex(t, v007ReportFrame), &m))

	r := m.GetReport()
	require.NotNil(t, r, "a v0.0.7 report must still arrive in the report branch of the oneof")
	assertNoUnknownFields(t, m.ProtoReflect(), "AgentMessage")

	// The renamed message: what v0.0.7 called ActualVM.power arrives verbatim in
	// VMStatus.power_state, because both are field 2 of the same message.
	require.Len(t, r.GetVms(), 2)
	vm := r.GetVms()[0]
	assert.Equal(t, "eitri.v1.ActualVM.vm_id#0", vm.GetVmId())
	assert.Equal(t, "eitri.v1.ActualVM.power#0", vm.GetPowerState())
	assert.Equal(t, "eitri.v1.ActualVM.phase#0", vm.GetPhase())
	assert.Equal(t, "eitri.v1.ActualVM.ip#0", vm.GetIp())
	assert.Equal(t, "eitri.v1.ActualVM.last_error#0", vm.GetLastError())
	assert.Equal(t, "eitri.v1.ActualVM.ssh_host_pubkey#0", vm.GetSshHostPubkey())
	assert.Equal(t, "eitri.v1.ActualVM.status_detail#0", vm.GetStatusDetail())
	assert.Equal(t, "eitri.v1.ActualVM.network_ip#0", vm.GetNetworkIp())

	// The renamed envelope's own fields.
	assert.Equal(t, "eitri.v1.ActualStateReport.guest_cidr#0", r.GetGuestCidr())
	assert.Equal(t, "eitri.v1.ActualStateReport.host_uplink_addr#0", r.GetHostUplinkAddr())
	assert.True(t, r.GetFenceViolation())
	assert.Equal(t, uint64(2060), r.GetLastSeenEpoch())
	require.NotNil(t, r.GetCapacity())
	assert.Equal(t, int64(1010), r.GetCapacity().GetVcpus())
	require.NotNil(t, r.GetMetrics())
	assert.InEpsilon(t, 4.5, r.GetMetrics().GetLoad1(), 1e-9)
	require.Len(t, r.GetQuarantined(), 2)
	assert.Equal(t, []byte("eitri.v1.QuarantinedVM.vmspec_json"), r.GetQuarantined()[0].GetVmspecJson())
	assert.Len(t, r.GetDestroyed(), 2)

	// ExposureActual -> ExposureStatus, sessions still a nested message.
	require.Len(t, r.GetExposures(), 2)
	ex := r.GetExposures()[0]
	assert.Equal(t, "eitri.v1.ExposureActual.id#0", ex.GetId())
	assert.Equal(t, "eitri.v1.ExposureActual.state#0", ex.GetState())
	require.NotNil(t, ex.GetSessions(), "presence must survive: nil sessions means no counters, not zero counters")
	assert.Equal(t, int64(1010), ex.GetSessions().GetActive())
}

func TestV007ServerFrameDecodesIntoTodaysSnapshot(t *testing.T) {
	var m pb.ServerMessage
	require.NoError(t, proto.Unmarshal(mustHex(t, v007SnapshotFrame), &m))

	s := m.GetSnapshot()
	require.NotNil(t, s, "a v0.0.7 snapshot must still arrive in the snapshot branch of the oneof")
	assertNoUnknownFields(t, m.ProtoReflect(), "ServerMessage")

	assert.Equal(t, uint64(2010), s.GetEpoch())
	require.Len(t, s.GetVms(), 2)
	vm := s.GetVms()[0]
	// VMDesired.power_state was already spelled right; it is field 10 either way.
	assert.Equal(t, "eitri.v1.VMDesired.vm_id#0", vm.GetVmId())
	assert.Equal(t, "eitri.v1.VMDesired.power_state#0", vm.GetPowerState())
	assert.Equal(t, "eitri.v1.VMDesired.network#0", vm.GetNetwork())
	assert.Equal(t, "eitri.v1.VMDesired.ssh_host_cert#0", vm.GetSshHostCert())
	assert.True(t, vm.GetHostCertRequired())
	assert.True(t, vm.GetTombstoned())
	assert.True(t, vm.GetPersistent(), "field 9 still means persistent on a v0.0.7 wire")
	assert.Len(t, vm.GetSshUserCaAuthorizedKeys(), 2)

	require.NotNil(t, s.GetAgentUpgrade())
	require.Len(t, s.GetExposures(), 2)
	assert.Equal(t, "eitri.v1.ExposureDesired.id#0", s.GetExposures()[0].GetId())
}

// A field the current schema does not claim lands in the unknown-fields set
// rather than failing to parse, so silence there is the real assertion: every
// number a v0.0.7 peer writes is still a number this build knows, at the same
// wire type. Recurses, because a renumber three messages deep parses fine at
// the top.
func assertNoUnknownFields(t *testing.T, m protoreflect.Message, path string) {
	t.Helper()
	if u := m.GetUnknown(); len(u) > 0 {
		t.Errorf("%s carries %d bytes this build does not recognise (%x) — a field number moved or was reused", path, len(u), []byte(u))
	}
	m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
		switch {
		case fd.IsMap():
		case fd.Kind() != protoreflect.MessageKind && fd.Kind() != protoreflect.GroupKind:
		case fd.IsList():
			l := v.List()
			for i := range l.Len() {
				assertNoUnknownFields(t, l.Get(i).Message(), fmt.Sprintf("%s.%s[%d]", path, fd.Name(), i))
			}
		default:
			assertNoUnknownFields(t, v.Message(), path+"."+string(fd.Name()))
		}
		return true
	})
}

// Re-encoding what a v0.0.7 peer sent must reproduce it byte for byte. Field
// numbers, wire types, ordering and repeated-element count all have to match,
// which catches the losses the getters above are too specific to see: a field
// this build no longer defines, or one whose type changed under it.
func TestV007FramesReEncodeByteIdentically(t *testing.T) {
	for _, tc := range []struct {
		name string
		hex  string
		msg  proto.Message
	}{
		{"report", v007ReportFrame, &pb.AgentMessage{}},
		{"snapshot", v007SnapshotFrame, &pb.ServerMessage{}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			want := mustHex(t, tc.hex)
			require.NoError(t, proto.Unmarshal(want, tc.msg))
			got, err := proto.MarshalOptions{Deterministic: true}.Marshal(tc.msg)
			require.NoError(t, err)
			assert.Equal(t, want, got, "re-encoding a v0.0.7 frame changed it")
		})
	}
}

func mustHex(t *testing.T, s string) []byte {
	t.Helper()
	b, err := hex.DecodeString(s)
	require.NoError(t, err)
	return b
}