Compare commits

..

1 commit

Author SHA1 Message Date
renovate[bot]
3c1e41023b
chore(deps): update module golang.org/x/crypto to v0.38.0 2025-05-05 23:08:09 +00:00
17 changed files with 78 additions and 253 deletions

View file

@ -1 +1 @@
{".":"1.1.0","hcloudimages":"1.1.0"}
{".":"1.0.0","hcloudimages":"1.0.0"}

View file

@ -18,15 +18,15 @@ jobs:
go-version-file: go.mod
- name: Run golangci-lint (CLI)
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v7
with:
version: v2.1.6 # renovate: datasource=github-releases depName=golangci/golangci-lint
version: v2.1.5 # renovate: datasource=github-releases depName=golangci/golangci-lint
args: --timeout 5m
- name: Run golangci-lint (Lib)
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v7
with:
version: v2.1.6 # renovate: datasource=github-releases depName=golangci/golangci-lint
version: v2.1.5 # renovate: datasource=github-releases depName=golangci/golangci-lint
args: --timeout 5m
working-directory: hcloudimages

View file

@ -19,7 +19,7 @@ jobs:
- uses: ./.github/actions/setup-mdbook
with:
version: v0.4.49 # renovate: datasource=github-releases depName=rust-lang/mdbook
version: v0.4.48 # renovate: datasource=github-releases depName=rust-lang/mdbook
- name: Build Book
working-directory: docs

View file

@ -1,24 +1,5 @@
# Changelog
## [1.1.0](https://github.com/apricote/hcloud-upload-image/compare/v1.0.1...v1.1.0) (2025-05-10)
### Features
* 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)
### Bug Fixes
* 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)
## [1.0.1](https://github.com/apricote/hcloud-upload-image/compare/v1.0.0...v1.0.1) (2025-05-09)
### Bug Fixes
* 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))
## [1.0.0](https://github.com/apricote/hcloud-upload-image/compare/v0.3.1...v1.0.0) (2025-05-04)

View file

@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/apricote/hcloud-upload-image/hcloudimages"
"github.com/apricote/hcloud-upload-image/hcloudimages/backoff"
"github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
"github.com/apricote/hcloud-upload-image/internal/ui"
"github.com/apricote/hcloud-upload-image/internal/version"
@ -88,7 +89,7 @@ func initClient(cmd *cobra.Command, _ []string) {
opts := []hcloud.ClientOption{
hcloud.WithToken(os.Getenv("HCLOUD_TOKEN")),
hcloud.WithApplication("hcloud-upload-image", version.Version),
hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: hcloud.ExponentialBackoffWithOpts(hcloud.ExponentialBackoffOpts{Multiplier: 2, Base: 1 * time.Second, Cap: 30 * time.Second})}),
hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: backoff.ExponentialBackoffWithLimit(2, 1*time.Second, 30*time.Second)}),
}
if os.Getenv("HCLOUD_DEBUG") != "" || verbose >= 2 {

4
go.mod
View file

@ -2,10 +2,10 @@ module github.com/apricote/hcloud-upload-image
go 1.23.0
toolchain go1.24.3
toolchain go1.24.2
require (
github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0
github.com/apricote/hcloud-upload-image/hcloudimages v1.0.0
github.com/hetznercloud/hcloud-go/v2 v2.21.0
github.com/spf13/cobra v1.9.1
)

4
go.sum
View file

@ -1,5 +1,5 @@
github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0 h1:1guW0IO2/070qbaP6zzNJJ8XsGLKPpxyE1W6fyf7MPc=
github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0/go.mod h1:iJ95BaLfISZBY9X8K2Y2A5a49dI0RLjAuq+4BqlOSgA=
github.com/apricote/hcloud-upload-image/hcloudimages v1.0.0 h1:Gq0SSuPCiZFApGQ3SIEEQqD8btD6tRwfOxr+cky7oTo=
github.com/apricote/hcloud-upload-image/hcloudimages v1.0.0/go.mod h1:MwBmhSPlwS3S3ynOsFXPbco40Iv3udqhXKz2EXKSpVU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

View file

@ -3,7 +3,7 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0/go.mod h1:iJ95BaLfISZBY9X8K2Y2A5a49dI0RLjAuq+4BqlOSgA=
github.com/apricote/hcloud-upload-image/hcloudimages v1.0.0/go.mod h1:MwBmhSPlwS3S3ynOsFXPbco40Iv3udqhXKz2EXKSpVU=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

View file

@ -1,24 +1,5 @@
# Changelog
## [1.1.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.0.1...hcloudimages/v1.1.0) (2025-05-10)
### Features
* 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)
### Bug Fixes
* 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)
## [1.0.1](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.0.0...hcloudimages/v1.0.1) (2025-05-09)
### Bug Fixes
* 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))
## [1.0.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.3.1...hcloudimages/v1.0.0) (2025-05-04)

