mirror of
https://github.com/linka-cloud/d2vm.git
synced 2025-06-27 23:52:27 +00:00
d2vm/run: add hetzner support
tests: add sysconfig tests for the supported distributions Signed-off-by: Adphi <philippe.adrien.nousse@gmail.com>
This commit is contained in:
@ -16,6 +16,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@ -41,5 +44,12 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, os.Interrupt, os.Kill)
|
||||
go func() {
|
||||
<-sigs
|
||||
fmt.Println()
|
||||
cancel()
|
||||
}()
|
||||
rootCmd.ExecuteContext(ctx)
|
||||
}
|
||||
|
@ -38,5 +38,6 @@ func init() {
|
||||
|
||||
runCmd.AddCommand(run.VboxCmd)
|
||||
runCmd.AddCommand(run.QemuCmd)
|
||||
runCmd.AddCommand(run.HetznerCmd)
|
||||
runCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable Debug output")
|
||||
}
|
||||
|
229
cmd/d2vm/run/hetzner.go
Normal file
229
cmd/d2vm/run/hetzner.go
Normal file
@ -0,0 +1,229 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/hetznercloud/hcloud-go/hcloud"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/svenwiltink/sparsecat"
|
||||
)
|
||||
|
||||
const (
|
||||
serverImg = "ubuntu-20.04"
|
||||
)
|
||||
|
||||
var (
|
||||
hetznerVMType = "cx11"
|
||||
hetznerToken = ""
|
||||
// ash-dc1 fsn1-dc14 hel1-dc2 nbg1-dc3
|
||||
hetznerDatacenter = "hel1-dc2"
|
||||
hetznerServerName = "d2vm"
|
||||
hetznerSSHUser = ""
|
||||
hetznerSSHKeyPath = ""
|
||||
hetznerRemove = false
|
||||
|
||||
HetznerCmd = &cobra.Command{
|
||||
Use: "hetzner [options] image-path",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: Hetzner,
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
HetznerCmd.Flags().StringVarP(&hetznerToken, "token", "t", "", "Hetzner Cloud API token")
|
||||
HetznerCmd.Flags().StringVarP(&hetznerSSHUser, "user", "u", "root", "d2vm image ssh user")
|
||||
HetznerCmd.Flags().StringVarP(&hetznerSSHKeyPath, "ssh-key", "i", "", "d2vm image identity key")
|
||||
HetznerCmd.Flags().BoolVar(&hetznerRemove, "rm", false, "remove server when done")
|
||||
HetznerCmd.Flags().StringVarP(&hetznerServerName, "name", "n", "d2vm", "d2vm server name")
|
||||
}
|
||||
|
||||
func Hetzner(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
if err := runHetzner(ctx, args[0], cmd.InOrStdin(), cmd.ErrOrStderr(), cmd.OutOrStdout()); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func runHetzner(ctx context.Context, imgPath string, stdin io.Reader, stderr io.Writer, stdout io.Writer) error {
|
||||
i, err := ImgInfo(ctx, imgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i.Format != "raw" {
|
||||
return fmt.Errorf("image format must be raw")
|
||||
}
|
||||
src, err := os.Open(imgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
// TODO(adphi): check image format
|
||||
// TODO(adphi): convert to raw if needed
|
||||
|
||||
c := hcloud.NewClient(hcloud.WithToken(hetznerToken))
|
||||
st, _, err := c.ServerType.GetByName(ctx, hetznerVMType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, _, err := c.Image.GetByName(ctx, serverImg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, _, err := c.Location.Get(ctx, hetznerDatacenter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("creating server %s", hetznerServerName)
|
||||
sres, _, err := c.Server.Create(ctx, hcloud.ServerCreateOpts{
|
||||
Name: hetznerServerName,
|
||||
ServerType: st,
|
||||
Image: img,
|
||||
Location: l,
|
||||
StartAfterCreate: hcloud.Bool(false),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, errs := c.Action.WatchProgress(ctx, sres.Action)
|
||||
if err := <-errs; err != nil {
|
||||
return err
|
||||
}
|
||||
remove := true
|
||||
defer func() {
|
||||
if !remove && !hetznerRemove {
|
||||
return
|
||||
}
|
||||
logrus.Infof("removing server %s", hetznerServerName)
|
||||
// we use context.Background() here because we don't want the request to fail if the context has been cancelled
|
||||
if _, err := c.Server.Delete(context.Background(), sres.Server); err != nil {
|
||||
logrus.Fatalf("failed to remove server: %v", err)
|
||||
}
|
||||
}()
|
||||
logrus.Infof("server created with ip: %s", sres.Server.PublicNet.IPv4.IP.String())
|
||||
logrus.Infof("enabling server rescue mode")
|
||||
rres, _, err := c.Server.EnableRescue(ctx, sres.Server, hcloud.ServerEnableRescueOpts{Type: hcloud.ServerRescueTypeLinux64})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, errs = c.Action.WatchProgress(ctx, rres.Action)
|
||||
if err := <-errs; err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("powering on server")
|
||||
pres, _, err := c.Server.Poweron(ctx, sres.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, errs = c.Action.WatchProgress(ctx, pres)
|
||||
if err := <-errs; err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("connecting to server via ssh")
|
||||
sc, err := dialSSHWithTimeout(sres.Server.PublicNet.IPv4.IP.String(), "root", rres.RootPassword, time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sc.Close()
|
||||
logrus.Infof("connection established")
|
||||
sftpc, err := sftp.NewClient(sc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := sftpc.Create("/usr/local/bin/sparsecat")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sftpc.Chmod("/usr/local/bin/sparsecat", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, bytes.NewReader(sparsecatBinary)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
wses, err := sc.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer wses.Close()
|
||||
logrus.Infof("writing image to /dev/sda")
|
||||
done := make(chan struct{})
|
||||
pr := newProgressReader(sparsecat.NewEncoder(src))
|
||||
wses.Stdin = pr
|
||||
go func() {
|
||||
tk := time.NewTicker(time.Second)
|
||||
last := 0
|
||||
for {
|
||||
select {
|
||||
case <-tk.C:
|
||||
b := pr.Progress()
|
||||
logrus.Infof("%s / %d%% transfered ( %s/s)", humanize.Bytes(uint64(b)), int(float64(b)/float64(i.ActualSize)*100), humanize.Bytes(uint64(b-last)))
|
||||
last = b
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
if b, err := wses.CombinedOutput("/usr/local/bin/sparsecat -r -disable-sparse-target -of /dev/sda"); err != nil {
|
||||
logrus.Fatalf("%v: %s", err, string(b))
|
||||
}
|
||||
close(done)
|
||||
logrus.Infof("rebooting server")
|
||||
rbres, _, err := c.Server.Reboot(ctx, sres.Server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, errs = c.Action.WatchProgress(ctx, rbres)
|
||||
if err := <-errs; err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Infof("server created")
|
||||
remove = false
|
||||
args := []string{"-o", "StrictHostKeyChecking=no"}
|
||||
if hetznerSSHKeyPath != "" {
|
||||
args = append(args, "-i", hetznerSSHKeyPath)
|
||||
}
|
||||
args = append(args, fmt.Sprintf("%s@%s", hetznerSSHUser, sres.Server.PublicNet.IPv4.IP.String()))
|
||||
makeCmd := func() *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdout = stdout
|
||||
return cmd
|
||||
}
|
||||
t := time.NewTimer(time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
return fmt.Errorf("ssh connection timeout")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
cmd := makeCmd()
|
||||
if err := cmd.Run(); err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 255") {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
cmd/d2vm/run/sparsecat-linux-amd64
Executable file
BIN
cmd/d2vm/run/sparsecat-linux-amd64
Executable file
Binary file not shown.
@ -1,3 +1,5 @@
|
||||
//go:generate env GOOS=linux GOARCH=amd64 go build -o sparsecat-linux-amd64 github.com/svenwiltink/sparsecat/cmd/sparsecat
|
||||
|
||||
// Copyright 2022 Linka Cloud All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@ -16,12 +18,24 @@ package run
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
//go:embed sparsecat-linux-amd64
|
||||
var sparsecatBinary []byte
|
||||
|
||||
// Handle flags with multiple occurrences
|
||||
type MultipleFlag []string
|
||||
|
||||
@ -274,3 +288,75 @@ func NewPublishedPort(publish string) (PublishedPort, error) {
|
||||
p.Protocol = protocol
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func dialSSH(server, user, password string) (*ssh.Client, error) {
|
||||
c, err := ssh.Dial("tcp", server+":22", &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func dialSSHWithTimeout(server, user, password string, timeout time.Duration) (*ssh.Client, error) {
|
||||
t := time.NewTimer(timeout)
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
return nil, fmt.Errorf("timeout while trying to connect to the server")
|
||||
default:
|
||||
c, err := dialSSH(server, user, password)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newProgressReader(r io.Reader) *pw {
|
||||
return &pw{r: r}
|
||||
}
|
||||
|
||||
type pw struct {
|
||||
r io.Reader
|
||||
total int
|
||||
size int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (p *pw) Read(buf []byte) (int, error) {
|
||||
p.mu.Lock()
|
||||
p.total += len(buf)
|
||||
p.mu.Unlock()
|
||||
return p.r.Read(buf)
|
||||
}
|
||||
|
||||
func (p *pw) Progress() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.total
|
||||
}
|
||||
|
||||
type QemuInfo struct {
|
||||
VirtualSize int `json:"virtual-size"`
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format"`
|
||||
ActualSize int `json:"actual-size"`
|
||||
DirtyFlag bool `json:"dirty-flag"`
|
||||
}
|
||||
|
||||
func ImgInfo(ctx context.Context, path string) (*QemuInfo, error) {
|
||||
o, err := exec.CommandContext(ctx, "qemu-img", "info", path, "--output", "json").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %s", err, string(o))
|
||||
}
|
||||
var i QemuInfo
|
||||
if err := json.Unmarshal(o, &i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &i, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user