d2vm/cmd/d2vm/run/hetzner.go

295 lines
7.9 KiB
Go

// Copyright 2022 Linka Cloud All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package run
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"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 (
hetznerTokenEnv = "HETZNER_TOKEN"
serverImg = "ubuntu-20.04"
vmBlockPath = "/dev/sda"
sparsecatPath = "/usr/local/bin/sparsecat"
)
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 [$"+hetznerTokenEnv+"]")
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()
c := hcloud.NewClient(hcloud.WithToken(GetStringValue(hetznerTokenEnv, 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
}
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)
}
}()
_, errs := c.Action.WatchProgress(ctx, sres.Action)
if err := <-errs; err != nil {
return 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(sparsecatPath)
if err != nil {
return err
}
if err := sftpc.Chmod(sparsecatPath, 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
}
serrs := make(chan error, 2)
go func() {
serrs <- func() error {
s, err := sc.NewSession()
if err != nil {
return err
}
defer s.Close()
logrus.Infof("installing cloud-guest-utils on rescue server")
if b, err := s.CombinedOutput("apt update && apt install -y cloud-guest-utils"); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
}
return nil
}()
}()
go func() {
serrs <- func() error {
wses, err := sc.NewSession()
if err != nil {
return err
}
defer wses.Close()
logrus.Infof("writing image to %s", vmBlockPath)
done := make(chan struct{})
defer close(done)
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.VirtualSize)*100), humanize.Bytes(uint64(b-last)))
last = b
case <-ctx.Done():
logrus.Warnf("context cancelled")
return
case <-done:
logrus.Infof("transfer finished")
return
}
}
}()
if b, err := wses.CombinedOutput(fmt.Sprintf("%s -r -disable-sparse-target -of %s", sparsecatPath, vmBlockPath)); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
}
return nil
}()
}()
for i := 0; i < 2; i++ {
select {
case err := <-serrs:
if err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
gses, err := sc.NewSession()
if err != nil {
return err
}
defer gses.Close()
logrus.Infof("resizing disk partition")
if b, err := gses.CombinedOutput(fmt.Sprintf("growpart %s 1", vmBlockPath)); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
}
eses, err := sc.NewSession()
if err != nil {
return err
}
defer eses.Close()
logrus.Infof("extending partition file system")
if b, err := eses.CombinedOutput(fmt.Sprintf("resize2fs %s1", vmBlockPath)); err != nil {
return fmt.Errorf("%v: %s", err, string(b))
}
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
}
remove = false
logrus.Infof("waiting for server to be ready")
t := time.NewTimer(time.Minute)
wait:
for {
select {
case <-t.C:
return fmt.Errorf("ssh connection timeout")
case <-ctx.Done():
return ctx.Err()
default:
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:22", sres.Server.PublicNet.IPv4.IP.String()))
if err == nil {
conn.Close()
break wait
}
time.Sleep(time.Second)
}
}
logrus.Infof("server ready")
args := []string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}
if hetznerSSHKeyPath != "" {
args = append(args, "-i", hetznerSSHKeyPath)
}
args = append(args, fmt.Sprintf("%s@%s", hetznerSSHUser, sres.Server.PublicNet.IPv4.IP.String()))
cmd := exec.CommandContext(ctx, "ssh", args...)
cmd.Stdin = stdin
cmd.Stderr = stderr
cmd.Stdout = stdout
if err := cmd.Run(); err != nil {
return err
}
return nil
}