View file

@ -16,10 +16,6 @@ import (
// It uses the formula:
//
// min(b^retries * d, limit)
//
// This function has a known overflow issue and should not be used anymore.
//
// Deprecated: Use BackoffFuncWithOpts from github.com/hetznercloud/hcloud-go/v2/hcloud instead.
func ExponentialBackoffWithLimit(b float64, d time.Duration, limit time.Duration) hcloud.BackoffFunc {
return func(retries int) time.Duration {
current := time.Duration(math.Pow(b, float64(retries))) * d

View file

@ -316,7 +316,7 @@ func (s *Client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Ima
err = control.Retry(
contextlogger.New(ctx, logger.With("operation", "ssh")),
100, // ~ 3 minutes
10,
func() error {
var err error
logger.DebugContext(ctx, "trying to connect to server", "ip", server.PublicNet.IPv4.IP)
@ -329,42 +329,55 @@ func (s *Client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Ima
}
defer func() { _ = sshClient.Close() }()
// 6. Wipe existing disk, to avoid storing any bytes from it in the snapshot
logger.InfoContext(ctx, "# Step 6: Cleaning existing disk")
output, err := sshsession.Run(sshClient, "blkdiscard /dev/sda", nil)
logger.DebugContext(ctx, string(output))
if err != nil {
return nil, fmt.Errorf("failed to clean existing disk: %w", err)
// 6. SSH On Server: Download Image, Decompress, Write to Root Disk
logger.InfoContext(ctx, "# Step 6: Downloading image and writing to disk")
cmd := ""
if options.ImageURL != nil {
cmd += fmt.Sprintf("wget --no-verbose -O - %q", options.ImageURL.String())
}
// 7. SSH On Server: Download Image, Decompress, Write to Root Disk
logger.InfoContext(ctx, "# Step 7: Downloading image and writing to disk")
cmd, err := assembleCommand(options)
if err != nil {
return nil, err
if options.ImageCompression != CompressionNone {
switch options.ImageCompression {
case CompressionBZ2:
cmd += " | bzip2 -cd"
case CompressionXZ:
cmd += " | xz -cd"
default:
return nil, fmt.Errorf("unknown compression: %q", options.ImageCompression)
}
}
switch options.ImageFormat {
case FormatRaw:
cmd += " | dd of=/dev/sda bs=4M"
case FormatQCOW2:
cmd += " > image.qcow2 && qemu-img dd -f qcow2 -O raw if=image.qcow2 of=/dev/sda bs=4M"
}
cmd += " && sync"
// Make sure that we fail early, ie. if the image url does not work.
// the pipefail does not work correctly without wrapping in bash.
cmd = fmt.Sprintf("bash -c 'set -euo pipefail && %s'", cmd)
logger.DebugContext(ctx, "running download, decompress and write to disk command", "cmd", cmd)
output, err = sshsession.Run(sshClient, cmd, options.ImageReader)
logger.InfoContext(ctx, "# Step 7: Finished writing image to disk")
output, err := sshsession.Run(sshClient, cmd, options.ImageReader)
logger.InfoContext(ctx, "# Step 6: Finished writing image to disk")
logger.DebugContext(ctx, string(output))
if err != nil {
return nil, fmt.Errorf("failed to download and write the image: %w", err)
}
// 8. SSH On Server: Shutdown
logger.InfoContext(ctx, "# Step 8: Shutting down server")
// 7. SSH On Server: Shutdown
logger.InfoContext(ctx, "# Step 7: Shutting down server")
_, err = sshsession.Run(sshClient, "shutdown now", nil)
if err != nil {
// TODO Verify if shutdown error, otherwise return
logger.WarnContext(ctx, "shutdown returned error", "err", err)
}
// 9. Create Image from Server
logger.InfoContext(ctx, "# Step 9: Creating Image")
// 8. Create Image from Server
logger.InfoContext(ctx, "# Step 8: Creating Image")
createImageResult, _, err := s.c.Server.CreateImage(ctx, server, &hcloud.ServerCreateImageOpts{
Type: hcloud.ImageTypeSnapshot,
Description: options.Description,
@ -509,39 +522,3 @@ func (s *Client) cleanupTempSSHKeys(ctx context.Context, logger *slog.Logger, se
return nil
}
func assembleCommand(options UploadOptions) (string, error) {
// Make sure that we fail early, ie. if the image url does not work
cmd := "set -euo pipefail && "
if options.ImageURL != nil {
cmd += fmt.Sprintf("wget --no-verbose -O - %q | ", options.ImageURL.String())
}
if options.ImageCompression != CompressionNone {
switch options.ImageCompression {
case CompressionBZ2:
cmd += "bzip2 -cd | "
case CompressionXZ:
cmd += "xz -cd | "
default:
return "", fmt.Errorf("unknown compression: %q", options.ImageCompression)
}
}
switch options.ImageFormat {
case FormatRaw:
cmd += "dd of=/dev/sda bs=4M"
case FormatQCOW2:
cmd += "tee image.qcow2 > /dev/null && qemu-img dd -f qcow2 -O raw if=image.qcow2 of=/dev/sda bs=4M"
default:
return "", fmt.Errorf("unknown format: %q", options.ImageFormat)
}
cmd += " && sync"
// the pipefail does not work correctly without wrapping in bash.
cmd = fmt.Sprintf("bash -c '%s'", cmd)
return cmd, nil
}

View file

@ -1,110 +1,33 @@
package hcloudimages
package hcloudimages_test
import (
"context"
"fmt"
"net/url"
"testing"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/apricote/hcloud-upload-image/hcloudimages"
)
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
func ExampleClient_Upload() {
client := hcloudimages.NewClient(
hcloud.NewClient(hcloud.WithToken("<your token>")),
)
imageURL, err := url.Parse("https://example.com/disk-image.raw.bz2")
if err != nil {
panic(err)
}
return u
}
func TestAssembleCommand(t *testing.T) {
tests := []struct {
name string
options UploadOptions
want string
wantErr bool
}{
{
name: "local raw",
options: UploadOptions{},
want: "bash -c 'set -euo pipefail && dd of=/dev/sda bs=4M && sync'",
},
{
name: "remote raw",
options: UploadOptions{
ImageURL: mustParseURL("https://example.com/image.xz"),
},
want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.xz\" | dd of=/dev/sda bs=4M && sync'",
},
{
name: "local xz",
options: UploadOptions{
ImageCompression: CompressionXZ,
},
want: "bash -c 'set -euo pipefail && xz -cd | dd of=/dev/sda bs=4M && sync'",
},
{
name: "remote xz",
options: UploadOptions{
ImageURL: mustParseURL("https://example.com/image.xz"),
ImageCompression: CompressionXZ,
},
want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.xz\" | xz -cd | dd of=/dev/sda bs=4M && sync'",
},
{
name: "local bz2",
options: UploadOptions{
ImageCompression: CompressionBZ2,
},
want: "bash -c 'set -euo pipefail && bzip2 -cd | dd of=/dev/sda bs=4M && sync'",
},
{
name: "remote bz2",
options: UploadOptions{
ImageURL: mustParseURL("https://example.com/image.bz2"),
ImageCompression: CompressionXZ,
},
want: "bash -c 'set -euo pipefail && wget --no-verbose -O - \"https://example.com/image.bz2\" | xz -cd | dd of=/dev/sda bs=4M && sync'",
},
{
name: "local qcow2",
options: UploadOptions{
ImageFormat: FormatQCOW2,
},
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'",
},
{
name: "remote qcow2",
options: UploadOptions{
ImageURL: mustParseURL("https://example.com/image.qcow2"),
ImageFormat: FormatQCOW2,
},
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'",
},
{
name: "unknown compression",
options: UploadOptions{
ImageCompression: "noodle",
},
wantErr: true,
},
{
name: "unknown format",
options: UploadOptions{
ImageFormat: "poodle",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := assembleCommand(tt.options)
if (err != nil) != tt.wantErr {
t.Errorf("assembleCommand() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("assembleCommand() got = %v, want %v", got, tt.want)
}
})
image, err := client.Upload(context.TODO(), hcloudimages.UploadOptions{
ImageURL: imageURL,
ImageCompression: hcloudimages.CompressionBZ2,
Architecture: hcloud.ArchitectureX86,
})
if err != nil {
panic(err)
}
fmt.Printf("Uploaded Image: %d", image.ID)
}

View file

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

View file

@ -2,12 +2,12 @@ module github.com/apricote/hcloud-upload-image/hcloudimages
go 1.23.0
toolchain go1.24.3
toolchain go1.24.2
require (
github.com/hetznercloud/hcloud-go/v2 v2.21.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.38.0
)
require (
@ -23,7 +23,7 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -32,16 +32,16 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -8,8 +8,7 @@ import (
"context"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/apricote/hcloud-upload-image/hcloudimages/backoff"
"github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
)
@ -19,7 +18,7 @@ func Retry(ctx context.Context, maxTries int, f func() error) error {
var err error
backoffFunc := hcloud.ExponentialBackoffWithOpts(hcloud.ExponentialBackoffOpts{Multiplier: 2, Base: 200 * time.Millisecond, Cap: 2 * time.Second})
backoffFunc := backoff.ExponentialBackoffWithLimit(2, 200*time.Millisecond, 2*time.Second)
for try := 0; try < maxTries; try++ {
if ctx.Err() != nil {

View file

@ -2,7 +2,7 @@ package version
var (
// version is a semver version (https://semver.org).
version = "1.1.0" // x-release-please-version
version = "1.0.0" // x-release-please-version
// versionPrerelease is a semver version pre-release identifier (https://semver.org).
//