|  | package e2e | 
|  |  | 
|  | import ( | 
|  | "bufio" | 
|  | "bytes" | 
|  | "crypto/ed25519" | 
|  | "crypto/rand" | 
|  | "encoding/json" | 
|  | "fmt" | 
|  | "io" | 
|  | "net" | 
|  | "os" | 
|  | "os/exec" | 
|  | "strings" | 
|  | "testing" | 
|  | "time" | 
|  |  | 
|  | "github.com/pkg/sftp" | 
|  | "golang.org/x/crypto/ssh" | 
|  | "google.golang.org/protobuf/proto" | 
|  |  | 
|  | "source.monogon.dev/cloud/agent/api" | 
|  | "source.monogon.dev/metropolis/cli/pkg/datafile" | 
|  | "source.monogon.dev/metropolis/pkg/fat32" | 
|  | "source.monogon.dev/metropolis/pkg/freeport" | 
|  | ) | 
|  |  | 
|  | func TestE2E(t *testing.T) { | 
|  | pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | sshPubKey, err := ssh.NewPublicKey(pubKey) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | sshPrivkey, err := ssh.NewSignerFromKey(privKey) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | // CloudConfig doesn't really have a rigid spec, so just put things into it | 
|  | cloudConfig := make(map[string]any) | 
|  | cloudConfig["ssh_authorized_keys"] = []string{ | 
|  | strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(sshPubKey)), "\n"), | 
|  | } | 
|  |  | 
|  | userData, err := json.Marshal(cloudConfig) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | rootInode := fat32.Inode{ | 
|  | Attrs: fat32.AttrDirectory, | 
|  | Children: []*fat32.Inode{ | 
|  | { | 
|  | Name:    "user-data", | 
|  | Content: strings.NewReader("#cloud-config\n" + string(userData)), | 
|  | }, | 
|  | { | 
|  | Name:    "meta-data", | 
|  | Content: strings.NewReader(""), | 
|  | }, | 
|  | }, | 
|  | } | 
|  | cloudInitDataFile, err := os.CreateTemp("", "cidata*.img") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer os.Remove(cloudInitDataFile.Name()) | 
|  | if err := fat32.WriteFS(cloudInitDataFile, rootInode, fat32.Options{Label: "cidata"}); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | cloudImagePath, err := datafile.ResolveRunfile("external/debian_11_cloudimage/file/downloaded") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | ovmfVarsPath, err := datafile.ResolveRunfile("external/edk2/OVMF_VARS.fd") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | ovmfCodePath, err := datafile.ResolveRunfile("external/edk2/OVMF_CODE.fd") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | sshPort, sshPortCloser, err := freeport.AllocateTCPPort() | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  |  | 
|  | qemuArgs := []string{ | 
|  | "-machine", "q35", "-accel", "kvm", "-nographic", "-nodefaults", "-m", "1024", | 
|  | "-cpu", "host", "-smp", "sockets=1,cpus=1,cores=2,threads=2,maxcpus=4", | 
|  | "-drive", "if=pflash,format=raw,readonly,file=" + ovmfCodePath, | 
|  | "-drive", "if=pflash,format=raw,snapshot=on,file=" + ovmfVarsPath, | 
|  | "-drive", "if=virtio,format=qcow2,snapshot=on,cache=unsafe,file=" + cloudImagePath, | 
|  | "-drive", "if=virtio,format=raw,snapshot=on,file=" + cloudInitDataFile.Name(), | 
|  | "-netdev", fmt.Sprintf("user,id=net0,net=10.42.0.0/24,dhcpstart=10.42.0.10,hostfwd=tcp::%d-:22", sshPort), | 
|  | "-device", "virtio-net-pci,netdev=net0,mac=22:d5:8e:76:1d:07", | 
|  | "-device", "virtio-rng-pci", | 
|  | "-serial", "stdio", | 
|  | "-no-reboot", | 
|  | } | 
|  | qemuCmd := exec.Command("qemu-system-x86_64", qemuArgs...) | 
|  | stdoutPipe, err := qemuCmd.StdoutPipe() | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | agentStarted := make(chan struct{}) | 
|  | go func() { | 
|  | s := bufio.NewScanner(stdoutPipe) | 
|  | for s.Scan() { | 
|  | t.Log("kernel: " + s.Text()) | 
|  | if strings.Contains(s.Text(), "Monogon BMaaS Agent started") { | 
|  | agentStarted <- struct{}{} | 
|  | break | 
|  | } | 
|  | } | 
|  | qemuCmd.Wait() | 
|  | }() | 
|  | qemuCmd.Stderr = os.Stderr | 
|  | sshPortCloser.Close() | 
|  | if err := qemuCmd.Start(); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer qemuCmd.Process.Kill() | 
|  |  | 
|  | var c *ssh.Client | 
|  | for { | 
|  | c, err = ssh.Dial("tcp", net.JoinHostPort("localhost", fmt.Sprintf("%d", sshPort)), &ssh.ClientConfig{ | 
|  | User:            "debian", | 
|  | Auth:            []ssh.AuthMethod{ssh.PublicKeys(sshPrivkey)}, | 
|  | HostKeyCallback: ssh.InsecureIgnoreHostKey(), | 
|  | Timeout:         5 * time.Second, | 
|  | }) | 
|  | if err != nil { | 
|  | t.Logf("error connecting via SSH, retrying: %v", err) | 
|  | time.Sleep(1 * time.Second) | 
|  | continue | 
|  | } | 
|  | break | 
|  | } | 
|  | defer c.Close() | 
|  | sc, err := sftp.NewClient(c) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer sc.Close() | 
|  | takeoverFile, err := sc.Create("takeover") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer takeoverFile.Close() | 
|  | if err := takeoverFile.Chmod(0o755); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | takeoverPath, err := datafile.ResolveRunfile("cloud/takeover/takeover_/takeover") | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | takeoverSrcFile, err := os.Open(takeoverPath) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer takeoverSrcFile.Close() | 
|  | if _, err := io.Copy(takeoverFile, takeoverSrcFile); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | if err := takeoverFile.Close(); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | sc.Close() | 
|  |  | 
|  | sess, err := c.NewSession() | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | defer sess.Close() | 
|  |  | 
|  | init := api.TakeoverInit{ | 
|  | MachineId:     "test", | 
|  | BmaasEndpoint: "localhost:1234", | 
|  | } | 
|  | initRaw, err := proto.Marshal(&init) | 
|  | if err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | sess.Stdin = bytes.NewReader(initRaw) | 
|  | var stdoutBuf bytes.Buffer | 
|  | var stderrBuf bytes.Buffer | 
|  | sess.Stdout = &stdoutBuf | 
|  | sess.Stderr = &stderrBuf | 
|  | if err := sess.Run("sudo ./takeover"); err != nil { | 
|  | t.Errorf("stderr:\n%s\n\n", stderrBuf.String()) | 
|  | t.Fatal(err) | 
|  | } | 
|  | var resp api.TakeoverResponse | 
|  | if err := proto.Unmarshal(stdoutBuf.Bytes(), &resp); err != nil { | 
|  | t.Fatal(err) | 
|  | } | 
|  | switch res := resp.Result.(type) { | 
|  | case *api.TakeoverResponse_Success: | 
|  | if res.Success.InitMessage.BmaasEndpoint != init.BmaasEndpoint { | 
|  | t.Error("InitMessage not passed through properly") | 
|  | } | 
|  | case *api.TakeoverResponse_Error: | 
|  | t.Fatalf("takeover returned error: %v", res.Error.Message) | 
|  | } | 
|  | select { | 
|  | case <-agentStarted: | 
|  | // Done, test passed | 
|  | case <-time.After(30 * time.Second): | 
|  | t.Fatal("Waiting for BMaaS agent startup timed out") | 
|  | } | 
|  | } |