mirror of
https://github.com/apricote/hcloud-upload-image.git
synced 2026-01-13 21:31:03 +00:00
feat: documentation and cleanup command
This commit is contained in:
parent
27d4e3240e
commit
c9ab40b539
16 changed files with 394 additions and 148 deletions
|
|
@ -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
|
||||
}
|
||||
33
hcloudimages/client_test.go
Normal file
33
hcloudimages/client_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
42
hcloudimages/doc.go
Normal 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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
25
hcloudimages/internal/actionutil/action.go
Normal file
25
hcloudimages/internal/actionutil/action.go
Normal 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
|
||||
}
|
||||
30
hcloudimages/internal/labelutil/labels.go
Normal file
30
hcloudimages/internal/labelutil/labels.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue