feat: documentation and cleanup command

This commit is contained in:
Julian Tölle 2024-05-04 22:13:33 +02:00
parent 27d4e3240e
commit c9ab40b539
16 changed files with 394 additions and 148 deletions

View file

@ -2,14 +2,19 @@ package hcloudimages
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"golang.org/x/crypto/ssh"
"github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/actionutil"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/control"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/labelutil"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/randomid"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/sshkey"
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/sshsession"
@ -39,19 +44,69 @@ var (
defaultSSHDialTimeout = 1 * time.Minute
)
func NewClient(c *hcloud.Client) Client {
return &client{
type UploadOptions struct {
// ImageURL must be publicly available. The instance will download the image from this endpoint.
ImageURL *url.URL
// ImageCompression describes the compression of the referenced image file. It defaults to [CompressionNone]. If
// set to anything else, the file will be decompressed before written to the disk.
ImageCompression Compression
// Possible future additions:
// ImageSignatureVerification
// ImageLocalPath
// ImageType (RawDiskImage, ISO, qcow2, ...)
// Architecture should match the architecture of the Image. This decides if the Snapshot can later be
// used with [hcloud.ArchitectureX86] or [hcloud.ArchitectureARM] servers.
//
// Internally this decides what server type is used for the temporary server.
Architecture hcloud.Architecture
// Description is an optional description that the resulting image (snapshot) will have. There is no way to
// select images by its description, you should use Labels if you need to identify your image later.
Description *string
// Labels will be added to the resulting image (snapshot). Use these to filter the image list if you
// need to identify the image later on.
//
// We also always add a label `apricote.de/created-by=hcloud-image-upload` ([CreatedByLabel], [CreatedByValue]).
Labels map[string]string
// DebugSkipResourceCleanup will skip the cleanup of the temporary SSH Key and Server.
DebugSkipResourceCleanup bool
}
type Compression string
const (
CompressionNone Compression = ""
CompressionBZ2 Compression = "bz2"
// Possible future additions:
// zip,xz,zstd
)
// NewClient instantiates a new client. It requires a working [*hcloud.Client] to interact with the Hetzner Cloud API.
func NewClient(c *hcloud.Client) *Client {
return &Client{
c: c,
}
}
type client struct {
type Client struct {
c *hcloud.Client
}
func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Image, error) {
// Upload the specified image into a snapshot on Hetzner Cloud.
//
// As the Hetzner Cloud API has no direct way to upload images, we create a temporary server,
// overwrite the root disk and take a snapshot of that disk instead.
//
// The temporary server costs money. If the upload fails, we might be unable to delete the server. Check out
// CleanupTempResources for a helper in this case.
func (s *Client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Image, error) {
logger := contextlogger.From(ctx).With(
"library", "hcloud-upload-image",
"library", "hcloudimages",
"method", "upload",
)
@ -62,6 +117,7 @@ func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Imag
logger = logger.With("run-id", id)
// For simplicity, we use the name random name for SSH Key + Server
resourceName := resourcePrefix + id
labels := labelutil.Merge(DefaultLabels, options.Labels)
// 1. Create SSH Key
logger.InfoContext(ctx, "# Step 1: Generating SSH Key")
@ -73,7 +129,7 @@ func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Imag
key, _, err := s.c.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{
Name: resourceName,
PublicKey: string(publicKey),
Labels: fullLabels(options.Labels),
Labels: labels,
})
if err != nil {
return nil, fmt.Errorf("failed to submit temporary ssh key to API: %w", err)
@ -119,7 +175,7 @@ func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Imag
// Image will never be booted, we only boot into rescue system
Image: defaultImage,
Location: defaultLocation,
Labels: fullLabels(options.Labels),
Labels: labels,
})
if err != nil {
return nil, fmt.Errorf("creating the temporary server failed: %w", err)
@ -253,7 +309,7 @@ func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Imag
createImageResult, _, err := s.c.Server.CreateImage(ctx, server, &hcloud.ServerCreateImageOpts{
Type: hcloud.ImageTypeSnapshot,
Description: options.Description,
Labels: fullLabels(options.Labels),
Labels: labels,
})
if err != nil {
return nil, fmt.Errorf("failed to create snapshot: %w", err)
@ -273,13 +329,124 @@ func (s client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Imag
return image, nil
}
func fullLabels(userLabels map[string]string) map[string]string {
if userLabels == nil {
userLabels = make(map[string]string, len(DefaultLabels))
// CleanupTempResources tries to delete any resources that were left over from previous calls to [Client.Upload].
// Upload tries to clean up any temporary resources it created at runtime, but might fail at any point.
// You can then use this command to make sure that all temporary resources are removed from your project.
//
// This method tries to delete any server or ssh keys that match the [DefaultLabels]
func (s *Client) CleanupTempResources(ctx context.Context) error {
logger := contextlogger.From(ctx).With(
"library", "hcloudimages",
"method", "cleanup",
)
selector := labelutil.Selector(DefaultLabels)
logger = logger.With("selector", selector)
logger.InfoContext(ctx, "# Cleaning up Servers")
err := s.cleanupTempServers(ctx, logger, selector)
if err != nil {
return fmt.Errorf("failed to clean up all servers: %w", err)
}
for k, v := range DefaultLabels {
userLabels[k] = v
logger.DebugContext(ctx, "cleaned up all servers")
logger.InfoContext(ctx, "# Cleaning up SSH Keys")
err = s.cleanupTempSSHKeys(ctx, logger, selector)
if err != nil {
return fmt.Errorf("failed to clean up all ssh keys: %w", err)
}
logger.DebugContext(ctx, "cleaned up all ssh keys")
return nil
}
func (s *Client) cleanupTempServers(ctx context.Context, logger *slog.Logger, selector string) error {
servers, err := s.c.Server.AllWithOpts(ctx, hcloud.ServerListOpts{ListOpts: hcloud.ListOpts{
LabelSelector: selector,
}})
if err != nil {
return fmt.Errorf("failed to list servers: %w", err)
}
return userLabels
if len(servers) == 0 {
logger.InfoContext(ctx, "No servers found")
return nil
}
logger.InfoContext(ctx, "removing servers", "count", len(servers))
errs := []error{}
actions := make([]*hcloud.Action, 0, len(servers))
for _, server := range servers {
result, _, err := s.c.Server.DeleteWithResult(ctx, server)
if err != nil {
errs = append(errs, err)
logger.WarnContext(ctx, "failed to delete server", "server", server.ID, "error", err)
continue
}
actions = append(actions, result.Action)
}
successActions, errorActions, err := actionutil.Settle(ctx, &s.c.Action, actions...)
if err != nil {
return fmt.Errorf("failed to wait for server delete: %w", err)
}
if len(successActions) > 0 {
ids := make([]int64, 0, len(successActions))
for _, action := range successActions {
for _, resource := range action.Resources {
if resource.Type == hcloud.ActionResourceTypeServer {
ids = append(ids, resource.ID)
}
}
}
logger.InfoContext(ctx, "successfully deleted servers", "servers", ids)
}
if len(errorActions) > 0 {
for _, action := range errorActions {
errs = append(errs, action.Error())
}
}
if len(errs) > 0 {
// The returned message contains no info about the server IDs which failed
return fmt.Errorf("failed to delete some of the servers: %w", errors.Join(errs...))
}
return nil
}
func (s *Client) cleanupTempSSHKeys(ctx context.Context, logger *slog.Logger, selector string) error {
keys, _, err := s.c.SSHKey.List(ctx, hcloud.SSHKeyListOpts{ListOpts: hcloud.ListOpts{
LabelSelector: selector,
}})
if err != nil {
return fmt.Errorf("failed to list keys: %w", err)
}
if len(keys) == 0 {
logger.InfoContext(ctx, "No ssh keys found")
return nil
}
errs := []error{}
for _, key := range keys {
_, err := s.c.SSHKey.Delete(ctx, key)
if err != nil {
errs = append(errs, err)
logger.WarnContext(ctx, "failed to delete ssh key", "ssh-key", key.ID, "error", err)
continue
}
}
if len(errs) > 0 {
// The returned message contains no info about the server IDs which failed
return fmt.Errorf("failed to delete some of the ssh keys: %w", errors.Join(errs...))
}
return nil
}

View file

@ -0,0 +1,33 @@
package hcloudimages_test
import (
"context"
"fmt"
"net/url"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/apricote/hcloud-upload-image/hcloudimages"
)
func ExampleClient_Upload() {
client := hcloudimages.NewClient(
hcloud.NewClient(hcloud.WithToken("<your token>")),
)
imageURL, err := url.Parse("https://example.com/disk-image.raw.bz2")
if err != nil {
panic(err)
}
image, err := client.Upload(context.TODO(), hcloudimages.UploadOptions{
ImageURL: imageURL,
ImageCompression: hcloudimages.CompressionBZ2,
Architecture: hcloud.ArchitectureX86,
})
if err != nil {
panic(err)
}
fmt.Printf("Uploaded Image: %d", image.ID)
}

View file

@ -9,10 +9,13 @@ type key int
var loggerKey key
// New saves the logger as a value to the context. This can then be retrieved through [From].
func New(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
// From returns the [*slog.Logger] set on the context by [New]. If there is none,
// it returns a no-op logger that discards any output it receives.
func From(ctx context.Context) *slog.Logger {
if ctxLogger := ctx.Value(loggerKey); ctxLogger != nil {
if logger, ok := ctxLogger.(*slog.Logger); ok {

42
hcloudimages/doc.go Normal file
View file

@ -0,0 +1,42 @@
// Package hcloudimages is a library to upload Disk Images into your Hetzner Cloud project.
//
// # Overview
//
// The Hetzner Cloud API does not support uploading disk images directly, and it only provides a limited set of default
// images. The only option for custom disk images that users have is by taking a "snapshot" of an existing servers root
// disk. These can then be used to create new servers.
//
// To create a completely custom disk image, users have to follow these steps:
//
// 1. Create server with the correct server type
// 2. Enable rescue system for the server
// 3. Boot the server
// 4. Download the disk image from within the rescue system
// 5. Write disk image to servers root disk
// 6. Shut down the server
// 7. Take a snapshot of the servers root disk
// 8. Delete the server
//
// This is an annoyingly long process. Many users have automated this with Packer before, but Packer offers a lot of
// additional complexity to understand.
//
// This library is a single call to do the above: [Client.Upload]
//
// # Costs
//
// The temporary server and the snapshot itself cost money. See the [Hetzner Cloud website] for up-to-date pricing
// information.
//
// Usually the upload takes no more than a few minutes of server time, so you will only be billed for the first hour
// (<1ct for most cases). If this process fails, the server might stay around until you manually delete it. In that case
// it continues to cost its hourly price. There is a utility [Client.CleanupTemporaryResources] that removes any
// leftover resources.
//
// # Logging
//
// By default, nothing is logged. As the update process takes a bit of time you might want to gain some insight into
// the process. For this we provide optional logs through [log/slog]. You can set a [log/slog.Logger] in the
// [context.Context] through [github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger.New].
//
// [Hetzner Cloud website]: https://www.hetzner.com/cloud/
package hcloudimages

View file

@ -3,7 +3,7 @@ module github.com/apricote/hcloud-upload-image/hcloudimages
go 1.22.2
require (
github.com/hetznercloud/hcloud-go/v2 v2.7.3-0.20240430130644-7bb1a7b9ae5f
github.com/hetznercloud/hcloud-go/v2 v2.7.3-0.20240503164107-1e3fa7033d8a
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.22.0
)

View file

@ -6,8 +6,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hetznercloud/hcloud-go/v2 v2.7.3-0.20240430130644-7bb1a7b9ae5f h1:c1ahn6OKXkSqwOfCoqyFrjVh14BEC9rD3ok0dehbCno=
github.com/hetznercloud/hcloud-go/v2 v2.7.3-0.20240430130644-7bb1a7b9ae5f/go.mod h1:jvpP3qAWMIZ3WQwQLYa97ia6t98iPCgsJNwRts+Jnrk=
github.com/hetznercloud/hcloud-go/v2 v2.7.3-0.20240503164107-1e3fa7033d8a h1:4L8VwfLGtlSBNPnsLINAqOEDde+vXi3AvZpTVtv+vs0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

View file

@ -1,55 +0,0 @@
package hcloudimages
import (
"context"
"net/url"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
)
type Client interface {
// Upload the specified image into a snapshot on Hetzner Cloud.
//
// As the Hetzner Cloud API has no direct way to upload images, we create a temporary server,
// overwrite the root disk and take a snapshot of that disk instead.
Upload(ctx context.Context, options UploadOptions) (*hcloud.Image, error)
// Possible future additions:
// List(ctx context.Context) []*hcloud.Image
// Delete(ctx context.Context, image *hcloud.Image) error
// CleanupTempResources(ctx context.Context) error
}
type UploadOptions struct {
// ImageURL must be publicly available. The instance will download the image from this endpoint.
ImageURL *url.URL
ImageCompression Compression
// ImageSignatureVerification
// Architecture should match the architecture of the Image. This decides if the Snapshot can later be
// used with [hcloud.ArchitectureX86] or [hcloud.ArchitectureARM] servers.
//
// Internally this decides what server type is used for the temporary server.
Architecture hcloud.Architecture
// Description is an optional description that the resulting image (snapshot) will have. There is no way to
// select images by its description, you should use Labels if you need to identify your image later.
Description *string
// Labels will be added to the resulting image (snapshot). Use these to filter the image list if you
// need to identify the image later on.
//
// We also always add a label `apricote.de/created-by=hcloud-image-upload` ([CreatedByLabel], [CreatedByValue]).
Labels map[string]string
// DebugSkipResourceCleanup will skip the cleanup of the temporary SSH Key and Server.
DebugSkipResourceCleanup bool
}
type Compression string
const (
CompressionNone Compression = ""
CompressionBZ2 Compression = "bz2"
// zip,xz,zstd
)

View file

@ -0,0 +1,25 @@
package actionutil
import (
"context"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
)
func Settle(ctx context.Context, client hcloud.IActionClient, actions ...*hcloud.Action) (successActions []*hcloud.Action, errorActions []*hcloud.Action, err error) {
err = client.WaitForFunc(ctx, func(update *hcloud.Action) error {
switch update.Status {
case hcloud.ActionStatusSuccess:
successActions = append(successActions, update)
case hcloud.ActionStatusError:
errorActions = append(errorActions, update)
}
return nil
}, actions...)
if err != nil {
return nil, nil, err
}
return successActions, errorActions, nil
}

View file

@ -0,0 +1,30 @@
package labelutil
import "fmt"
func Merge(a, b map[string]string) map[string]string {
result := make(map[string]string, len(a)+len(b))
for k, v := range a {
result[k] = v
}
for k, v := range b {
result[k] = v
}
return result
}
func Selector(labels map[string]string) string {
selector := make([]byte, 0, 64)
separator := ""
for k, v := range labels {
selector = fmt.Appendf(selector, "%s%s=%s", separator, k, v)
// Do not print separator on first element
separator = ","
}
return string(selector)
}