├── docs ├── .gitignore ├── introduction.md ├── changelog.md ├── changelog-hcloudimages.md ├── book.toml ├── reference │ ├── go-library.md │ └── cli │ │ ├── hcloud-upload-image.md │ │ ├── hcloud-upload-image_cleanup.md │ │ └── hcloud-upload-image_upload.md ├── guides │ └── README.md └── SUMMARY.md ├── .gitignore ├── .github ├── release-please-manifest.json ├── actions │ └── setup-mdbook │ │ └── action.yaml ├── workflows │ ├── release-please.yaml │ ├── release.yaml │ ├── docs.yaml │ └── ci.yaml └── release-please-config.json ├── go.work ├── main.go ├── scripts ├── completions.sh └── cli-help-pages.go ├── hcloudimages ├── internal │ ├── sshsession │ │ └── session.go │ ├── randomid │ │ ├── randomid.go │ │ └── randomid_test.go │ ├── labelutil │ │ └── labels.go │ ├── actionutil │ │ └── action.go │ └── control │ │ └── retry.go ├── contextlogger │ ├── discard.go │ └── context.go ├── doc_test.go ├── backoff │ └── backoff.go ├── go.mod ├── doc.go ├── CHANGELOG.md ├── client_test.go ├── go.sum └── client.go ├── internal ├── version │ └── version.go └── ui │ └── slog_handler.go ├── cmd ├── upload.md ├── cleanup.go ├── root.go └── upload.go ├── renovate.json ├── LICENSE ├── go.mod ├── .goreleaser.yaml ├── README.md ├── go.sum ├── CHANGELOG.md └── go.work.sum /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | https: 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | completions/ 4 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | {{#include ../README.md:2:}} -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"1.2.0","hcloudimages":"1.2.0"} 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog CLI 2 | 3 | {{#include ../CHANGELOG.md:2: }} 4 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.24.0 2 | 3 | toolchain go1.25.4 4 | 5 | use ( 6 | . 7 | ./hcloudimages 8 | ) 9 | -------------------------------------------------------------------------------- /docs/changelog-hcloudimages.md: -------------------------------------------------------------------------------- 1 | # Changelog Library 2 | 3 | {{#include ../hcloudimages/CHANGELOG.md:2: }} 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/apricote/hcloud-upload-image/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /scripts/completions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rm -rf completions 4 | mkdir completions 5 | for sh in bash zsh fish; do 6 | go run . completion "$sh" >"completions/hcloud-upload-image.$sh" 7 | done 8 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | language = "en" 3 | multilingual = false 4 | src = "." 5 | title = "hcloud-upload-image" 6 | 7 | [output.html] 8 | git-repository-url = "https://github.com/apricote/hcloud-upload-image" 9 | -------------------------------------------------------------------------------- /docs/reference/go-library.md: -------------------------------------------------------------------------------- 1 | # Go Library 2 | 3 | You can find the documentation at [pkg.go.dev/github.com/apricote/hcloud-upload-image/hcloudimages ↗](https://pkg.go.dev/github.com/apricote/hcloud-upload-image/hcloudimages). 4 | -------------------------------------------------------------------------------- /hcloudimages/internal/sshsession/session.go: -------------------------------------------------------------------------------- 1 | package sshsession 2 | 3 | import ( 4 | "io" 5 | 6 | "golang.org/x/crypto/ssh" 7 | ) 8 | 9 | func Run(client *ssh.Client, cmd string, stdin io.Reader) ([]byte, error) { 10 | sess, err := client.NewSession() 11 | 12 | if err != nil { 13 | return nil, err 14 | } 15 | defer func() { _ = sess.Close() }() 16 | 17 | if stdin != nil { 18 | sess.Stdin = stdin 19 | } 20 | return sess.CombinedOutput(cmd) 21 | } 22 | -------------------------------------------------------------------------------- /docs/guides/README.md: -------------------------------------------------------------------------------- 1 | # Uploading Images 2 | 3 | Check out these docs from other projects to learn how to use `hcloud-upload-image`: 4 | 5 | - [Fedora CoreOS ↗](https://docs.fedoraproject.org/en-US/fedora-coreos/provisioning-hetzner/#_creating_a_snapshot) 6 | - [Flatcar Container Linux ↗](https://www.flatcar.org/docs/latest/installing/cloud/hetzner/#building-the-snapshots-1) 7 | - [Talos Linux ↗](https://www.talos.dev/v1.10/talos-guides/install/cloud-platforms/hetzner/#hcloud-upload-image) 8 | -------------------------------------------------------------------------------- /.github/actions/setup-mdbook/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Setup mdbook" 2 | inputs: 3 | version: 4 | description: "mdbook version" 5 | 6 | runs: 7 | using: composite 8 | steps: 9 | - name: Setup mdbook 10 | shell: bash 11 | env: 12 | url: https://github.com/rust-lang/mdbook/releases/download/${{ inputs.version }}/mdbook-${{ inputs.version }}-x86_64-unknown-linux-gnu.tar.gz 13 | run: | 14 | mkdir mdbook 15 | curl -sSL "$url" | tar -xz --directory=./mdbook 16 | echo `pwd`/mdbook >> $GITHUB_PATH 17 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yaml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release-please: 9 | # Do not run on forks. 10 | if: github.repository == 'apricote/hcloud-upload-image' 11 | 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: googleapis/release-please-action@v4 15 | with: 16 | token: ${{ secrets.RELEASE_GH_TOKEN }} 17 | config-file: .github/release-please-config.json 18 | manifest-file: .github/release-please-manifest.json 19 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | // version is a semver version (https://semver.org). 5 | version = "1.2.0" // x-release-please-version 6 | 7 | // versionPrerelease is a semver version pre-release identifier (https://semver.org). 8 | // 9 | // For final releases, we set this to an empty string. 10 | versionPrerelease = "dev" 11 | 12 | // Version of the hcloud-upload-image CLI. 13 | Version = func() string { 14 | if versionPrerelease != "" { 15 | return version + "-" + versionPrerelease 16 | } 17 | return version 18 | }() 19 | ) 20 | -------------------------------------------------------------------------------- /hcloudimages/internal/randomid/randomid.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // From https://gitlab.com/hetznercloud/fleeting-plugin-hetzner/-/blob/0f60204582289c243599f8ca0f5be4822789131d/internal/utils/random.go 3 | // Copyright (c) 2024 Hetzner Cloud GmbH 4 | 5 | package randomid 6 | 7 | import ( 8 | "crypto/rand" 9 | "encoding/hex" 10 | "fmt" 11 | ) 12 | 13 | func Generate() (string, error) { 14 | b := make([]byte, 4) 15 | _, err := rand.Read(b) 16 | if err != nil { 17 | return "", fmt.Errorf("failed to generate random string: %w", err) 18 | } 19 | return hex.EncodeToString(b), nil 20 | } 21 | -------------------------------------------------------------------------------- /cmd/upload.md: -------------------------------------------------------------------------------- 1 | This command implements a fake "upload", by going through a real server and 2 | snapshots. This does cost a bit of money for the server. 3 | 4 | #### Image Size 5 | 6 | The image size for raw disk images is only limited by the servers root disk. 7 | 8 | The image size for qcow2 images is limited to the rescue systems root disk. 9 | This is currently a memory-backed file system with **960 MB** of space. A qcow2 10 | image not be larger than this size, or the process will error. There is a 11 | warning being logged if hcloud-upload-image can detect that your file is larger 12 | than this size. 13 | -------------------------------------------------------------------------------- /docs/reference/cli/hcloud-upload-image.md: -------------------------------------------------------------------------------- 1 | ## hcloud-upload-image 2 | 3 | Manage custom OS images on Hetzner Cloud. 4 | 5 | ### Synopsis 6 | 7 | Manage custom OS images on Hetzner Cloud. 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for hcloud-upload-image 13 | -v, --verbose count verbose debug output, can be specified up to 2 times 14 | ``` 15 | 16 | ### SEE ALSO 17 | 18 | * [hcloud-upload-image cleanup](hcloud-upload-image_cleanup.md) - Remove any temporary resources that were left over 19 | * [hcloud-upload-image upload](hcloud-upload-image_upload.md) - Upload the specified disk image into your Hetzner Cloud project. 20 | 21 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "include-component-in-tag": false, 4 | "include-v-in-tag": true, 5 | "release-type": "go", 6 | "separate-pull-requests": true, 7 | "packages": { 8 | ".": { 9 | "component": "cli", 10 | "package-name": "hcloud-upload-image", 11 | "extra-files": ["internal/version/version.go"] 12 | }, 13 | "hcloudimages": { 14 | "component": "hcloudimages", 15 | "package-name": "hcloudimages", 16 | "include-component-in-tag": true, 17 | "tag-separator": "/" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /hcloudimages/internal/randomid/randomid_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // From https://gitlab.com/hetznercloud/fleeting-plugin-hetzner/-/blob/0f60204582289c243599f8ca0f5be4822789131d/internal/utils/random_test.go 3 | // Copyright (c) 2024 Hetzner Cloud GmbH 4 | 5 | package randomid 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestGenerateRandomID(t *testing.T) { 14 | found1, err := Generate() 15 | assert.NoError(t, err) 16 | found2, err := Generate() 17 | assert.NoError(t, err) 18 | 19 | assert.Len(t, found1, 8) 20 | assert.Len(t, found2, 8) 21 | assert.NotEqual(t, found1, found2) 22 | } 23 | -------------------------------------------------------------------------------- /hcloudimages/contextlogger/discard.go: -------------------------------------------------------------------------------- 1 | package contextlogger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | // discardHandler is a [slog.Handler] that just discards any input. It is a safe default if any library 9 | // method does not get passed a logger through the context. 10 | type discardHandler struct{} 11 | 12 | func (discardHandler) Enabled(_ context.Context, _ slog.Level) bool { return false } 13 | func (discardHandler) Handle(_ context.Context, _ slog.Record) error { return nil } 14 | func (d discardHandler) WithAttrs(_ []slog.Attr) slog.Handler { return d } 15 | func (d discardHandler) WithGroup(_ string) slog.Handler { return d } 16 | -------------------------------------------------------------------------------- /hcloudimages/internal/labelutil/labels.go: -------------------------------------------------------------------------------- 1 | package labelutil 2 | 3 | import "fmt" 4 | 5 | func Merge(a, b map[string]string) map[string]string { 6 | result := make(map[string]string, len(a)+len(b)) 7 | 8 | for k, v := range a { 9 | result[k] = v 10 | } 11 | for k, v := range b { 12 | result[k] = v 13 | } 14 | 15 | return result 16 | } 17 | 18 | func Selector(labels map[string]string) string { 19 | selector := make([]byte, 0, 64) 20 | separator := "" 21 | 22 | for k, v := range labels { 23 | selector = fmt.Appendf(selector, "%s%s=%s", separator, k, v) 24 | 25 | // Do not print separator on first element 26 | separator = "," 27 | } 28 | 29 | return string(selector) 30 | } 31 | -------------------------------------------------------------------------------- /hcloudimages/internal/actionutil/action.go: -------------------------------------------------------------------------------- 1 | package actionutil 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | func Settle(ctx context.Context, client hcloud.IActionClient, actions ...*hcloud.Action) (successActions []*hcloud.Action, errorActions []*hcloud.Action, err error) { 10 | err = client.WaitForFunc(ctx, func(update *hcloud.Action) error { 11 | switch update.Status { 12 | case hcloud.ActionStatusSuccess: 13 | successActions = append(successActions, update) 14 | case hcloud.ActionStatusError: 15 | errorActions = append(errorActions, update) 16 | } 17 | 18 | return nil 19 | }, actions...) 20 | if err != nil { 21 | return nil, nil, err 22 | } 23 | 24 | return successActions, errorActions, nil 25 | } 26 | -------------------------------------------------------------------------------- /hcloudimages/contextlogger/context.go: -------------------------------------------------------------------------------- 1 | package contextlogger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type key int 9 | 10 | var loggerKey key 11 | 12 | // New saves the logger as a value to the context. This can then be retrieved through [From]. 13 | func New(ctx context.Context, logger *slog.Logger) context.Context { 14 | return context.WithValue(ctx, loggerKey, logger) 15 | } 16 | 17 | // From returns the [*slog.Logger] set on the context by [New]. If there is none, 18 | // it returns a no-op logger that discards any output it receives. 19 | func From(ctx context.Context) *slog.Logger { 20 | if ctxLogger := ctx.Value(loggerKey); ctxLogger != nil { 21 | if logger, ok := ctxLogger.(*slog.Logger); ok { 22 | return logger 23 | } 24 | } 25 | 26 | return slog.New(discardHandler{}) 27 | } 28 | -------------------------------------------------------------------------------- /hcloudimages/doc_test.go: -------------------------------------------------------------------------------- 1 | package hcloudimages_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 9 | 10 | "github.com/apricote/hcloud-upload-image/hcloudimages" 11 | ) 12 | 13 | func ExampleClient_Upload() { 14 | client := hcloudimages.NewClient( 15 | hcloud.NewClient(hcloud.WithToken("")), 16 | ) 17 | 18 | imageURL, err := url.Parse("https://example.com/disk-image.raw.bz2") 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | image, err := client.Upload(context.TODO(), hcloudimages.UploadOptions{ 24 | ImageURL: imageURL, 25 | ImageCompression: hcloudimages.CompressionBZ2, 26 | Architecture: hcloud.ArchitectureX86, 27 | }) 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | fmt.Printf("Uploaded Image: %d", image.ID) 33 | } 34 | -------------------------------------------------------------------------------- /scripts/cli-help-pages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra/doc" 8 | 9 | "github.com/apricote/hcloud-upload-image/cmd" 10 | ) 11 | 12 | func run() error { 13 | // Define the directory where the docs will be generated 14 | dir := "docs/reference/cli" 15 | 16 | // Ensure the directory exists 17 | if err := os.MkdirAll(dir, 0755); err != nil { 18 | return fmt.Errorf("error creating docs directory: %v", err) 19 | } 20 | 21 | // Generate the docs 22 | if err := doc.GenMarkdownTree(cmd.RootCmd, dir); err != nil { 23 | return fmt.Errorf("error generating docs: %v", err) 24 | } 25 | 26 | fmt.Println("Docs generated successfully in", dir) 27 | return nil 28 | } 29 | 30 | func main() { 31 | if err := run(); err != nil { 32 | fmt.Printf("Error: %v\n", err) 33 | os.Exit(1) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](introduction.md) 4 | 5 | # Guides 6 | 7 | - [Uploading Images](guides/README.md) 8 | - [Fedora CoreOS ↗](https://docs.fedoraproject.org/en-US/fedora-coreos/provisioning-hetzner/#_creating_a_snapshot) 9 | - [Flatcar Container Linux ↗](https://www.flatcar.org/docs/latest/installing/cloud/hetzner/#building-the-snapshots-1) 10 | - [Talos Linux ↗](https://www.talos.dev/v1.10/talos-guides/install/cloud-platforms/hetzner/#hcloud-upload-image) 11 | 12 | # Reference 13 | 14 | - [CLI](reference/cli/hcloud-upload-image.md) 15 | - [`upload`](reference/cli/hcloud-upload-image_upload.md) 16 | - [`cleanup`](reference/cli/hcloud-upload-image_cleanup.md) 17 | - [Go Library](reference/go-library.md) 18 | 19 | --- 20 | 21 | [Changelog CLI](changelog.md) 22 | [Changelog Go Library](changelog-hcloudimages.md) 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticCommits", 6 | ":semanticCommitTypeAll(chore)", 7 | ":semanticCommitScope(deps)", 8 | ":enableVulnerabilityAlerts" 9 | ], 10 | "postUpdateOptions": [ 11 | "gomodTidy", 12 | "gomodUpdateImportPaths" 13 | ], 14 | "goGetDirs": ["./...", "./hcloudimages/..."], 15 | "customManagers": [ 16 | { 17 | "customType": "regex", 18 | "fileMatch": [ 19 | "^\\.github\\/(?:workflows|actions)\\/.+\\.ya?ml$" 20 | ], 21 | "matchStrings": [ 22 | "(?:version|VERSION): (?.+) # renovate: datasource=(?[a-z-]+) depName=(?.+)(?: packageName=(?.+))?(?: versioning=(?[a-z-]+))?" 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v5 18 | 19 | - name: Log in to the Container registry 20 | uses: docker/login-action@28fdb31ff34708d19615a74d67103ddc2ea9725c 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v6 28 | with: 29 | go-version-file: go.mod 30 | 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v6 33 | with: 34 | version: "~> v2" 35 | args: release --clean 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.RELEASE_GH_TOKEN }} 38 | AUR_SSH_KEY: ${{ secrets.RELEASE_AUR_SSH_KEY }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Julian Tölle 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /hcloudimages/backoff/backoff.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | // From https://github.com/hetznercloud/terraform-provider-hcloud/blob/v1.46.1/internal/control/retry.go 3 | // Copyright (c) Hetzner Cloud GmbH 4 | 5 | package backoff 6 | 7 | import ( 8 | "math" 9 | "time" 10 | 11 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 12 | ) 13 | 14 | // ExponentialBackoffWithLimit returns a [hcloud.BackoffFunc] which implements an exponential 15 | // backoff. 16 | // It uses the formula: 17 | // 18 | // min(b^retries * d, limit) 19 | // 20 | // This function has a known overflow issue and should not be used anymore. 21 | // 22 | // Deprecated: Use BackoffFuncWithOpts from github.com/hetznercloud/hcloud-go/v2/hcloud instead. 23 | func ExponentialBackoffWithLimit(b float64, d time.Duration, limit time.Duration) hcloud.BackoffFunc { 24 | return func(retries int) time.Duration { 25 | current := time.Duration(math.Pow(b, float64(retries))) * d 26 | 27 | if current > limit { 28 | return limit 29 | } else { 30 | return current 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /hcloudimages/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/apricote/hcloud-upload-image/hcloudimages 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.25.4 6 | 7 | require ( 8 | github.com/hetznercloud/hcloud-go/v2 v2.29.0 9 | github.com/stretchr/testify v1.11.1 10 | golang.org/x/crypto v0.43.0 11 | ) 12 | 13 | require ( 14 | github.com/beorn7/perks v1.0.1 // indirect 15 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/prometheus/client_golang v1.23.2 // indirect 20 | github.com/prometheus/client_model v0.6.2 // indirect 21 | github.com/prometheus/common v0.66.1 // indirect 22 | github.com/prometheus/procfs v0.16.1 // indirect 23 | go.yaml.in/yaml/v2 v2.4.2 // indirect 24 | golang.org/x/net v0.46.0 // indirect 25 | golang.org/x/sys v0.37.0 // indirect 26 | golang.org/x/text v0.30.0 // indirect 27 | google.golang.org/protobuf v1.36.8 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /hcloudimages/internal/control/retry.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | // From https://github.com/hetznercloud/terraform-provider-hcloud/blob/v1.46.1/internal/control/retry.go 3 | // Copyright (c) Hetzner Cloud GmbH 4 | 5 | package control 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 12 | 13 | "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger" 14 | ) 15 | 16 | // Retry executes f at most maxTries times. 17 | func Retry(ctx context.Context, maxTries int, f func() error) error { 18 | logger := contextlogger.From(ctx) 19 | 20 | var err error 21 | 22 | backoffFunc := hcloud.ExponentialBackoffWithOpts(hcloud.ExponentialBackoffOpts{Multiplier: 2, Base: 200 * time.Millisecond, Cap: 2 * time.Second}) 23 | 24 | for try := 0; try < maxTries; try++ { 25 | if ctx.Err() != nil { 26 | return ctx.Err() 27 | } 28 | 29 | err = f() 30 | if err != nil { 31 | sleep := backoffFunc(try) 32 | logger.DebugContext(ctx, "operation failed, waiting before trying again", "try", try, "backoff", sleep) 33 | time.Sleep(sleep) 34 | continue 35 | } 36 | 37 | return nil 38 | } 39 | 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /docs/reference/cli/hcloud-upload-image_cleanup.md: -------------------------------------------------------------------------------- 1 | ## hcloud-upload-image cleanup 2 | 3 | Remove any temporary resources that were left over 4 | 5 | ### Synopsis 6 | 7 | If the upload fails at any point, there might still exist a server or 8 | ssh key in your Hetzner Cloud project. This command cleans up any resources 9 | that match the label "apricote.de/created-by=hcloud-upload-image". 10 | 11 | If you want to see a preview of what would be removed, you can use the official hcloud CLI and run: 12 | 13 | $ hcloud server list -l apricote.de/created-by=hcloud-upload-image 14 | $ hcloud ssh-key list -l apricote.de/created-by=hcloud-upload-image 15 | 16 | This command does not handle any parallel executions of hcloud-upload-image 17 | and will remove in-use resources if called at the same time. 18 | 19 | ``` 20 | hcloud-upload-image cleanup [flags] 21 | ``` 22 | 23 | ### Options 24 | 25 | ``` 26 | -h, --help help for cleanup 27 | ``` 28 | 29 | ### Options inherited from parent commands 30 | 31 | ``` 32 | -v, --verbose count verbose debug output, can be specified up to 2 times 33 | ``` 34 | 35 | ### SEE ALSO 36 | 37 | * [hcloud-upload-image](hcloud-upload-image.md) - Manage custom OS images on Hetzner Cloud. 38 | 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/apricote/hcloud-upload-image 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.25.4 6 | 7 | require ( 8 | github.com/apricote/hcloud-upload-image/hcloudimages v1.2.0 9 | github.com/hetznercloud/hcloud-go/v2 v2.29.0 10 | github.com/spf13/cobra v1.10.1 11 | ) 12 | 13 | require ( 14 | github.com/beorn7/perks v1.0.1 // indirect 15 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 16 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 19 | github.com/prometheus/client_golang v1.23.2 // indirect 20 | github.com/prometheus/client_model v0.6.2 // indirect 21 | github.com/prometheus/common v0.66.1 // indirect 22 | github.com/prometheus/procfs v0.16.1 // indirect 23 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 24 | github.com/spf13/pflag v1.0.9 // indirect 25 | go.yaml.in/yaml/v2 v2.4.2 // indirect 26 | golang.org/x/crypto v0.43.0 // indirect 27 | golang.org/x/net v0.46.0 // indirect 28 | golang.org/x/sys v0.37.0 // indirect 29 | golang.org/x/text v0.30.0 // indirect 30 | google.golang.org/protobuf v1.36.8 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write # To push a branch 12 | pages: write # To push to a GitHub Pages site 13 | id-token: write # To update the deployment status 14 | 15 | steps: 16 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 17 | with: 18 | lfs: "true" 19 | 20 | - uses: ./.github/actions/setup-mdbook 21 | with: 22 | version: v0.4.52 # renovate: datasource=github-releases depName=rust-lang/mdbook 23 | 24 | - name: Build Book 25 | working-directory: docs 26 | run: mdbook build 27 | 28 | - name: Setup Pages 29 | uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 30 | 31 | - name: Upload artifact 32 | uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 33 | with: 34 | # Upload entire repository 35 | path: "docs/book" 36 | 37 | - name: Deploy to GitHub Pages 38 | id: deployment 39 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 40 | -------------------------------------------------------------------------------- /cmd/cleanup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger" 9 | ) 10 | 11 | // cleanupCmd represents the cleanup command 12 | var cleanupCmd = &cobra.Command{ 13 | Use: "cleanup", 14 | Short: "Remove any temporary resources that were left over", 15 | Long: `If the upload fails at any point, there might still exist a server or 16 | ssh key in your Hetzner Cloud project. This command cleans up any resources 17 | that match the label "apricote.de/created-by=hcloud-upload-image". 18 | 19 | If you want to see a preview of what would be removed, you can use the official hcloud CLI and run: 20 | 21 | $ hcloud server list -l apricote.de/created-by=hcloud-upload-image 22 | $ hcloud ssh-key list -l apricote.de/created-by=hcloud-upload-image 23 | 24 | This command does not handle any parallel executions of hcloud-upload-image 25 | and will remove in-use resources if called at the same time.`, 26 | DisableAutoGenTag: true, 27 | 28 | GroupID: "primary", 29 | 30 | PreRun: initClient, 31 | 32 | RunE: func(cmd *cobra.Command, args []string) error { 33 | ctx := cmd.Context() 34 | logger := contextlogger.From(ctx) 35 | 36 | err := client.CleanupTempResources(ctx) 37 | if err != nil { 38 | return fmt.Errorf("failed to clean up temporary resources: %w", err) 39 | } 40 | 41 | logger.InfoContext(ctx, "Successfully cleaned up all temporary resources!") 42 | 43 | return nil 44 | }, 45 | } 46 | 47 | func init() { 48 | RootCmd.AddCommand(cleanupCmd) 49 | } 50 | -------------------------------------------------------------------------------- /hcloudimages/doc.go: -------------------------------------------------------------------------------- 1 | // Package hcloudimages is a library to upload Disk Images into your Hetzner Cloud project. 2 | // 3 | // # Overview 4 | // 5 | // The Hetzner Cloud API does not support uploading disk images directly, and it only provides a limited set of default 6 | // images. The only option for custom disk images that users have is by taking a "snapshot" of an existing servers root 7 | // disk. These can then be used to create new servers. 8 | // 9 | // To create a completely custom disk image, users have to follow these steps: 10 | // 11 | // 1. Create server with the correct server type 12 | // 2. Enable rescue system for the server 13 | // 3. Boot the server 14 | // 4. Download the disk image from within the rescue system 15 | // 5. Write disk image to servers root disk 16 | // 6. Shut down the server 17 | // 7. Take a snapshot of the servers root disk 18 | // 8. Delete the server 19 | // 20 | // This is an annoyingly long process. Many users have automated this with Packer before, but Packer offers a lot of 21 | // additional complexity to understand. 22 | // 23 | // This library is a single call to do the above: [Client.Upload] 24 | // 25 | // # Costs 26 | // 27 | // The temporary server and the snapshot itself cost money. See the [Hetzner Cloud website] for up-to-date pricing 28 | // information. 29 | // 30 | // Usually the upload takes no more than a few minutes of server time, so you will only be billed for the first hour 31 | // (<1ct for most cases). If this process fails, the server might stay around until you manually delete it. In that case 32 | // it continues to cost its hourly price. There is a utility [Client.CleanupTemporaryResources] that removes any 33 | // leftover resources. 34 | // 35 | // # Logging 36 | // 37 | // By default, nothing is logged. As the update process takes a bit of time you might want to gain some insight into 38 | // the process. For this we provide optional logs through [log/slog]. You can set a [log/slog.Logger] in the 39 | // [context.Context] through [github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger.New]. 40 | // 41 | // [Hetzner Cloud website]: https://www.hetzner.com/cloud/ 42 | package hcloudimages 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v5 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v6 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Run golangci-lint (CLI) 21 | uses: golangci/golangci-lint-action@v8 22 | with: 23 | version: v2.6.1 # renovate: datasource=github-releases depName=golangci/golangci-lint 24 | args: --timeout 5m 25 | 26 | - name: Run golangci-lint (Lib) 27 | uses: golangci/golangci-lint-action@v8 28 | with: 29 | version: v2.6.1 # renovate: datasource=github-releases depName=golangci/golangci-lint 30 | args: --timeout 5m 31 | working-directory: hcloudimages 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v5 38 | 39 | - name: Set up Go 40 | uses: actions/setup-go@v6 41 | with: 42 | go-version-file: go.mod 43 | 44 | - name: Run tests 45 | run: go test -v -race -coverpkg=./...,./hcloudimages/... ./... ./hcloudimages/... 46 | 47 | go-mod-tidy: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v5 52 | 53 | - name: Set up Go 54 | uses: actions/setup-go@v6 55 | with: 56 | go-version-file: go.mod 57 | 58 | - name: Run go mod tidy 59 | run: go mod tidy 60 | 61 | - name: Check uncommitted changes 62 | run: git diff --exit-code 63 | 64 | - if: failure() 65 | run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes." 66 | 67 | cli-help-pages: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v5 72 | 73 | - name: Set up Go 74 | uses: actions/setup-go@v6 75 | with: 76 | go-version-file: go.mod 77 | 78 | - name: Generate CLI help pages 79 | run: go run ./scripts/cli-help-pages.go 80 | 81 | - name: Check uncommitted changes 82 | run: git diff --exit-code 83 | 84 | - if: failure() 85 | run: echo "::error::Check failed, please run 'go run ./scripts/cli-help-pages.go' and commit the changes." 86 | -------------------------------------------------------------------------------- /docs/reference/cli/hcloud-upload-image_upload.md: -------------------------------------------------------------------------------- 1 | ## hcloud-upload-image upload 2 | 3 | Upload the specified disk image into your Hetzner Cloud project. 4 | 5 | ### Synopsis 6 | 7 | This command implements a fake "upload", by going through a real server and 8 | snapshots. This does cost a bit of money for the server. 9 | 10 | #### Image Size 11 | 12 | The image size for raw disk images is only limited by the servers root disk. 13 | 14 | The image size for qcow2 images is limited to the rescue systems root disk. 15 | This is currently a memory-backed file system with **960 MB** of space. A qcow2 16 | image not be larger than this size, or the process will error. There is a 17 | warning being logged if hcloud-upload-image can detect that your file is larger 18 | than this size. 19 | 20 | 21 | ``` 22 | hcloud-upload-image upload (--image-path= | --image-url=) --architecture= [flags] 23 | ``` 24 | 25 | ### Examples 26 | 27 | ``` 28 | hcloud-upload-image upload --image-path /home/you/images/custom-linux-image-x86.bz2 --architecture x86 --compression bz2 --description "My super duper custom linux" 29 | hcloud-upload-image upload --image-url https://examples.com/image-arm.raw --architecture arm --labels foo=bar,version=latest 30 | hcloud-upload-image upload --image-url https://examples.com/image-x86.qcow2 --architecture x86 --format qcow2 31 | ``` 32 | 33 | ### Options 34 | 35 | ``` 36 | --architecture string CPU architecture of the disk image [choices: x86, arm] 37 | --compression string Type of compression that was used on the disk image [choices: bz2, xz, zstd] 38 | --description string Description for the resulting image 39 | --format string Format of the image. [default: raw, choices: qcow2] 40 | -h, --help help for upload 41 | --image-path string Local path to the disk image that should be uploaded 42 | --image-url string Remote URL of the disk image that should be uploaded 43 | --labels stringToString Labels for the resulting image (default []) 44 | --server-type string Explicitly use this server type to generate the image. Mutually exclusive with --architecture. 45 | ``` 46 | 47 | ### Options inherited from parent commands 48 | 49 | ``` 50 | -v, --verbose count verbose debug output, can be specified up to 2 times 51 | ``` 52 | 53 | ### SEE ALSO 54 | 55 | * [hcloud-upload-image](hcloud-upload-image.md) - Manage custom OS images on Hetzner Cloud. 56 | 57 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "time" 7 | 8 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/apricote/hcloud-upload-image/hcloudimages" 12 | "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger" 13 | "github.com/apricote/hcloud-upload-image/internal/ui" 14 | "github.com/apricote/hcloud-upload-image/internal/version" 15 | ) 16 | 17 | const ( 18 | flagVerbose = "verbose" 19 | ) 20 | 21 | var ( 22 | // 1 activates slog debug output 23 | // 2 activates hcloud-go debug output 24 | verbose int 25 | ) 26 | 27 | // The pre-authenticated client. Set in the root command PersistentPreRun 28 | var client *hcloudimages.Client 29 | 30 | // RootCmd represents the base command when called without any subcommands 31 | var RootCmd = &cobra.Command{ 32 | Use: "hcloud-upload-image", 33 | Short: `Manage custom OS images on Hetzner Cloud.`, 34 | Long: `Manage custom OS images on Hetzner Cloud.`, 35 | SilenceUsage: true, 36 | DisableAutoGenTag: true, 37 | 38 | Version: version.Version, 39 | 40 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 41 | ctx := cmd.Context() 42 | 43 | slog.SetDefault(initLogger()) 44 | 45 | // Add logger to command context 46 | logger := slog.Default() 47 | ctx = contextlogger.New(ctx, logger) 48 | cmd.SetContext(ctx) 49 | }, 50 | } 51 | 52 | func initLogger() *slog.Logger { 53 | logLevel := slog.LevelInfo 54 | if verbose >= 1 { 55 | logLevel = slog.LevelDebug 56 | } 57 | 58 | return slog.New(ui.NewHandler(os.Stdout, &ui.HandlerOptions{ 59 | Level: logLevel, 60 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 61 | // Remove attributes that are unnecessary for the cli context 62 | if a.Key == "library" || a.Key == "method" { 63 | return slog.Attr{} 64 | } 65 | 66 | return a 67 | }, 68 | })) 69 | 70 | } 71 | 72 | func initClient(cmd *cobra.Command, _ []string) { 73 | if client != nil { 74 | // Only init if not set. 75 | // Theoretically this is not safe against data races and should use [sync.Once], but :shrug: 76 | return 77 | } 78 | 79 | ctx := cmd.Context() 80 | 81 | logger := contextlogger.From(ctx) 82 | // Build hcloud-go client 83 | if os.Getenv("HCLOUD_TOKEN") == "" { 84 | logger.ErrorContext(ctx, "You need to set the HCLOUD_TOKEN environment variable to your Hetzner Cloud API Token.") 85 | os.Exit(1) 86 | } 87 | 88 | opts := []hcloud.ClientOption{ 89 | hcloud.WithToken(os.Getenv("HCLOUD_TOKEN")), 90 | hcloud.WithApplication("hcloud-upload-image", version.Version), 91 | hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: hcloud.ExponentialBackoffWithOpts(hcloud.ExponentialBackoffOpts{Multiplier: 2, Base: 1 * time.Second, Cap: 30 * time.Second})}), 92 | } 93 | 94 | if os.Getenv("HCLOUD_DEBUG") != "" || verbose >= 2 { 95 | opts = append(opts, hcloud.WithDebugWriter(os.Stderr)) 96 | } 97 | 98 | client = hcloudimages.NewClient(hcloud.NewClient(opts...)) 99 | } 100 | 101 | func Execute() { 102 | err := RootCmd.Execute() 103 | if err != nil { 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | func init() { 109 | RootCmd.SetErrPrefix("\033[1;31mError:") 110 | 111 | RootCmd.PersistentFlags().CountVarP(&verbose, flagVerbose, "v", "verbose debug output, can be specified up to 2 times") 112 | 113 | RootCmd.AddGroup(&cobra.Group{ 114 | ID: "primary", 115 | Title: "Primary Commands:", 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /hcloudimages/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.1.0...hcloudimages/v1.2.0) (2025-11-06) 4 | 5 | 6 | ### Features 7 | 8 | * change minimum required Go version to 1.24 ([#130](https://github.com/apricote/hcloud-upload-image/issues/130)) ([5eba2d5](https://github.com/apricote/hcloud-upload-image/commit/5eba2d52fe3aafb4fd0d93403548f4c32bc2b5ac)) 9 | * support zstd compression ([#125](https://github.com/apricote/hcloud-upload-image/issues/125)) ([37ebbce](https://github.com/apricote/hcloud-upload-image/commit/37ebbce5179997ac216af274055fc34c777b01e6)), closes [#122](https://github.com/apricote/hcloud-upload-image/issues/122) 10 | * update default x86 server type to cx23 ([#129](https://github.com/apricote/hcloud-upload-image/issues/129)) ([a205619](https://github.com/apricote/hcloud-upload-image/commit/a20561944d0ba9485a6e10e99df15c56a688541d)) 11 | 12 | ## [1.1.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.0.1...hcloudimages/v1.1.0) (2025-05-10) 13 | 14 | 15 | ### Features 16 | 17 | * smaller snapshots by zeroing disk first ([#101](https://github.com/apricote/hcloud-upload-image/issues/101)) ([fdfb284](https://github.com/apricote/hcloud-upload-image/commit/fdfb284533d3154806b0936c08015fd5cc64b0fb)), closes [#96](https://github.com/apricote/hcloud-upload-image/issues/96) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * upload from local image generates broken command ([#98](https://github.com/apricote/hcloud-upload-image/issues/98)) ([420dcf9](https://github.com/apricote/hcloud-upload-image/commit/420dcf94c965ee470602db6c9c23c777fda91222)), closes [#97](https://github.com/apricote/hcloud-upload-image/issues/97) 23 | 24 | ## [1.0.1](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.0.0...hcloudimages/v1.0.1) (2025-05-09) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * timeout while waiting for SSH to become available ([#92](https://github.com/apricote/hcloud-upload-image/issues/92)) ([e490b9a](https://github.com/apricote/hcloud-upload-image/commit/e490b9a7f394e268fa1946ca51aa998c78c3d46a)) 30 | 31 | ## [1.0.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.3.1...hcloudimages/v1.0.0) (2025-05-04) 32 | 33 | 34 | ### Features 35 | 36 | * upload qcow2 images ([#69](https://github.com/apricote/hcloud-upload-image/issues/69)) ([ac3e9dd](https://github.com/apricote/hcloud-upload-image/commit/ac3e9dd7ecd86d1538b6401c3073c7c078c40847)) 37 | 38 | ## [0.3.1](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.3.0...hcloudimages/v0.3.1) (2024-12-07) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **cli:** local install fails because of go.mod replace ([#47](https://github.com/apricote/hcloud-upload-image/issues/47)) ([66dc5f7](https://github.com/apricote/hcloud-upload-image/commit/66dc5f70b604ed3ee964576d74f94bdcea710c95)) 44 | 45 | ## [0.3.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.2.0...hcloudimages/v0.3.0) (2024-06-23) 46 | 47 | 48 | ### Features 49 | 50 | * set server type explicitly ([#36](https://github.com/apricote/hcloud-upload-image/issues/36)) ([42eeb00](https://github.com/apricote/hcloud-upload-image/commit/42eeb00a0784e13a00a52cf15a8659b497d78d72)), closes [#30](https://github.com/apricote/hcloud-upload-image/issues/30) 51 | * update default x86 server type to cx22 ([#38](https://github.com/apricote/hcloud-upload-image/issues/38)) ([ebe08b3](https://github.com/apricote/hcloud-upload-image/commit/ebe08b345c8f31df73087b091fa39f5fdc195156)) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * error early when the image write fails ([#34](https://github.com/apricote/hcloud-upload-image/issues/34)) ([256989f](https://github.com/apricote/hcloud-upload-image/commit/256989f4a37e7b124c0684aab0f34cf5e09559be)), closes [#33](https://github.com/apricote/hcloud-upload-image/issues/33) 57 | -------------------------------------------------------------------------------- /hcloudimages/client_test.go: -------------------------------------------------------------------------------- 1 | package hcloudimages 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func mustParseURL(s string) *url.URL { 9 | u, err := url.Parse(s) 10 | if err != nil { 11 | panic(err) 12 | } 13 | 14 | return u 15 | } 16 | 17 | func TestAssembleCommand(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | options UploadOptions 21 | want string 22 | wantErr bool 23 | }{ 24 | { 25 | name: "local raw", 26 | options: UploadOptions{}, 27 | want: "bash -c 'set -euo pipefail && dd of=/dev/sda bs=4M && sync'", 28 | }, 29 | { 30 | name: "remote raw", 31 | options: UploadOptions{ 32 | ImageURL: mustParseURL("https://example.com/image.xz"), 33 | }, 34 | want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.xz\" | dd of=/dev/sda bs=4M && sync'", 35 | }, 36 | { 37 | name: "local xz", 38 | options: UploadOptions{ 39 | ImageCompression: CompressionXZ, 40 | }, 41 | want: "bash -c 'set -euo pipefail && xz -cd | dd of=/dev/sda bs=4M && sync'", 42 | }, 43 | { 44 | name: "remote xz", 45 | options: UploadOptions{ 46 | ImageURL: mustParseURL("https://example.com/image.xz"), 47 | ImageCompression: CompressionXZ, 48 | }, 49 | want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.xz\" | xz -cd | dd of=/dev/sda bs=4M && sync'", 50 | }, 51 | { 52 | name: "local zstd", 53 | options: UploadOptions{ 54 | ImageCompression: CompressionZSTD, 55 | }, 56 | want: "bash -c 'set -euo pipefail && zstd -cd | dd of=/dev/sda bs=4M && sync'", 57 | }, 58 | { 59 | name: "remote zstd", 60 | options: UploadOptions{ 61 | ImageURL: mustParseURL("https://example.com/image.zst"), 62 | ImageCompression: CompressionZSTD, 63 | }, 64 | want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.zst\" | zstd -cd | dd of=/dev/sda bs=4M && sync'", 65 | }, 66 | { 67 | name: "local bz2", 68 | options: UploadOptions{ 69 | ImageCompression: CompressionBZ2, 70 | }, 71 | want: "bash -c 'set -euo pipefail && bzip2 -cd | dd of=/dev/sda bs=4M && sync'", 72 | }, 73 | { 74 | name: "remote bz2", 75 | options: UploadOptions{ 76 | ImageURL: mustParseURL("https://example.com/image.bz2"), 77 | ImageCompression: CompressionBZ2, 78 | }, 79 | want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.bz2\" | bzip2 -cd | dd of=/dev/sda bs=4M && sync'", 80 | }, 81 | { 82 | name: "local qcow2", 83 | options: UploadOptions{ 84 | ImageFormat: FormatQCOW2, 85 | }, 86 | want: "bash -c 'set -euo pipefail && tee image.qcow2 > /dev/null && qemu-img dd -f qcow2 -O raw if=image.qcow2 of=/dev/sda bs=4M && sync'", 87 | }, 88 | { 89 | name: "remote qcow2", 90 | options: UploadOptions{ 91 | ImageURL: mustParseURL("https://example.com/image.qcow2"), 92 | ImageFormat: FormatQCOW2, 93 | }, 94 | want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.qcow2\" | tee image.qcow2 > /dev/null && qemu-img dd -f qcow2 -O raw if=image.qcow2 of=/dev/sda bs=4M && sync'", 95 | }, 96 | 97 | { 98 | name: "unknown compression", 99 | options: UploadOptions{ 100 | ImageCompression: "noodle", 101 | }, 102 | wantErr: true, 103 | }, 104 | 105 | { 106 | name: "unknown format", 107 | options: UploadOptions{ 108 | ImageFormat: "poodle", 109 | }, 110 | wantErr: true, 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | got, err := assembleCommand(tt.options) 116 | if (err != nil) != tt.wantErr { 117 | t.Errorf("assembleCommand() error = %v, wantErr %v", err, tt.wantErr) 118 | return 119 | } 120 | if got != tt.want { 121 | t.Errorf("assembleCommand() got = %v, want %v", got, tt.want) 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - ./scripts/completions.sh 8 | 9 | builds: 10 | - id: default 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | mod_timestamp: "{{ .CommitTimestamp }}" 18 | flags: 19 | - -trimpath 20 | ldflags: 21 | - -X {{ .ModulePath }}/internal/version.version={{ .Version }} 22 | - -X {{ .ModulePath }}/internal/version.versionPrerelease= 23 | 24 | archives: 25 | - formats: [ "tar.gz" ] 26 | # this name template makes the OS and Arch compatible with the results of `uname`. 27 | name_template: >- 28 | {{ .ProjectName }}_ 29 | {{- title .Os }}_ 30 | {{- if eq .Arch "amd64" }}x86_64 31 | {{- else if eq .Arch "386" }}i386 32 | {{- else }}{{ .Arch }}{{ end }} 33 | {{- if .Arm }}v{{ .Arm }}{{ end }} 34 | # use zip for windows archives 35 | format_overrides: 36 | - goos: windows 37 | formats: [ "zip" ] 38 | 39 | files: 40 | - README.md 41 | - LICENSE 42 | - completions/* 43 | 44 | nfpms: 45 | - id: default 46 | file_name_template: "{{ .ConventionalFileName }}" 47 | package_name: hcloud-upload-image 48 | vendor: Julian Tölle 49 | homepage: https://github.com/apricote/hcloud-upload-image 50 | maintainer: Julian Tölle 51 | formats: 52 | - deb 53 | - rpm 54 | - apk 55 | description: Manage custom OS images on Hetzner Cloud. 56 | license: MIT 57 | dependencies: 58 | - openssh 59 | recommends: 60 | - hcloud-cli 61 | 62 | contents: 63 | - src: ./completions/hcloud-upload-image.bash 64 | dst: /usr/share/bash-completion/completions/hcloud-upload-image 65 | file_info: 66 | mode: 0644 67 | - src: ./completions/hcloud-upload-image.fish 68 | dst: /usr/share/fish/vendor_completions.d/hcloud-upload-image.fish 69 | file_info: 70 | mode: 0644 71 | - src: ./completions/hcloud-upload-image.zsh 72 | dst: /usr/share/zsh/vendor-completions/_hcloud-upload-image 73 | file_info: 74 | mode: 0644 75 | - src: ./LICENSE 76 | dst: /usr/share/doc/hcloud-upload-image/license 77 | file_info: 78 | mode: 0644 79 | 80 | 81 | aurs: 82 | - name: hcloud-upload-image-bin 83 | homepage: "https://github.com/apricote/hcloud-upload-image" 84 | description: Manage custom OS images on Hetzner Cloud. 85 | maintainers: 86 | - "Julian Tölle " 87 | license: MIT 88 | private_key: "{{ .Env.AUR_SSH_KEY }}" 89 | git_url: "ssh://aur@aur.archlinux.org/hcloud-upload-image-bin.git" 90 | depends: 91 | - openssh 92 | 93 | package: |- 94 | # bin 95 | install -Dm755 "./hcloud-upload-image" "${pkgdir}/usr/bin/hcloud-upload-image" 96 | 97 | # license 98 | install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/hcloud-upload-image/LICENSE" 99 | 100 | # completions 101 | mkdir -p "${pkgdir}/usr/share/bash-completion/completions/" 102 | mkdir -p "${pkgdir}/usr/share/zsh/site-functions/" 103 | mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/" 104 | install -Dm644 "./completions/hcloud-upload-image.bash" "${pkgdir}/usr/share/bash-completion/completions/hcloud-upload-image" 105 | install -Dm644 "./completions/hcloud-upload-image.zsh" "${pkgdir}/usr/share/zsh/site-functions/_hcloud-upload-image" 106 | install -Dm644 "./completions/hcloud-upload-image.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/hcloud-upload-image.fish" 107 | 108 | kos: 109 | - id: container-images 110 | build: default 111 | repositories: 112 | - ghcr.io/apricote 113 | platforms: 114 | - linux/amd64 115 | - linux/arm64 116 | base_import_paths: true 117 | labels: 118 | org.opencontainers.image.source: https://github.com/apricote/hcloud-upload-image 119 | tags: 120 | - latest 121 | - "{{.Tag}}" 122 | 123 | snapshot: 124 | version_template: "{{ .Version }}-dev+{{ .ShortCommit }}" 125 | 126 | changelog: 127 | # Generated by release-please 128 | disable: true 129 | 130 | 131 | -------------------------------------------------------------------------------- /hcloudimages/go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 8 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 9 | github.com/hetznercloud/hcloud-go/v2 v2.29.0 h1:LzNFw5XLBfftyu3WM1sdSLjOZBlWORtz2hgGydHaYV8= 10 | github.com/hetznercloud/hcloud-go/v2 v2.29.0/go.mod h1:XBU4+EDH2KVqu2KU7Ws0+ciZcX4ygukQl/J0L5GS8P8= 11 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 12 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 13 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 14 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 15 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 16 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 17 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 18 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 24 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 25 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 26 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 27 | github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 28 | github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 29 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 30 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 31 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 32 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 33 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 34 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 35 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 36 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 37 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 38 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 39 | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 40 | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 41 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 42 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 43 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 44 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 45 | golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 46 | golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 47 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 48 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 49 | google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 50 | google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 53 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hcloud-upload-image 2 | 3 |

4 | Quickly upload any raw disk images into your Hetzner Cloud projects! 5 |

6 | 7 |

8 | Badge: Documentation 9 | Badge: Stable Release 10 | Badge: License MIT 11 |

12 | 13 | 14 | ## About 15 | 16 | The [Hetzner Cloud API](https://docs.hetzner.cloud/) does not support uploading disk images directly and only provides a limited set of default images. The only option for custom disk images is to take a snapshot of an existing server’s root disk. These snapshots can then be used to create new servers. 17 | 18 | To create a completely custom disk image, users need to follow these steps: 19 | 20 | 1. Create a server with the correct server type 21 | 2. Enable the rescue system for the server 22 | 3. Boot the server 23 | 4. Download the disk image from within the rescue system 24 | 5. Write the disk image to the server’s root disk 25 | 6. Shut down the server 26 | 7. Take a snapshot of the server’s root disk 27 | 8. Delete the server 28 | 29 | This is a frustratingly long process. Many users have automated it with [Packer](https://www.packer.io/) and [`packer-plugin-hcloud`](https://github.com/hetznercloud/packer-plugin-hcloud/), but Packer introduces additional complexity that can be difficult to manage. 30 | 31 | This repository provides a simple CLI tool and Go library to streamline the process. 32 | 33 | ## Getting Started 34 | 35 | ### CLI 36 | 37 | #### Binary 38 | 39 | We provide pre-built `deb`, `rpm` and `apk` packages. Alternatively we also provide the binaries directly. 40 | 41 | Check out the [GitHub release artifacts](https://github.com/apricote/hcloud-upload-image/releases/latest) for all of these files and archives. 42 | 43 | #### Arch Linux 44 | 45 | You can get [`hcloud-upload-image-bin`](https://aur.archlinux.org/packages/hcloud-upload-image-bin) from the AUR. 46 | 47 | Use your preferred wrapper to install: 48 | 49 | ```shell 50 | yay -S hcloud-upload-image-bin 51 | ``` 52 | 53 | #### `go install` 54 | 55 | If you already have a recent Go toolchain installed, you can build & install the binary from source: 56 | 57 | ```shell 58 | go install github.com/apricote/hcloud-upload-image@latest 59 | ``` 60 | 61 | #### Docker 62 | 63 | There is a docker image published at `ghcr.io/apricote/hcloud-upload-image`. 64 | 65 | ```shell 66 | docker run --rm -e HCLOUD_TOKEN="" ghcr.io/apricote/hcloud-upload-image:latest 67 | ``` 68 | 69 | #### Usage 70 | 71 | ```shell 72 | export HCLOUD_TOKEN="" 73 | hcloud-upload-image upload \ 74 | --image-url "https://example.com/disk-image-x86.raw.bz2" \ 75 | --architecture x86 \ 76 | --compression bz2 77 | ``` 78 | 79 | To learn more, you can use the embedded help output or check out the [CLI help pages in this repository](docs/reference/cli/hcloud-upload-image.md).: 80 | 81 | ```shell 82 | hcloud-upload-image --help 83 | hcloud-upload-image upload --help 84 | hcloud-upload-image cleanup --help 85 | ``` 86 | 87 | ### Go Library 88 | 89 | The functionality to upload images is also exposed in the library `hcloudimages`! Check out the [reference documentation](https://pkg.go.dev/github.com/apricote/hcloud-upload-image/hcloudimages) for more details. 90 | 91 | #### Install 92 | 93 | ```shell 94 | go get github.com/apricote/hcloud-upload-image/hcloudimages 95 | ``` 96 | 97 | #### Usages 98 | 99 | ```go 100 | package main 101 | 102 | import ( 103 | "context" 104 | "fmt" 105 | "net/url" 106 | 107 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 108 | 109 | "github.com/apricote/hcloud-upload-image/hcloudimages" 110 | ) 111 | 112 | func main() { 113 | client := hcloudimages.NewClient( 114 | hcloud.NewClient(hcloud.WithToken("")), 115 | ) 116 | 117 | imageURL, err := url.Parse("https://example.com/disk-image-x86.raw.bz2") 118 | if err != nil { 119 | panic(err) 120 | } 121 | 122 | image, err := client.Upload(context.TODO(), hcloudimages.UploadOptions{ 123 | ImageURL: imageURL, 124 | ImageCompression: hcloudimages.CompressionBZ2, 125 | Architecture: hcloud.ArchitectureX86, 126 | }) 127 | if err != nil { 128 | panic(err) 129 | } 130 | 131 | fmt.Printf("Uploaded Image: %d", image.ID) 132 | } 133 | ``` 134 | 135 | ## Contributing 136 | 137 | If you have any questions, feedback or ideas, feel free to open an issue or pull request. 138 | 139 | ## License 140 | 141 | This project is licensed under the MIT license, unless the file explicitly specifies another license. 142 | 143 | ## Support Disclaimer 144 | 145 | This is not an official Hetzner Cloud product in any way and Hetzner Cloud does not provide support for this. 146 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/apricote/hcloud-upload-image/hcloudimages v1.2.0 h1:P8e2RBs+2iXDJ0mLP3w3ml0cIDLYUCc9XUTCiUjT5cE= 2 | github.com/apricote/hcloud-upload-image/hcloudimages v1.2.0/go.mod h1:I+R3+ubW2P+X5hOt2lrsWiM2N7zgrukkDhe41riRNb4= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 12 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 13 | github.com/hetznercloud/hcloud-go/v2 v2.29.0 h1:LzNFw5XLBfftyu3WM1sdSLjOZBlWORtz2hgGydHaYV8= 14 | github.com/hetznercloud/hcloud-go/v2 v2.29.0/go.mod h1:XBU4+EDH2KVqu2KU7Ws0+ciZcX4ygukQl/J0L5GS8P8= 15 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 16 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 17 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 18 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 19 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 20 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 24 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 25 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 26 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 30 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 31 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 32 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 33 | github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 34 | github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 35 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 36 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 37 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 38 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 39 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 40 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 42 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 43 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 44 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 45 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 46 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 47 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 48 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 49 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 50 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 51 | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 52 | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 53 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 54 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 55 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 56 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 57 | golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 58 | golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 59 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 60 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 61 | google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 62 | google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 65 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 66 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 67 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 68 | -------------------------------------------------------------------------------- /internal/ui/slog_handler.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "sync" 9 | ) 10 | 11 | // Developed with guidance from golang docs: 12 | // https://github.com/golang/example/blob/32022caedd6a177a7717aa8680cbe179e1045935/slog-handler-guide/README.md 13 | 14 | const ( 15 | ansiClear = "\033[0m" 16 | ansiBold = "\033[1m" 17 | ansiBoldYellow = "\033[1;93m" 18 | ansiBoldRed = "\033[1;31m" 19 | ansiThinGray = "\033[2;37m" 20 | ) 21 | 22 | type Handler struct { 23 | opts HandlerOptions 24 | goas []groupOrAttrs 25 | mu *sync.Mutex 26 | out io.Writer 27 | } 28 | 29 | // HandlerOptions are a subset of [slog.HandlerOptions] that are implemented for the UI handler. 30 | type HandlerOptions struct { 31 | // Level reports the minimum record level that will be logged. 32 | // The handler discards records with lower levels. 33 | // If Level is nil, the handler assumes LevelInfo. 34 | // The handler calls Level.Level for each record processed; 35 | // to adjust the minimum level dynamically, use a LevelVar. 36 | Level slog.Leveler 37 | 38 | // ReplaceAttr is called to rewrite each non-group attribute before it is logged. 39 | // The attribute's value has been resolved (see [Value.Resolve]). 40 | // If ReplaceAttr returns a zero Attr, the attribute is discarded. 41 | // 42 | // The built-in attributes with keys "time", "level", "source", and "msg" 43 | // are passed to this function, except that time is omitted 44 | // if zero, and source is omitted if AddSource is false. 45 | // 46 | // The first argument is a list of currently open groups that contain the 47 | // Attr. It must not be retained or modified. ReplaceAttr is never called 48 | // for Group attributes, only their contents. For example, the attribute 49 | // list 50 | // 51 | // Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) 52 | // 53 | // results in consecutive calls to ReplaceAttr with the following arguments: 54 | // 55 | // nil, Int("a", 1) 56 | // []string{"g"}, Int("b", 2) 57 | // nil, Int("c", 3) 58 | // 59 | // ReplaceAttr can be used to change the default keys of the built-in 60 | // attributes, convert types (for example, to replace a `time.Time` with the 61 | // integer seconds since the Unix epoch), sanitize personal information, or 62 | // remove attributes from the output. 63 | ReplaceAttr func(groups []string, a slog.Attr) slog.Attr 64 | } 65 | 66 | // groupOrAttrs holds either a group name or a list of [slog.Attr]. 67 | type groupOrAttrs struct { 68 | group string // group name if non-empty 69 | attrs []slog.Attr // attrs if non-empty 70 | } 71 | 72 | var _ slog.Handler = &Handler{} 73 | 74 | func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { 75 | h := &Handler{ 76 | out: out, 77 | mu: &sync.Mutex{}, 78 | } 79 | if opts != nil { 80 | h.opts = *opts 81 | } 82 | if h.opts.Level == nil { 83 | h.opts.Level = slog.LevelInfo 84 | } 85 | return h 86 | } 87 | 88 | func (h *Handler) Enabled(_ context.Context, level slog.Level) bool { 89 | return level >= h.opts.Level.Level() 90 | } 91 | 92 | func (h *Handler) Handle(_ context.Context, record slog.Record) error { 93 | buf := make([]byte, 0, 512) 94 | 95 | formattingPrefix := "" 96 | 97 | switch record.Level { 98 | case slog.LevelInfo: 99 | formattingPrefix = ansiBold 100 | case slog.LevelWarn: 101 | // Bold + Yellow 102 | formattingPrefix = ansiBoldYellow 103 | case slog.LevelError: 104 | // Bold + Red 105 | formattingPrefix = ansiBoldRed 106 | } 107 | 108 | // Print main message in formatted text 109 | buf = fmt.Appendf(buf, "%s%s%s", formattingPrefix, record.Message, ansiClear) 110 | 111 | // Add attributes in thin gray 112 | buf = fmt.Append(buf, ansiThinGray) 113 | 114 | // Attributes from [WithGroup] and [WithAttrs] calls 115 | goas := h.goas 116 | if record.NumAttrs() == 0 { 117 | for len(goas) > 0 && goas[len(goas)-1].group != "" { 118 | goas = goas[:len(goas)-1] 119 | } 120 | } 121 | group := "" 122 | for _, goa := range goas { 123 | if goa.group != "" { 124 | group = goa.group 125 | } else { 126 | for _, a := range goa.attrs { 127 | buf = h.appendAttr(buf, group, a) 128 | } 129 | } 130 | } 131 | 132 | record.Attrs(func(a slog.Attr) bool { 133 | buf = h.appendAttr(buf, group, a) 134 | return true 135 | }) 136 | 137 | buf = fmt.Appendf(buf, "%s\n", ansiClear) 138 | 139 | h.mu.Lock() 140 | defer h.mu.Unlock() 141 | _, err := h.out.Write(buf) 142 | return err 143 | } 144 | 145 | func (h *Handler) appendAttr(buf []byte, group string, a slog.Attr) []byte { 146 | a.Value = a.Value.Resolve() 147 | 148 | if h.opts.ReplaceAttr != nil { 149 | a = h.opts.ReplaceAttr([]string{group}, a) 150 | } 151 | 152 | // No-op if null attr 153 | if a.Equal(slog.Attr{}) { 154 | return buf 155 | } 156 | 157 | if group != "" { 158 | group += "." 159 | } 160 | 161 | switch a.Value.Kind() { 162 | case slog.KindString: 163 | buf = fmt.Appendf(buf, " %s%s=%q", group, a.Key, a.Value) 164 | case slog.KindAny: 165 | if err, ok := a.Value.Any().(error); ok { 166 | buf = fmt.Appendf(buf, " %s%s=%q", group, a.Key, err.Error()) 167 | } else { 168 | buf = fmt.Appendf(buf, " %s%s=%s", group, a.Key, a.Value) 169 | } 170 | default: 171 | buf = fmt.Appendf(buf, " %s%s=%s", group, a.Key, a.Value) 172 | } 173 | 174 | return buf 175 | } 176 | 177 | func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { 178 | if len(attrs) == 0 { 179 | return h 180 | } 181 | return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs}) 182 | } 183 | 184 | func (h *Handler) WithGroup(name string) slog.Handler { 185 | if name == "" { 186 | return h 187 | } 188 | return h.withGroupOrAttrs(groupOrAttrs{group: name}) 189 | } 190 | 191 | func (h *Handler) withGroupOrAttrs(goa groupOrAttrs) *Handler { 192 | h2 := *h 193 | h2.goas = make([]groupOrAttrs, len(h.goas)+1) 194 | copy(h2.goas, h.goas) 195 | h2.goas[len(h2.goas)-1] = goa 196 | return &h2 197 | } 198 | -------------------------------------------------------------------------------- /cmd/upload.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/apricote/hcloud-upload-image/hcloudimages" 14 | "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger" 15 | ) 16 | 17 | const ( 18 | uploadFlagImageURL = "image-url" 19 | uploadFlagImagePath = "image-path" 20 | uploadFlagCompression = "compression" 21 | uploadFlagFormat = "format" 22 | uploadFlagArchitecture = "architecture" 23 | uploadFlagServerType = "server-type" 24 | uploadFlagDescription = "description" 25 | uploadFlagLabels = "labels" 26 | ) 27 | 28 | //go:embed upload.md 29 | var longDescription string 30 | 31 | // uploadCmd represents the upload command 32 | var uploadCmd = &cobra.Command{ 33 | Use: "upload (--image-path= | --image-url=) --architecture=", 34 | Short: "Upload the specified disk image into your Hetzner Cloud project.", 35 | Long: longDescription, 36 | Example: ` hcloud-upload-image upload --image-path /home/you/images/custom-linux-image-x86.bz2 --architecture x86 --compression bz2 --description "My super duper custom linux" 37 | hcloud-upload-image upload --image-url https://examples.com/image-arm.raw --architecture arm --labels foo=bar,version=latest 38 | hcloud-upload-image upload --image-url https://examples.com/image-x86.qcow2 --architecture x86 --format qcow2`, 39 | DisableAutoGenTag: true, 40 | 41 | GroupID: "primary", 42 | 43 | PreRun: initClient, 44 | 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | ctx := cmd.Context() 47 | logger := contextlogger.From(ctx) 48 | 49 | imageURLString, _ := cmd.Flags().GetString(uploadFlagImageURL) 50 | imagePathString, _ := cmd.Flags().GetString(uploadFlagImagePath) 51 | imageCompression, _ := cmd.Flags().GetString(uploadFlagCompression) 52 | imageFormat, _ := cmd.Flags().GetString(uploadFlagFormat) 53 | architecture, _ := cmd.Flags().GetString(uploadFlagArchitecture) 54 | serverType, _ := cmd.Flags().GetString(uploadFlagServerType) 55 | description, _ := cmd.Flags().GetString(uploadFlagDescription) 56 | labels, _ := cmd.Flags().GetStringToString(uploadFlagLabels) 57 | 58 | options := hcloudimages.UploadOptions{ 59 | ImageCompression: hcloudimages.Compression(imageCompression), 60 | ImageFormat: hcloudimages.Format(imageFormat), 61 | Description: hcloud.Ptr(description), 62 | Labels: labels, 63 | } 64 | 65 | if imageURLString != "" { 66 | imageURL, err := url.Parse(imageURLString) 67 | if err != nil { 68 | return fmt.Errorf("unable to parse url from --%s=%q: %w", uploadFlagImageURL, imageURLString, err) 69 | } 70 | 71 | // Check for image size 72 | resp, err := http.Head(imageURL.String()) 73 | switch { 74 | case err != nil: 75 | logger.DebugContext(ctx, "failed to check for file size, error on request", "err", err) 76 | case resp.ContentLength == -1: 77 | logger.DebugContext(ctx, "failed to check for file size, server did not set the Content-Length", "err", err) 78 | default: 79 | options.ImageSize = resp.ContentLength 80 | } 81 | 82 | options.ImageURL = imageURL 83 | } else if imagePathString != "" { 84 | stat, err := os.Stat(imagePathString) 85 | if err != nil { 86 | logger.DebugContext(ctx, "failed to check for file size, error on stat", "err", err) 87 | } else { 88 | options.ImageSize = stat.Size() 89 | } 90 | 91 | imageFile, err := os.Open(imagePathString) 92 | if err != nil { 93 | return fmt.Errorf("unable to read file from --%s=%q: %w", uploadFlagImagePath, imagePathString, err) 94 | } 95 | 96 | options.ImageReader = imageFile 97 | } 98 | 99 | if architecture != "" { 100 | options.Architecture = hcloud.Architecture(architecture) 101 | } else if serverType != "" { 102 | options.ServerType = &hcloud.ServerType{Name: serverType} 103 | } 104 | 105 | image, err := client.Upload(ctx, options) 106 | if err != nil { 107 | return fmt.Errorf("failed to upload the image: %w", err) 108 | } 109 | 110 | logger.InfoContext(ctx, "Successfully uploaded the image!", "image", image.ID) 111 | 112 | return nil 113 | }, 114 | } 115 | 116 | func init() { 117 | RootCmd.AddCommand(uploadCmd) 118 | 119 | uploadCmd.Flags().String(uploadFlagImageURL, "", "Remote URL of the disk image that should be uploaded") 120 | uploadCmd.Flags().String(uploadFlagImagePath, "", "Local path to the disk image that should be uploaded") 121 | uploadCmd.MarkFlagsMutuallyExclusive(uploadFlagImageURL, uploadFlagImagePath) 122 | uploadCmd.MarkFlagsOneRequired(uploadFlagImageURL, uploadFlagImagePath) 123 | 124 | uploadCmd.Flags().String(uploadFlagCompression, "", "Type of compression that was used on the disk image [choices: bz2, xz, zstd]") 125 | _ = uploadCmd.RegisterFlagCompletionFunc( 126 | uploadFlagCompression, 127 | cobra.FixedCompletions([]string{string(hcloudimages.CompressionBZ2), string(hcloudimages.CompressionXZ), string(hcloudimages.CompressionZSTD)}, cobra.ShellCompDirectiveNoFileComp), 128 | ) 129 | 130 | uploadCmd.Flags().String(uploadFlagFormat, "", "Format of the image. [default: raw, choices: qcow2]") 131 | _ = uploadCmd.RegisterFlagCompletionFunc( 132 | uploadFlagFormat, 133 | cobra.FixedCompletions([]string{string(hcloudimages.FormatQCOW2)}, cobra.ShellCompDirectiveNoFileComp), 134 | ) 135 | 136 | uploadCmd.Flags().String(uploadFlagArchitecture, "", "CPU architecture of the disk image [choices: x86, arm]") 137 | _ = uploadCmd.RegisterFlagCompletionFunc( 138 | uploadFlagArchitecture, 139 | cobra.FixedCompletions([]string{string(hcloud.ArchitectureX86), string(hcloud.ArchitectureARM)}, cobra.ShellCompDirectiveNoFileComp), 140 | ) 141 | 142 | uploadCmd.Flags().String(uploadFlagServerType, "", "Explicitly use this server type to generate the image. Mutually exclusive with --architecture.") 143 | 144 | // Only one of them needs to be set 145 | uploadCmd.MarkFlagsOneRequired(uploadFlagArchitecture, uploadFlagServerType) 146 | uploadCmd.MarkFlagsMutuallyExclusive(uploadFlagArchitecture, uploadFlagServerType) 147 | 148 | uploadCmd.Flags().String(uploadFlagDescription, "", "Description for the resulting image") 149 | 150 | uploadCmd.Flags().StringToString(uploadFlagLabels, map[string]string{}, "Labels for the resulting image") 151 | } 152 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.0](https://github.com/apricote/hcloud-upload-image/compare/v1.1.0...v1.2.0) (2025-11-06) 4 | 5 | 6 | ### Features 7 | 8 | * change minimum required Go version to 1.24 ([#130](https://github.com/apricote/hcloud-upload-image/issues/130)) ([5eba2d5](https://github.com/apricote/hcloud-upload-image/commit/5eba2d52fe3aafb4fd0d93403548f4c32bc2b5ac)) 9 | * support zstd compression ([#125](https://github.com/apricote/hcloud-upload-image/issues/125)) ([37ebbce](https://github.com/apricote/hcloud-upload-image/commit/37ebbce5179997ac216af274055fc34c777b01e6)), closes [#122](https://github.com/apricote/hcloud-upload-image/issues/122) 10 | * update default x86 server type to cx23 ([#129](https://github.com/apricote/hcloud-upload-image/issues/129)) ([a205619](https://github.com/apricote/hcloud-upload-image/commit/a20561944d0ba9485a6e10e99df15c56a688541d)) 11 | 12 | ## [1.1.0](https://github.com/apricote/hcloud-upload-image/compare/v1.0.1...v1.1.0) (2025-05-10) 13 | 14 | 15 | ### Features 16 | 17 | * smaller snapshots by zeroing disk first ([#101](https://github.com/apricote/hcloud-upload-image/issues/101)) ([fdfb284](https://github.com/apricote/hcloud-upload-image/commit/fdfb284533d3154806b0936c08015fd5cc64b0fb)), closes [#96](https://github.com/apricote/hcloud-upload-image/issues/96) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * upload from local image generates broken command ([#98](https://github.com/apricote/hcloud-upload-image/issues/98)) ([420dcf9](https://github.com/apricote/hcloud-upload-image/commit/420dcf94c965ee470602db6c9c23c777fda91222)), closes [#97](https://github.com/apricote/hcloud-upload-image/issues/97) 23 | 24 | ## [1.0.1](https://github.com/apricote/hcloud-upload-image/compare/v1.0.0...v1.0.1) (2025-05-09) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * timeout while waiting for SSH to become available ([#92](https://github.com/apricote/hcloud-upload-image/issues/92)) ([e490b9a](https://github.com/apricote/hcloud-upload-image/commit/e490b9a7f394e268fa1946ca51aa998c78c3d46a)) 30 | 31 | ## [1.0.0](https://github.com/apricote/hcloud-upload-image/compare/v0.3.1...v1.0.0) (2025-05-04) 32 | 33 | 34 | ### Features 35 | 36 | * **deps:** require Go 1.23 ([#70](https://github.com/apricote/hcloud-upload-image/issues/70)) ([f3fcb62](https://github.com/apricote/hcloud-upload-image/commit/f3fcb623fc00095ab3806fa41dbcb7083c13c5df)) 37 | * docs website ([#80](https://github.com/apricote/hcloud-upload-image/issues/80)) ([d144b85](https://github.com/apricote/hcloud-upload-image/commit/d144b85e3dfd933e8fbb09a0e6f5acacb4d05bea)) 38 | * publish container image ([#82](https://github.com/apricote/hcloud-upload-image/issues/82)) ([91df729](https://github.com/apricote/hcloud-upload-image/commit/91df729f1cfd636355fc8338f47aefa4ab8b3b84)) 39 | * upload qcow2 images ([#69](https://github.com/apricote/hcloud-upload-image/issues/69)) ([ac3e9dd](https://github.com/apricote/hcloud-upload-image/commit/ac3e9dd7ecd86d1538b6401c3073c7c078c40847)) 40 | 41 | ## [0.3.1](https://github.com/apricote/hcloud-upload-image/compare/v0.3.0...v0.3.1) (2024-12-07) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **cli:** local install fails because of go.mod replace ([#47](https://github.com/apricote/hcloud-upload-image/issues/47)) ([66dc5f7](https://github.com/apricote/hcloud-upload-image/commit/66dc5f70b604ed3ee964576d74f94bdcea710c95)) 47 | 48 | ## [0.3.0](https://github.com/apricote/hcloud-upload-image/compare/v0.2.1...v0.3.0) (2024-06-23) 49 | 50 | 51 | ### Features 52 | 53 | * set server type explicitly ([#36](https://github.com/apricote/hcloud-upload-image/issues/36)) ([42eeb00](https://github.com/apricote/hcloud-upload-image/commit/42eeb00a0784e13a00a52cf15a8659b497d78d72)), closes [#30](https://github.com/apricote/hcloud-upload-image/issues/30) 54 | * update default x86 server type to cx22 ([#38](https://github.com/apricote/hcloud-upload-image/issues/38)) ([ebe08b3](https://github.com/apricote/hcloud-upload-image/commit/ebe08b345c8f31df73087b091fa39f5fdc195156)) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * error early when the image write fails ([#34](https://github.com/apricote/hcloud-upload-image/issues/34)) ([256989f](https://github.com/apricote/hcloud-upload-image/commit/256989f4a37e7b124c0684aab0f34cf5e09559be)), closes [#33](https://github.com/apricote/hcloud-upload-image/issues/33) 60 | 61 | ## [0.2.1](https://github.com/apricote/hcloud-upload-image/compare/v0.2.0...v0.2.1) (2024-05-10) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * **cli:** completion requires HCLOUD_TOKEN ([#19](https://github.com/apricote/hcloud-upload-image/issues/19)) ([bb2ca48](https://github.com/apricote/hcloud-upload-image/commit/bb2ca482000f5c780545edb9a03aa9f6bf93d906)) 67 | 68 | ## [0.2.0](https://github.com/apricote/hcloud-upload-image/compare/v0.1.1...v0.2.0) (2024-05-09) 69 | 70 | 71 | ### Features 72 | 73 | * packaging for deb, rpm, apk, aur ([#17](https://github.com/apricote/hcloud-upload-image/issues/17)) ([139761c](https://github.com/apricote/hcloud-upload-image/commit/139761cc28050b00bca22573d765f2b94af89bac)) 74 | * upload local disk images ([#15](https://github.com/apricote/hcloud-upload-image/issues/15)) ([fcea3e3](https://github.com/apricote/hcloud-upload-image/commit/fcea3e3c6e5ba7383aa69838401903e3f54f910c)) 75 | * upload xz compressed images ([#16](https://github.com/apricote/hcloud-upload-image/issues/16)) ([1c943e4](https://github.com/apricote/hcloud-upload-image/commit/1c943e4480ba2042fc3feabf363ec88eb2efbaee)) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * update user-agent in CLI ([#5](https://github.com/apricote/hcloud-upload-image/issues/5)) ([b17857c](https://github.com/apricote/hcloud-upload-image/commit/b17857c1fefc0b09da2ed2711b20ba76930dd365)) 81 | 82 | ## [0.1.1](https://github.com/apricote/hcloud-upload-image/compare/v0.1.0...v0.1.1) (2024-05-04) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * CLI does not produce release binaries ([#3](https://github.com/apricote/hcloud-upload-image/issues/3)) ([f373d4c](https://github.com/apricote/hcloud-upload-image/commit/f373d4c2baca9ccc892e6b6abff6dd217f2fdbeb)) 88 | 89 | ## [0.1.0](https://github.com/apricote/hcloud-upload-image/compare/v0.0.1...v0.1.0) (2024-05-04) 90 | 91 | 92 | ### Features 93 | 94 | * **cli:** docs grouping and version ([847b696](https://github.com/apricote/hcloud-upload-image/commit/847b696c74ce67c2f18aaa69af60f6c0c5b736c4)) 95 | * **cli:** hide redundant log attributes ([9e65452](https://github.com/apricote/hcloud-upload-image/commit/9e654521ae12debf40f181dfe291ad4ded0f7524)) 96 | * **cli:** upload command ([b6ae95f](https://github.com/apricote/hcloud-upload-image/commit/b6ae95f55ba134f5ef124d377ed3ad0a556b8cf4)) 97 | * documentation and cleanup command ([c9ab40b](https://github.com/apricote/hcloud-upload-image/commit/c9ab40b539bc51ea2611bb0b58ab8aef4ec06eea)) 98 | * initial library code ([4f57df5](https://github.com/apricote/hcloud-upload-image/commit/4f57df5b66ed1391155792758737b8f54b7ef2ab)) 99 | * log output ([904e5e0](https://github.com/apricote/hcloud-upload-image/commit/904e5e0bed6ba87e0f4063c27a0678a9c85b7371)) 100 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 2 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 3 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 5 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 6 | github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0/go.mod h1:iJ95BaLfISZBY9X8K2Y2A5a49dI0RLjAuq+4BqlOSgA= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= 8 | github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4= 11 | github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms= 12 | github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw= 13 | github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM= 14 | github.com/dave/courtney v0.3.0 h1:8aR1os2ImdIQf3Zj4oro+lD/L4Srb5VwGefqZ/jzz7U= 15 | github.com/dave/courtney v0.3.0/go.mod h1:BAv3hA06AYfNUjfjQr+5gc6vxeBVOupLqrColj+QSD8= 16 | github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e h1:l99YKCdrK4Lvb/zTupt0GMPfNbncAGf8Cv/t1sYLOg0= 17 | github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e/go.mod h1:i00+b/gKdIDIxuLDFob7ustLAVqhsZRk2qVZrArELGQ= 18 | github.com/dave/jennifer v1.6.0 h1:MQ/6emI2xM7wt0tJzJzyUik2Q3Tcn2eE0vtYgh4GPVI= 19 | github.com/dave/jennifer v1.6.0/go.mod h1:AxTG893FiZKqxy3FP1kL80VMshSMuz2G+EgvszgGRnk= 20 | github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e h1:xURkGi4RydhyaYR6PzcyHTueQudxY4LgxN1oYEPJHa0= 21 | github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e/go.mod h1:qZqlPyPvfsDJt+3wHJ1EvSXDuVjFTK0j2p/ca+gtsb8= 22 | github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs= 23 | github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc= 24 | github.com/dave/rebecca v0.9.1 h1:jxVfdOxRirbXL28vXMvUvJ1in3djwkVKXCq339qhBL0= 25 | github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWEmXBA= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= 28 | github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= 29 | github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= 30 | github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 31 | github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= 32 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 33 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 34 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 35 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/jessevdk/go-flags v1.4.1-0.20181029123624-5de817a9aa20 h1:dAOsPLhnBzIyxu0VvmnKjlNcIlgMK+erD6VRHDtweMI= 39 | github.com/jessevdk/go-flags v1.4.1-0.20181029123624-5de817a9aa20/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 40 | github.com/jmattheis/goverter v1.4.0 h1:SrboBYMpGkj1XSgFhWwqzdP024zIa1+58YzUm+0jcBE= 41 | github.com/jmattheis/goverter v1.4.0/go.mod h1:iVIl/4qItWjWj2g3vjouGoYensJbRqDHpzlEVMHHFeY= 42 | github.com/jmattheis/goverter v1.5.1 h1:NdBYrF1V1EFQbAA1M/ZR4YVbQjxVl3L6Xupn7moF3LU= 43 | github.com/jmattheis/goverter v1.5.1/go.mod h1:iVIl/4qItWjWj2g3vjouGoYensJbRqDHpzlEVMHHFeY= 44 | github.com/jmattheis/goverter v1.8.0 h1:P8GQ/uJEzCwpNdm5vKxaAjDDMxTpsAJZxgrXegicAW8= 45 | github.com/jmattheis/goverter v1.8.0/go.mod h1:c8TVzpum2NThy2eJ/Wz3tyqRxzpElP2xDfoHOIDrNSQ= 46 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 47 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 48 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 49 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 50 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 51 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 52 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 53 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 54 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 55 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 58 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 59 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 60 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 61 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 62 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 63 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 64 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 65 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 66 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 67 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 68 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 69 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 70 | github.com/vburenin/ifacemaker v1.2.1 h1:3Vq8B/bfBgjWTkv+jDg4dVL1KHt3k1K4lO7XRxYA2sk= 71 | github.com/vburenin/ifacemaker v1.2.1/go.mod h1:5WqrzX2aD7/hi+okBjcaEQJMg4lDGrpuEX3B8L4Wgrs= 72 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 73 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 74 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 75 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 76 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 77 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 78 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 79 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 80 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 81 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 82 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 83 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 84 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 85 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 86 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 87 | golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= 88 | golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= 89 | golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= 90 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 91 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 92 | golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= 93 | golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 94 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 95 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 96 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 97 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 98 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 99 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 100 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 101 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 102 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 103 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 104 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 105 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 106 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 107 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 108 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 109 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 110 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 112 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 113 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 114 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 115 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= 116 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 117 | golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= 118 | golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= 119 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 120 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 121 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 122 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 123 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 124 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 125 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 126 | golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= 127 | golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= 128 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 129 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 130 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 131 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 132 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 133 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 134 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 135 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 136 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 137 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 138 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 139 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 140 | -------------------------------------------------------------------------------- /hcloudimages/client.go: -------------------------------------------------------------------------------- 1 | package hcloudimages 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 13 | "github.com/hetznercloud/hcloud-go/v2/hcloud/exp/kit/sshutil" 14 | "golang.org/x/crypto/ssh" 15 | 16 | "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger" 17 | "github.com/apricote/hcloud-upload-image/hcloudimages/internal/actionutil" 18 | "github.com/apricote/hcloud-upload-image/hcloudimages/internal/control" 19 | "github.com/apricote/hcloud-upload-image/hcloudimages/internal/labelutil" 20 | "github.com/apricote/hcloud-upload-image/hcloudimages/internal/randomid" 21 | "github.com/apricote/hcloud-upload-image/hcloudimages/internal/sshsession" 22 | ) 23 | 24 | const ( 25 | CreatedByLabel = "apricote.de/created-by" 26 | CreatedByValue = "hcloud-upload-image" 27 | 28 | resourcePrefix = "hcloud-upload-image-" 29 | ) 30 | 31 | var ( 32 | DefaultLabels = map[string]string{ 33 | CreatedByLabel: CreatedByValue, 34 | } 35 | 36 | serverTypePerArchitecture = map[hcloud.Architecture]*hcloud.ServerType{ 37 | hcloud.ArchitectureX86: {Name: "cx23"}, 38 | hcloud.ArchitectureARM: {Name: "cax11"}, 39 | } 40 | 41 | defaultImage = &hcloud.Image{Name: "ubuntu-24.04"} 42 | defaultLocation = &hcloud.Location{Name: "fsn1"} 43 | defaultRescueType = hcloud.ServerRescueTypeLinux64 44 | 45 | defaultSSHDialTimeout = 1 * time.Minute 46 | 47 | // Size observed on x86, 2025-05-03, no idea if that changes. 48 | // Might be able to extends this to more of the available memory. 49 | rescueSystemRootDiskSizeMB int64 = 960 50 | ) 51 | 52 | type UploadOptions struct { 53 | // ImageURL must be publicly available. The instance will download the image from this endpoint. 54 | ImageURL *url.URL 55 | 56 | // ImageReader 57 | ImageReader io.Reader 58 | 59 | // ImageCompression describes the compression of the referenced image file. It defaults to [CompressionNone]. If 60 | // set to anything else, the file will be decompressed before written to the disk. 61 | ImageCompression Compression 62 | 63 | ImageFormat Format 64 | 65 | // Can be optionally set to make the client validate that the image can be written to the server. 66 | ImageSize int64 67 | 68 | // Possible future additions: 69 | // ImageSignatureVerification 70 | // ImageLocalPath 71 | 72 | // Architecture should match the architecture of the Image. This decides if the Snapshot can later be 73 | // used with [hcloud.ArchitectureX86] or [hcloud.ArchitectureARM] servers. 74 | // 75 | // Internally this decides what server type is used for the temporary server. 76 | // 77 | // Optional if [UploadOptions.ServerType] is set. 78 | Architecture hcloud.Architecture 79 | 80 | // ServerType can be optionally set to override the default server type for the architecture. 81 | // Situations where this makes sense: 82 | // 83 | // - Your image is larger than the root disk of the default server types. 84 | // - The default server type is no longer available, or not temporarily out of stock. 85 | ServerType *hcloud.ServerType 86 | 87 | // Description is an optional description that the resulting image (snapshot) will have. There is no way to 88 | // select images by its description, you should use Labels if you need to identify your image later. 89 | Description *string 90 | 91 | // Labels will be added to the resulting image (snapshot). Use these to filter the image list if you 92 | // need to identify the image later on. 93 | // 94 | // We also always add a label `apricote.de/created-by=hcloud-image-upload` ([CreatedByLabel], [CreatedByValue]). 95 | Labels map[string]string 96 | 97 | // DebugSkipResourceCleanup will skip the cleanup of the temporary SSH Key and Server. 98 | DebugSkipResourceCleanup bool 99 | } 100 | 101 | type Compression string 102 | 103 | const ( 104 | CompressionNone Compression = "" 105 | CompressionBZ2 Compression = "bz2" 106 | CompressionXZ Compression = "xz" 107 | CompressionZSTD Compression = "zstd" 108 | 109 | // Possible future additions: 110 | // zip 111 | ) 112 | 113 | type Format string 114 | 115 | const ( 116 | FormatRaw Format = "" 117 | 118 | // FormatQCOW2 allows to upload images in the qcow2 format directly. 119 | // 120 | // The qcow2 image must fit on the disk available in the rescue system. "qemu-img dd", which is used to convert 121 | // qcow2 to raw, requires a file as an input. If [UploadOption.ImageSize] is set and FormatQCOW2 is used, there is a 122 | // warning message displayed if there is a high probability of issues. 123 | FormatQCOW2 Format = "qcow2" 124 | ) 125 | 126 | // NewClient instantiates a new client. It requires a working [*hcloud.Client] to interact with the Hetzner Cloud API. 127 | func NewClient(c *hcloud.Client) *Client { 128 | return &Client{ 129 | c: c, 130 | } 131 | } 132 | 133 | type Client struct { 134 | c *hcloud.Client 135 | } 136 | 137 | // Upload the specified image into a snapshot on Hetzner Cloud. 138 | // 139 | // As the Hetzner Cloud API has no direct way to upload images, we create a temporary server, 140 | // overwrite the root disk and take a snapshot of that disk instead. 141 | // 142 | // The temporary server costs money. If the upload fails, we might be unable to delete the server. Check out 143 | // CleanupTempResources for a helper in this case. 144 | func (s *Client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Image, error) { 145 | logger := contextlogger.From(ctx).With( 146 | "library", "hcloudimages", 147 | "method", "upload", 148 | ) 149 | 150 | id, err := randomid.Generate() 151 | if err != nil { 152 | return nil, err 153 | } 154 | logger = logger.With("run-id", id) 155 | // For simplicity, we use the name random name for SSH Key + Server 156 | resourceName := resourcePrefix + id 157 | labels := labelutil.Merge(DefaultLabels, options.Labels) 158 | 159 | // 0. Validations 160 | if options.ImageFormat == FormatQCOW2 && options.ImageSize > 0 { 161 | if options.ImageSize > rescueSystemRootDiskSizeMB*1024*1024 { 162 | // Just a warning, because the size might change with time. 163 | // Alternatively one could add an override flag for the check and make this an error. 164 | logger.WarnContext(ctx, 165 | fmt.Sprintf("image must be smaller than %d MB (rescue system root disk) for qcow2", rescueSystemRootDiskSizeMB), 166 | "maximum-size", rescueSystemRootDiskSizeMB, 167 | "actual-size", options.ImageSize/(1024*1024), 168 | ) 169 | } 170 | } 171 | 172 | // 1. Create SSH Key 173 | logger.InfoContext(ctx, "# Step 1: Generating SSH Key") 174 | privateKey, publicKey, err := sshutil.GenerateKeyPair() 175 | if err != nil { 176 | return nil, fmt.Errorf("failed to generate temporary ssh key pair: %w", err) 177 | } 178 | 179 | key, _, err := s.c.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{ 180 | Name: resourceName, 181 | PublicKey: string(publicKey), 182 | Labels: labels, 183 | }) 184 | if err != nil { 185 | return nil, fmt.Errorf("failed to submit temporary ssh key to API: %w", err) 186 | } 187 | logger.DebugContext(ctx, "Uploaded ssh key", "ssh-key-id", key.ID) 188 | defer func() { 189 | // Cleanup SSH Key 190 | if options.DebugSkipResourceCleanup { 191 | logger.InfoContext(ctx, "Cleanup: Skipping cleanup of temporary ssh key") 192 | return 193 | } 194 | 195 | logger.InfoContext(ctx, "Cleanup: Deleting temporary ssh key") 196 | 197 | _, err := s.c.SSHKey.Delete(ctx, key) 198 | if err != nil { 199 | logger.WarnContext(ctx, "Cleanup: ssh key could not be deleted", "error", err) 200 | // TODO 201 | } 202 | }() 203 | 204 | // 2. Create Server 205 | logger.InfoContext(ctx, "# Step 2: Creating Server") 206 | var serverType *hcloud.ServerType 207 | if options.ServerType != nil { 208 | serverType = options.ServerType 209 | } else { 210 | var ok bool 211 | serverType, ok = serverTypePerArchitecture[options.Architecture] 212 | if !ok { 213 | return nil, fmt.Errorf("unknown architecture %q, valid options: %q, %q", options.Architecture, hcloud.ArchitectureX86, hcloud.ArchitectureARM) 214 | } 215 | } 216 | 217 | logger.DebugContext(ctx, "creating server with config", 218 | "image", defaultImage.Name, 219 | "location", defaultLocation.Name, 220 | "serverType", serverType.Name, 221 | ) 222 | serverCreateResult, _, err := s.c.Server.Create(ctx, hcloud.ServerCreateOpts{ 223 | Name: resourceName, 224 | ServerType: serverType, 225 | 226 | // Not used, but without this the user receives an email with a password for every created server 227 | SSHKeys: []*hcloud.SSHKey{key}, 228 | 229 | // We need to enable rescue system first 230 | StartAfterCreate: hcloud.Ptr(false), 231 | // Image will never be booted, we only boot into rescue system 232 | Image: defaultImage, 233 | Location: defaultLocation, 234 | Labels: labels, 235 | }) 236 | if err != nil { 237 | return nil, fmt.Errorf("creating the temporary server failed: %w", err) 238 | } 239 | logger = logger.With("server", serverCreateResult.Server.ID) 240 | logger.DebugContext(ctx, "Created Server") 241 | 242 | logger.DebugContext(ctx, "waiting on actions") 243 | err = s.c.Action.WaitFor(ctx, append(serverCreateResult.NextActions, serverCreateResult.Action)...) 244 | if err != nil { 245 | return nil, fmt.Errorf("creating the temporary server failed: %w", err) 246 | } 247 | logger.DebugContext(ctx, "actions finished") 248 | 249 | server := serverCreateResult.Server 250 | defer func() { 251 | // Cleanup Server 252 | if options.DebugSkipResourceCleanup { 253 | logger.InfoContext(ctx, "Cleanup: Skipping cleanup of temporary server") 254 | return 255 | } 256 | 257 | logger.InfoContext(ctx, "Cleanup: Deleting temporary server") 258 | 259 | _, _, err := s.c.Server.DeleteWithResult(ctx, server) 260 | if err != nil { 261 | logger.WarnContext(ctx, "Cleanup: server could not be deleted", "error", err) 262 | } 263 | }() 264 | 265 | // 3. Activate Rescue System 266 | logger.InfoContext(ctx, "# Step 3: Activating Rescue System") 267 | enableRescueResult, _, err := s.c.Server.EnableRescue(ctx, server, hcloud.ServerEnableRescueOpts{ 268 | Type: defaultRescueType, 269 | SSHKeys: []*hcloud.SSHKey{key}, 270 | }) 271 | if err != nil { 272 | return nil, fmt.Errorf("enabling the rescue system on the temporary server failed: %w", err) 273 | } 274 | 275 | logger.DebugContext(ctx, "rescue system requested, waiting on action") 276 | 277 | err = s.c.Action.WaitFor(ctx, enableRescueResult.Action) 278 | if err != nil { 279 | return nil, fmt.Errorf("enabling the rescue system on the temporary server failed: %w", err) 280 | } 281 | logger.DebugContext(ctx, "action finished, rescue system enabled") 282 | 283 | // 4. Boot Server 284 | logger.InfoContext(ctx, "# Step 4: Booting Server") 285 | powerOnAction, _, err := s.c.Server.Poweron(ctx, server) 286 | if err != nil { 287 | return nil, fmt.Errorf("starting the temporary server failed: %w", err) 288 | } 289 | 290 | logger.DebugContext(ctx, "boot requested, waiting on action") 291 | 292 | err = s.c.Action.WaitFor(ctx, powerOnAction) 293 | if err != nil { 294 | return nil, fmt.Errorf("starting the temporary server failed: %w", err) 295 | } 296 | logger.DebugContext(ctx, "action finished, server is booting") 297 | 298 | // 5. Open SSH Session 299 | logger.InfoContext(ctx, "# Step 5: Opening SSH Connection") 300 | signer, err := ssh.ParsePrivateKey(privateKey) 301 | if err != nil { 302 | return nil, fmt.Errorf("parsing the automatically generated temporary private key failed: %w", err) 303 | } 304 | 305 | sshClientConfig := &ssh.ClientConfig{ 306 | User: "root", 307 | Auth: []ssh.AuthMethod{ 308 | ssh.PublicKeys(signer), 309 | }, 310 | // There is no way to get the host key of the rescue system beforehand 311 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 312 | Timeout: defaultSSHDialTimeout, 313 | } 314 | 315 | // the server needs some time until its properly started and ssh is available 316 | var sshClient *ssh.Client 317 | 318 | err = control.Retry( 319 | contextlogger.New(ctx, logger.With("operation", "ssh")), 320 | 100, // ~ 3 minutes 321 | func() error { 322 | var err error 323 | logger.DebugContext(ctx, "trying to connect to server", "ip", server.PublicNet.IPv4.IP) 324 | sshClient, err = ssh.Dial("tcp", server.PublicNet.IPv4.IP.String()+":ssh", sshClientConfig) 325 | return err 326 | }, 327 | ) 328 | if err != nil { 329 | return nil, fmt.Errorf("failed to ssh into temporary server: %w", err) 330 | } 331 | defer func() { _ = sshClient.Close() }() 332 | 333 | // 6. Wipe existing disk, to avoid storing any bytes from it in the snapshot 334 | logger.InfoContext(ctx, "# Step 6: Cleaning existing disk") 335 | 336 | output, err := sshsession.Run(sshClient, "blkdiscard /dev/sda", nil) 337 | logger.DebugContext(ctx, string(output)) 338 | if err != nil { 339 | return nil, fmt.Errorf("failed to clean existing disk: %w", err) 340 | } 341 | 342 | // 7. SSH On Server: Download Image, Decompress, Write to Root Disk 343 | logger.InfoContext(ctx, "# Step 7: Downloading image and writing to disk") 344 | 345 | cmd, err := assembleCommand(options) 346 | if err != nil { 347 | return nil, err 348 | } 349 | 350 | logger.DebugContext(ctx, "running download, decompress and write to disk command", "cmd", cmd) 351 | 352 | output, err = sshsession.Run(sshClient, cmd, options.ImageReader) 353 | logger.InfoContext(ctx, "# Step 7: Finished writing image to disk") 354 | logger.DebugContext(ctx, string(output)) 355 | if err != nil { 356 | return nil, fmt.Errorf("failed to download and write the image: %w", err) 357 | } 358 | 359 | // 8. SSH On Server: Shutdown 360 | logger.InfoContext(ctx, "# Step 8: Shutting down server") 361 | _, err = sshsession.Run(sshClient, "shutdown now", nil) 362 | if err != nil { 363 | // TODO Verify if shutdown error, otherwise return 364 | logger.WarnContext(ctx, "shutdown returned error", "err", err) 365 | } 366 | 367 | // 9. Create Image from Server 368 | logger.InfoContext(ctx, "# Step 9: Creating Image") 369 | createImageResult, _, err := s.c.Server.CreateImage(ctx, server, &hcloud.ServerCreateImageOpts{ 370 | Type: hcloud.ImageTypeSnapshot, 371 | Description: options.Description, 372 | Labels: labels, 373 | }) 374 | if err != nil { 375 | return nil, fmt.Errorf("failed to create snapshot: %w", err) 376 | } 377 | logger.DebugContext(ctx, "image creation requested, waiting on action") 378 | 379 | err = s.c.Action.WaitFor(ctx, createImageResult.Action) 380 | if err != nil { 381 | return nil, fmt.Errorf("failed to create snapshot: %w", err) 382 | } 383 | logger.DebugContext(ctx, "action finished, image was created") 384 | 385 | image := createImageResult.Image 386 | logger.InfoContext(ctx, "# Image was created", "image", image.ID) 387 | 388 | // Resource cleanup is happening in `defer` 389 | return image, nil 390 | } 391 | 392 | // CleanupTempResources tries to delete any resources that were left over from previous calls to [Client.Upload]. 393 | // Upload tries to clean up any temporary resources it created at runtime, but might fail at any point. 394 | // You can then use this command to make sure that all temporary resources are removed from your project. 395 | // 396 | // This method tries to delete any server or ssh keys that match the [DefaultLabels] 397 | func (s *Client) CleanupTempResources(ctx context.Context) error { 398 | logger := contextlogger.From(ctx).With( 399 | "library", "hcloudimages", 400 | "method", "cleanup", 401 | ) 402 | 403 | selector := labelutil.Selector(DefaultLabels) 404 | logger = logger.With("selector", selector) 405 | 406 | logger.InfoContext(ctx, "# Cleaning up Servers") 407 | err := s.cleanupTempServers(ctx, logger, selector) 408 | if err != nil { 409 | return fmt.Errorf("failed to clean up all servers: %w", err) 410 | } 411 | logger.DebugContext(ctx, "cleaned up all servers") 412 | 413 | logger.InfoContext(ctx, "# Cleaning up SSH Keys") 414 | err = s.cleanupTempSSHKeys(ctx, logger, selector) 415 | if err != nil { 416 | return fmt.Errorf("failed to clean up all ssh keys: %w", err) 417 | } 418 | logger.DebugContext(ctx, "cleaned up all ssh keys") 419 | 420 | return nil 421 | } 422 | 423 | func (s *Client) cleanupTempServers(ctx context.Context, logger *slog.Logger, selector string) error { 424 | servers, err := s.c.Server.AllWithOpts(ctx, hcloud.ServerListOpts{ListOpts: hcloud.ListOpts{ 425 | LabelSelector: selector, 426 | }}) 427 | if err != nil { 428 | return fmt.Errorf("failed to list servers: %w", err) 429 | } 430 | 431 | if len(servers) == 0 { 432 | logger.InfoContext(ctx, "No servers found") 433 | return nil 434 | } 435 | logger.InfoContext(ctx, "removing servers", "count", len(servers)) 436 | 437 | errs := []error{} 438 | actions := make([]*hcloud.Action, 0, len(servers)) 439 | 440 | for _, server := range servers { 441 | result, _, err := s.c.Server.DeleteWithResult(ctx, server) 442 | if err != nil { 443 | errs = append(errs, err) 444 | logger.WarnContext(ctx, "failed to delete server", "server", server.ID, "error", err) 445 | continue 446 | } 447 | 448 | actions = append(actions, result.Action) 449 | } 450 | 451 | successActions, errorActions, err := actionutil.Settle(ctx, &s.c.Action, actions...) 452 | if err != nil { 453 | return fmt.Errorf("failed to wait for server delete: %w", err) 454 | } 455 | 456 | if len(successActions) > 0 { 457 | ids := make([]int64, 0, len(successActions)) 458 | for _, action := range successActions { 459 | for _, resource := range action.Resources { 460 | if resource.Type == hcloud.ActionResourceTypeServer { 461 | ids = append(ids, resource.ID) 462 | } 463 | } 464 | } 465 | 466 | logger.InfoContext(ctx, "successfully deleted servers", "servers", ids) 467 | } 468 | 469 | if len(errorActions) > 0 { 470 | for _, action := range errorActions { 471 | errs = append(errs, action.Error()) 472 | } 473 | } 474 | 475 | if len(errs) > 0 { 476 | // The returned message contains no info about the server IDs which failed 477 | return fmt.Errorf("failed to delete some of the servers: %w", errors.Join(errs...)) 478 | } 479 | 480 | return nil 481 | } 482 | 483 | func (s *Client) cleanupTempSSHKeys(ctx context.Context, logger *slog.Logger, selector string) error { 484 | keys, _, err := s.c.SSHKey.List(ctx, hcloud.SSHKeyListOpts{ListOpts: hcloud.ListOpts{ 485 | LabelSelector: selector, 486 | }}) 487 | if err != nil { 488 | return fmt.Errorf("failed to list keys: %w", err) 489 | } 490 | 491 | if len(keys) == 0 { 492 | logger.InfoContext(ctx, "No ssh keys found") 493 | return nil 494 | } 495 | 496 | errs := []error{} 497 | for _, key := range keys { 498 | _, err := s.c.SSHKey.Delete(ctx, key) 499 | if err != nil { 500 | errs = append(errs, err) 501 | logger.WarnContext(ctx, "failed to delete ssh key", "ssh-key", key.ID, "error", err) 502 | continue 503 | } 504 | } 505 | 506 | if len(errs) > 0 { 507 | // The returned message contains no info about the server IDs which failed 508 | return fmt.Errorf("failed to delete some of the ssh keys: %w", errors.Join(errs...)) 509 | } 510 | 511 | return nil 512 | } 513 | 514 | func assembleCommand(options UploadOptions) (string, error) { 515 | // Make sure that we fail early, ie. if the image url does not work 516 | cmd := "set -euo pipefail && " 517 | 518 | if options.ImageURL != nil { 519 | cmd += fmt.Sprintf("wget --no-verbose -O - %q | ", options.ImageURL.String()) 520 | } 521 | 522 | if options.ImageCompression != CompressionNone { 523 | switch options.ImageCompression { 524 | case CompressionBZ2: 525 | cmd += "bzip2 -cd | " 526 | case CompressionXZ: 527 | cmd += "xz -cd | " 528 | case CompressionZSTD: 529 | cmd += "zstd -cd | " 530 | default: 531 | return "", fmt.Errorf("unknown compression: %q", options.ImageCompression) 532 | } 533 | } 534 | 535 | switch options.ImageFormat { 536 | case FormatRaw: 537 | cmd += "dd of=/dev/sda bs=4M" 538 | case FormatQCOW2: 539 | cmd += "tee image.qcow2 > /dev/null && qemu-img dd -f qcow2 -O raw if=image.qcow2 of=/dev/sda bs=4M" 540 | default: 541 | return "", fmt.Errorf("unknown format: %q", options.ImageFormat) 542 | } 543 | 544 | cmd += " && sync" 545 | 546 | // the pipefail does not work correctly without wrapping in bash. 547 | cmd = fmt.Sprintf("bash -c '%s'", cmd) 548 | 549 | return cmd, nil 550 | } 551 | --------------------------------------------------------------------------------