c/bmaas/bmdb: implement OS installation flow
This adds two new tags: OSInstallationRequest and
OSInstallationResponse. It also implements interacting with these tags
from the agent side.
This doesn't yet implement any admin/user-facing API to actually request
OS installation, for now we just exercise this in tests.
Change-Id: I2e31a8369a3a8670bb92bcacfb8231a0d5e1b9fd
Reviewed-on: https://review.monogon.dev/c/monogon/+/1011
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/bmaas/server/agent_callback_service_test.go b/cloud/bmaas/server/agent_callback_service_test.go
index bc3201a..320bb68 100644
--- a/cloud/bmaas/server/agent_callback_service_test.go
+++ b/cloud/bmaas/server/agent_callback_service_test.go
@@ -9,6 +9,7 @@
"github.com/google/uuid"
"google.golang.org/grpc"
+ "google.golang.org/protobuf/proto"
"source.monogon.dev/cloud/bmaas/bmdb"
"source.monogon.dev/cloud/bmaas/bmdb/model"
@@ -114,3 +115,138 @@
// TODO(q3k): test hardware report being attached once we have some debug API
// for tags.
}
+
+// TestOSInstallationFlow exercises the agent's OS installation request/report
+// functionality.
+func TestOSInstallationFlow(t *testing.T) {
+ s := dut()
+ ctx, ctxC := context.WithCancel(context.Background())
+ defer ctxC()
+ s.Start(ctx)
+
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("could not generate keypair: %v", err)
+ }
+
+ sess, err := s.bmdb.StartSession(ctx)
+ if err != nil {
+ t.Fatalf("could not start session")
+ }
+
+ heartbeat := func(mid uuid.UUID, report *apb.OSInstallationReport) (*apb.AgentHeartbeatResponse, error) {
+ creds, err := rpc.NewEphemeralCredentials(priv, nil)
+ if err != nil {
+ t.Fatalf("could not generate ephemeral credentials: %v", err)
+ }
+ conn, err := grpc.Dial(s.ListenPublic, grpc.WithTransportCredentials(creds))
+ if err != nil {
+ t.Fatalf("Dial failed: %v", err)
+ }
+ defer conn.Close()
+
+ stub := apb.NewAgentCallbackClient(conn)
+ return stub.Heartbeat(ctx, &apb.AgentHeartbeatRequest{
+ MachineId: mid.String(),
+ HardwareReport: &apb.AgentHardwareReport{},
+ InstallationReport: report,
+ })
+ }
+
+ // Create machine with no OS installation request.
+ var machine model.Machine
+ err = sess.Transact(ctx, func(q *model.Queries) error {
+ machine, err = q.NewMachine(ctx)
+ if err != nil {
+ return err
+ }
+ err = q.MachineAddProvided(ctx, model.MachineAddProvidedParams{
+ MachineID: machine.MachineID,
+ Provider: model.ProviderEquinix,
+ ProviderID: "123",
+ })
+ if err != nil {
+ return err
+ }
+ return q.MachineSetAgentStarted(ctx, model.MachineSetAgentStartedParams{
+ MachineID: machine.MachineID,
+ AgentStartedAt: time.Now(),
+ AgentPublicKey: pub,
+ })
+ })
+ if err != nil {
+ t.Fatalf("could not create machine: %v", err)
+ }
+
+ // Expect successful heartbeat, but no OS installation request.
+ hbr, err := heartbeat(machine.MachineID, nil)
+ if err != nil {
+ t.Fatalf("heartbeat: %v", err)
+ }
+ if hbr.InstallationRequest != nil {
+ t.Fatalf("expected no installation request")
+ }
+
+ // Now add an OS installation request tag, and expect it to be returned.
+ err = sess.Transact(ctx, func(q *model.Queries) error {
+ req := apb.OSInstallationRequest{
+ Generation: 123,
+ }
+ raw, _ := proto.Marshal(&req)
+ return q.MachineSetOSInstallationRequest(ctx, model.MachineSetOSInstallationRequestParams{
+ MachineID: machine.MachineID,
+ Generation: req.Generation,
+ OsInstallationRequestRaw: raw,
+ })
+ })
+ if err != nil {
+ t.Fatalf("could not add os installation request to machine: %v", err)
+ }
+
+ // Heartbeat a few times just to make sure every response is as expected.
+ for i := 0; i < 3; i++ {
+ hbr, err = heartbeat(machine.MachineID, nil)
+ if err != nil {
+ t.Fatalf("heartbeat: %v", err)
+ }
+ if hbr.InstallationRequest == nil || hbr.InstallationRequest.Generation != 123 {
+ t.Fatalf("expected installation request for generation 123, got %+v", hbr.InstallationRequest)
+ }
+ }
+
+ // Submit a report, expect no more request.
+ hbr, err = heartbeat(machine.MachineID, &apb.OSInstallationReport{Generation: 123})
+ if err != nil {
+ t.Fatalf("heartbeat: %v", err)
+ }
+ if hbr.InstallationRequest != nil {
+ t.Fatalf("expected no installation request")
+ }
+
+ // Submit a newer request, expect it to be returned.
+ err = sess.Transact(ctx, func(q *model.Queries) error {
+ req := apb.OSInstallationRequest{
+ Generation: 234,
+ }
+ raw, _ := proto.Marshal(&req)
+ return q.MachineSetOSInstallationRequest(ctx, model.MachineSetOSInstallationRequestParams{
+ MachineID: machine.MachineID,
+ Generation: req.Generation,
+ OsInstallationRequestRaw: raw,
+ })
+ })
+ if err != nil {
+ t.Fatalf("could not update installation request: %v", err)
+ }
+
+ // Heartbeat a few times just to make sure every response is as expected.
+ for i := 0; i < 3; i++ {
+ hbr, err = heartbeat(machine.MachineID, nil)
+ if err != nil {
+ t.Fatalf("heartbeat: %v", err)
+ }
+ if hbr.InstallationRequest == nil || hbr.InstallationRequest.Generation != 234 {
+ t.Fatalf("expected installation request for generation 234, got %+v", hbr.InstallationRequest)
+ }
+ }
+}