Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package hcloud-upload-image for 
openSUSE:Factory checked in at 2026-06-22 17:35:18
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/hcloud-upload-image (Old)
 and      /work/SRC/openSUSE:Factory/.hcloud-upload-image.new.1956 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "hcloud-upload-image"

Mon Jun 22 17:35:18 2026 rev:7 rq:1360865 version:1.5.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/hcloud-upload-image/hcloud-upload-image.changes  
2026-05-15 23:54:46.036213814 +0200
+++ 
/work/SRC/openSUSE:Factory/.hcloud-upload-image.new.1956/hcloud-upload-image.changes
        2026-06-22 17:35:28.526437997 +0200
@@ -1,0 +2,23 @@
+Sun Jun 21 16:30:35 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 1.5.0:
+  * BREAKING CHANGES
+    - new write-to-disk command (#178)
+  * Features
+    - new write-to-disk command (#178) (d65441f), closes #157
+  * Bug Fixes
+    - update hcloudimages module path for v2 (#184) (f4fd485)
+  * Miscellaneous Chores
+    - deps: update module
+      github.com/apricote/hcloud-upload-image/hcloudimages to
+      v2.0.1 (#186) (1b1e1af)
+  * Dependencies
+    - chore(deps): update actions/checkout action to v7 (#188)
+    - chore(main): release hcloudimages 2.0.1 (#185)
+    - chore(main): release hcloudimages 2.0.0 (#181)
+    - chore(deps): update dependency rust-lang/mdbook to v0.5.3
+      (#175)
+    - chore(deps): update go toolchain directive to v1.26.4 (#179)
+    - chore(deps): update actions/checkout digest to df4cb1c (#180)
+
+-------------------------------------------------------------------

Old:
----
  hcloud-upload-image-1.4.0.obscpio

New:
----
  hcloud-upload-image-1.5.0.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ hcloud-upload-image.spec ++++++
--- /var/tmp/diff_new_pack.MYCqvo/_old  2026-06-22 17:35:31.318536254 +0200
+++ /var/tmp/diff_new_pack.MYCqvo/_new  2026-06-22 17:35:31.322536394 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           hcloud-upload-image
-Version:        1.4.0
+Version:        1.5.0
 Release:        0
 Summary:        Quickly upload any raw disk images into your Hetzner Cloud 
projects
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.MYCqvo/_old  2026-06-22 17:35:31.358537661 +0200
+++ /var/tmp/diff_new_pack.MYCqvo/_new  2026-06-22 17:35:31.362537802 +0200
@@ -5,7 +5,7 @@
     <param name="exclude">.git</param>
     <param name="exclude">go.work</param>
     <param name="exclude">go.work.sum</param>
-    <param name="revision">v1.4.0</param>
+    <param name="revision">v1.5.0</param>
     <param name="match-tag">v*</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.MYCqvo/_old  2026-06-22 17:35:31.406539350 +0200
+++ /var/tmp/diff_new_pack.MYCqvo/_new  2026-06-22 17:35:31.410539491 +0200
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param 
name="url">https://github.com/apricote/hcloud-upload-image/</param>
-              <param 
name="changesrevision">73af6a8a750a37d062bbb8e8dd4d09f17274f5a3</param></service></servicedata>
+              <param 
name="changesrevision">97083898f202471b7f7b96fa9417c1c0f1c025fc</param></service></servicedata>
 (No newline at EOF)
 

++++++ hcloud-upload-image-1.4.0.obscpio -> hcloud-upload-image-1.5.0.obscpio 
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/CHANGELOG.md 
new/hcloud-upload-image-1.5.0/CHANGELOG.md
--- old/hcloud-upload-image-1.4.0/CHANGELOG.md  2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/CHANGELOG.md  2026-06-21 17:36:47.000000000 
+0200
@@ -1,5 +1,26 @@
 # Changelog
 
+## 
[1.5.0](https://github.com/apricote/hcloud-upload-image/compare/v1.4.0...v1.5.0)
 (2026-06-21)
+
+
+### ⚠ BREAKING CHANGES
+
+* new write-to-disk command 
([#178](https://github.com/apricote/hcloud-upload-image/issues/178))
+
+### Features
+
+* new write-to-disk command 
([#178](https://github.com/apricote/hcloud-upload-image/issues/178)) 
([d65441f](https://github.com/apricote/hcloud-upload-image/commit/d65441fc41e377e4a51c4b817383eb7167264c97)),
 closes [#157](https://github.com/apricote/hcloud-upload-image/issues/157)
+
+
+### Bug Fixes
+
+* update hcloudimages module path for v2 
([#184](https://github.com/apricote/hcloud-upload-image/issues/184)) 
([f4fd485](https://github.com/apricote/hcloud-upload-image/commit/f4fd4854492fd53221293cd828db16305c5accf5))
+
+
+### Miscellaneous Chores
+
+* **deps:** update module github.com/apricote/hcloud-upload-image/hcloudimages 
to v2.0.1 ([#186](https://github.com/apricote/hcloud-upload-image/issues/186)) 
([1b1e1af](https://github.com/apricote/hcloud-upload-image/commit/1b1e1afffc089db41affaff730c18850abe1ac13))
+
 ## 
[1.4.0](https://github.com/apricote/hcloud-upload-image/compare/v1.3.0...v1.4.0)
 (2026-05-14)
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/README.md 
new/hcloud-upload-image-1.5.0/README.md
--- old/hcloud-upload-image-1.4.0/README.md     2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/README.md     2026-06-21 17:36:47.000000000 
+0200
@@ -134,7 +134,7 @@
 #### Install
 
 ```shell
-go get github.com/apricote/hcloud-upload-image/hcloudimages
+go get github.com/apricote/hcloud-upload-image/hcloudimages/v2
 ```
 
 #### Usages
@@ -149,7 +149,7 @@
 
        "github.com/hetznercloud/hcloud-go/v2/hcloud"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2"
 )
 
 func main() {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/cmd/cleanup.go 
new/hcloud-upload-image-1.5.0/cmd/cleanup.go
--- old/hcloud-upload-image-1.4.0/cmd/cleanup.go        2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/cmd/cleanup.go        2026-06-21 
17:36:47.000000000 +0200
@@ -5,7 +5,7 @@
 
        "github.com/spf13/cobra"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
 )
 
 // cleanupCmd represents the cleanup command
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/cmd/root.go 
new/hcloud-upload-image-1.5.0/cmd/root.go
--- old/hcloud-upload-image-1.4.0/cmd/root.go   2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/cmd/root.go   2026-06-21 17:36:47.000000000 
+0200
@@ -8,8 +8,8 @@
        "github.com/hetznercloud/hcloud-go/v2/hcloud"
        "github.com/spf13/cobra"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages"
-       "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
        "github.com/apricote/hcloud-upload-image/internal/ui"
        "github.com/apricote/hcloud-upload-image/internal/version"
 )
@@ -26,6 +26,7 @@
 
 // The pre-authenticated client. Set in the root command PersistentPreRun
 var client *hcloudimages.Client
+var hcloudclient *hcloud.Client
 
 // RootCmd represents the base command when called without any subcommands
 var RootCmd = &cobra.Command{
@@ -95,7 +96,8 @@
                opts = append(opts, hcloud.WithDebugWriter(os.Stderr))
        }
 
-       client = hcloudimages.NewClient(hcloud.NewClient(opts...))
+       hcloudclient = hcloud.NewClient(opts...)
+       client = hcloudimages.NewClient(hcloudclient)
 }
 
 func Execute() {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/cmd/upload.go 
new/hcloud-upload-image-1.5.0/cmd/upload.go
--- old/hcloud-upload-image-1.4.0/cmd/upload.go 2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/cmd/upload.go 2026-06-21 17:36:47.000000000 
+0200
@@ -3,22 +3,15 @@
 import (
        _ "embed"
        "fmt"
-       "net/http"
-       "net/url"
-       "os"
 
        "github.com/hetznercloud/hcloud-go/v2/hcloud"
        "github.com/spf13/cobra"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages"
-       "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
 )
 
 const (
-       uploadFlagImageURL     = "image-url"
-       uploadFlagImagePath    = "image-path"
-       uploadFlagCompression  = "compression"
-       uploadFlagFormat       = "format"
        uploadFlagArchitecture = "architecture"
        uploadFlagServerType   = "server-type"
        uploadFlagDescription  = "description"
@@ -27,13 +20,13 @@
 )
 
 //go:embed upload.md
-var longDescription string
+var uploadLongDescription string
 
 // uploadCmd represents the upload command
 var uploadCmd = &cobra.Command{
        Use:   "upload (--image-path=<local-path> | --image-url=<url>) 
--architecture=<x86|arm>",
        Short: "Upload the specified disk image into your Hetzner Cloud 
project.",
-       Long:  longDescription,
+       Long:  uploadLongDescription,
        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"
   hcloud-upload-image upload --image-url https://examples.com/image-arm.raw 
--architecture arm --labels foo=bar,version=latest
   hcloud-upload-image upload --image-url https://examples.com/image-x86.qcow2 
--architecture x86 --format qcow2`,
@@ -47,10 +40,11 @@
                ctx := cmd.Context()
                logger := contextlogger.From(ctx)
 
-               imageURLString, _ := cmd.Flags().GetString(uploadFlagImageURL)
-               imagePathString, _ := cmd.Flags().GetString(uploadFlagImagePath)
-               imageCompression, _ := 
cmd.Flags().GetString(uploadFlagCompression)
-               imageFormat, _ := cmd.Flags().GetString(uploadFlagFormat)
+               writeOptions, err := parseAndValidateWriteOptions(ctx, 
cmd.Flags())
+               if err != nil {
+                       return err
+               }
+
                architecture, _ := cmd.Flags().GetString(uploadFlagArchitecture)
                serverType, _ := cmd.Flags().GetString(uploadFlagServerType)
                description, _ := cmd.Flags().GetString(uploadFlagDescription)
@@ -58,44 +52,9 @@
                location, _ := cmd.Flags().GetString(uploadFlagLocation)
 
                options := hcloudimages.UploadOptions{
-                       ImageCompression: 
hcloudimages.Compression(imageCompression),
-                       ImageFormat:      hcloudimages.Format(imageFormat),
-                       Description:      hcloud.Ptr(description),
-                       Labels:           labels,
-               }
-
-               if imageURLString != "" {
-                       imageURL, err := url.Parse(imageURLString)
-                       if err != nil {
-                               return fmt.Errorf("unable to parse url from 
--%s=%q: %w", uploadFlagImageURL, imageURLString, err)
-                       }
-
-                       // Check for image size
-                       resp, err := http.Head(imageURL.String())
-                       switch {
-                       case err != nil:
-                               logger.DebugContext(ctx, "failed to check for 
file size, error on request", "err", err)
-                       case resp.ContentLength == -1:
-                               logger.DebugContext(ctx, "failed to check for 
file size, server did not set the Content-Length", "err", err)
-                       default:
-                               options.ImageSize = resp.ContentLength
-                       }
-
-                       options.ImageURL = imageURL
-               } else if imagePathString != "" {
-                       stat, err := os.Stat(imagePathString)
-                       if err != nil {
-                               logger.DebugContext(ctx, "failed to check for 
file size, error on stat", "err", err)
-                       } else {
-                               options.ImageSize = stat.Size()
-                       }
-
-                       imageFile, err := os.Open(imagePathString)
-                       if err != nil {
-                               return fmt.Errorf("unable to read file from 
--%s=%q: %w", uploadFlagImagePath, imagePathString, err)
-                       }
-
-                       options.ImageReader = imageFile
+                       WriteOptions: writeOptions,
+                       Description:  hcloud.Ptr(description),
+                       Labels:       labels,
                }
 
                if architecture != "" {
@@ -122,22 +81,7 @@
 func init() {
        RootCmd.AddCommand(uploadCmd)
 
-       uploadCmd.Flags().String(uploadFlagImageURL, "", "Remote URL of the 
disk image that should be uploaded")
-       uploadCmd.Flags().String(uploadFlagImagePath, "", "Local path to the 
disk image that should be uploaded")
-       uploadCmd.MarkFlagsMutuallyExclusive(uploadFlagImageURL, 
uploadFlagImagePath)
-       uploadCmd.MarkFlagsOneRequired(uploadFlagImageURL, uploadFlagImagePath)
-
-       uploadCmd.Flags().String(uploadFlagCompression, "", "Type of 
compression that was used on the disk image [choices: bz2, xz, zstd]")
-       _ = uploadCmd.RegisterFlagCompletionFunc(
-               uploadFlagCompression,
-               
cobra.FixedCompletions([]string{string(hcloudimages.CompressionBZ2), 
string(hcloudimages.CompressionXZ), string(hcloudimages.CompressionZSTD)}, 
cobra.ShellCompDirectiveNoFileComp),
-       )
-
-       uploadCmd.Flags().String(uploadFlagFormat, "", "Format of the image. 
[default: raw, choices: qcow2]")
-       _ = uploadCmd.RegisterFlagCompletionFunc(
-               uploadFlagFormat,
-               
cobra.FixedCompletions([]string{string(hcloudimages.FormatQCOW2)}, 
cobra.ShellCompDirectiveNoFileComp),
-       )
+       registerWriteOptions(uploadCmd)
 
        uploadCmd.Flags().String(uploadFlagArchitecture, "", "CPU architecture 
of the disk image [choices: x86, arm]")
        _ = uploadCmd.RegisterFlagCompletionFunc(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/cmd/write-to-disk.go 
new/hcloud-upload-image-1.5.0/cmd/write-to-disk.go
--- old/hcloud-upload-image-1.4.0/cmd/write-to-disk.go  1970-01-01 
01:00:00.000000000 +0100
+++ new/hcloud-upload-image-1.5.0/cmd/write-to-disk.go  2026-06-21 
17:36:47.000000000 +0200
@@ -0,0 +1,166 @@
+package cmd
+
+import (
+       "context"
+       _ "embed"
+       "fmt"
+       "net/http"
+       "net/url"
+       "os"
+
+       "github.com/hetznercloud/hcloud-go/v2/hcloud"
+       "github.com/spf13/cobra"
+       "github.com/spf13/pflag"
+
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
+)
+
+const (
+       writeFlagImageURL    = "image-url"
+       writeFlagImagePath   = "image-path"
+       writeFlagCompression = "compression"
+       writeFlagFormat      = "format"
+       writeFlagServer      = "server"
+)
+
+func registerWriteOptions(cmd *cobra.Command) {
+       cmd.Flags().String(writeFlagImageURL, "", "Remote URL of the disk 
image")
+       cmd.Flags().String(writeFlagImagePath, "", "Local path to the disk 
image")
+       cmd.MarkFlagsMutuallyExclusive(writeFlagImageURL, writeFlagImagePath)
+       cmd.MarkFlagsOneRequired(writeFlagImageURL, writeFlagImagePath)
+
+       cmd.Flags().String(writeFlagCompression, "", "Type of compression that 
was used on the disk image [choices: bz2, xz, zstd]")
+       _ = cmd.RegisterFlagCompletionFunc(
+               writeFlagCompression,
+               
cobra.FixedCompletions([]string{string(hcloudimages.CompressionBZ2), 
string(hcloudimages.CompressionXZ), string(hcloudimages.CompressionZSTD)}, 
cobra.ShellCompDirectiveNoFileComp),
+       )
+
+       cmd.Flags().String(writeFlagFormat, "", "Format of the disk image. 
[default: raw, choices: qcow2]")
+       _ = cmd.RegisterFlagCompletionFunc(
+               writeFlagFormat,
+               
cobra.FixedCompletions([]string{string(hcloudimages.FormatQCOW2)}, 
cobra.ShellCompDirectiveNoFileComp),
+       )
+}
+
+func parseAndValidateWriteOptions(ctx context.Context, flags *pflag.FlagSet) 
(hcloudimages.WriteOptions, error) {
+       logger := contextlogger.From(ctx)
+
+       imageURLString, _ := flags.GetString(writeFlagImageURL)
+       imagePathString, _ := flags.GetString(writeFlagImagePath)
+       imageCompression, _ := flags.GetString(writeFlagCompression)
+       imageFormat, _ := flags.GetString(writeFlagFormat)
+
+       options := hcloudimages.WriteOptions{
+               ImageCompression: hcloudimages.Compression(imageCompression),
+               ImageFormat:      hcloudimages.Format(imageFormat),
+       }
+
+       if imageURLString != "" {
+               imageURL, err := url.Parse(imageURLString)
+               if err != nil {
+                       return hcloudimages.WriteOptions{}, fmt.Errorf("unable 
to parse url from --%s=%q: %w", writeFlagImageURL, imageURLString, err)
+               }
+
+               // Check for image size
+               resp, err := http.Head(imageURL.String())
+               switch {
+               case err != nil:
+                       logger.DebugContext(ctx, "failed to check for file 
size, error on request", "err", err)
+               case resp.ContentLength == -1:
+                       logger.DebugContext(ctx, "failed to check for file 
size, server did not set the Content-Length", "err", err)
+               default:
+                       options.ImageSize = resp.ContentLength
+               }
+               _ = resp.Body.Close()
+
+               options.ImageURL = imageURL
+       } else if imagePathString != "" {
+               stat, err := os.Stat(imagePathString)
+               if err != nil {
+                       logger.DebugContext(ctx, "failed to check for file 
size, error on stat", "err", err)
+               } else {
+                       options.ImageSize = stat.Size()
+               }
+
+               imageFile, err := os.Open(imagePathString)
+               if err != nil {
+                       return hcloudimages.WriteOptions{}, fmt.Errorf("unable 
to read file from --%s=%q: %w", writeFlagImagePath, imagePathString, err)
+               }
+
+               options.ImageReader = imageFile
+       }
+
+       return options, nil
+}
+
+//go:embed write-to-disk.md
+var writeToDiskLongDescription string
+
+// writeToDiskCmd represents the write-to-disk command
+var writeToDiskCmd = &cobra.Command{
+       Use:   "write-to-disk (--image-path=<local-path> | --image-url=<url>) 
--server <id-or-name>",
+       Short: "Write the specified disk image to the root disk of the 
specified server.",
+       Long:  writeToDiskLongDescription,
+       Example: `  hcloud-upload-image write-to-disk --image-path 
/home/you/images/custom-linux-image-x86.bz2 --compression bz2 --server my-server
+  hcloud-upload-image write-to-disk --image-url 
https://examples.com/image-arm.raw --server my-arm-server
+  hcloud-upload-image write-to-disk --image-url 
https://examples.com/image-x86.qcow2 --format qcow2 --server my-x86-server`,
+       DisableAutoGenTag: true,
+
+       GroupID: "primary",
+
+       PreRun: initClient,
+
+       RunE: func(cmd *cobra.Command, args []string) error {
+               ctx := cmd.Context()
+               logger := contextlogger.From(ctx)
+
+               options, err := parseAndValidateWriteOptions(ctx, cmd.Flags())
+               if err != nil {
+                       return err
+               }
+
+               serverIDOrName, _ := cmd.Flags().GetString(writeFlagServer)
+               options.Server, _, err = hcloudclient.Server.Get(ctx, 
serverIDOrName)
+               if err != nil {
+                       return fmt.Errorf("could not get server %q: %w", 
serverIDOrName, err)
+               }
+               if options.Server == nil {
+                       return fmt.Errorf("server %q not found", serverIDOrName)
+               }
+
+               err = client.WriteToDisk(ctx, options)
+               if err != nil {
+                       return fmt.Errorf("failed to write the image: %w", err)
+               }
+
+               logger.InfoContext(ctx, "Successfully wrote the image!")
+
+               return nil
+       },
+}
+
+func init() {
+       RootCmd.AddCommand(writeToDiskCmd)
+
+       registerWriteOptions(writeToDiskCmd)
+
+       writeToDiskCmd.Flags().String(writeFlagServer, "", "ID or name of 
target server")
+       _ = writeToDiskCmd.MarkFlagRequired(writeFlagServer)
+       _ = writeToDiskCmd.RegisterFlagCompletionFunc(
+               writeFlagServer,
+               func(cmd *cobra.Command, args []string, toComplete string) 
([]cobra.Completion, cobra.ShellCompDirective) {
+                       servers, err := 
hcloudclient.Server.AllWithOpts(cmd.Context(), hcloud.ServerListOpts{})
+                       if err != nil {
+                               return []cobra.Completion{}, 
cobra.ShellCompDirectiveError
+                       }
+
+                       serverNames := make([]string, len(servers))
+                       for i, server := range servers {
+                               serverNames[i] = server.Name
+                       }
+
+                       return serverNames, cobra.ShellCompDirectiveNoFileComp
+               },
+       )
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/cmd/write-to-disk.md 
new/hcloud-upload-image-1.5.0/cmd/write-to-disk.md
--- old/hcloud-upload-image-1.4.0/cmd/write-to-disk.md  1970-01-01 
01:00:00.000000000 +0100
+++ new/hcloud-upload-image-1.5.0/cmd/write-to-disk.md  2026-06-21 
17:36:47.000000000 +0200
@@ -0,0 +1,12 @@
+This command writes the specified image to the target servers root disk. Think 
of
+it as a one-off "upload".
+
+#### Image Size
+
+The image size for raw disk images is only limited by the servers root disk.
+
+The image size for qcow2 images is limited to the rescue systems root disk.
+This is currently a memory-backed file system with **960 MB** of space. A qcow2
+image not be larger than this size, or the process will error. There is a
+warning being logged if hcloud-upload-image can detect that your file is larger
+than this size.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/default.nix 
new/hcloud-upload-image-1.5.0/default.nix
--- old/hcloud-upload-image-1.4.0/default.nix   2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/default.nix   2026-06-21 17:36:47.000000000 
+0200
@@ -9,13 +9,13 @@
       (builtins.readFile ./internal/version/version.go));
 
   src = ./.;
-  vendorHash = "sha256-CVdhf8pDBANzlZzaqaGPv+vUTOQB/K+LEIh8tZUTSnA=";
+  vendorHash = "sha256-K8d6Q6WYJw3SelckscSh1CJBecG+y11+q9UcMDyotLU=";
   env.GOWORK = "off";
   subPackages = ["."];
   goSum = ./go.sum; # make sure to rebuild
 
   postPatch = ''
-    echo 'replace github.com/apricote/hcloud-upload-image/hcloudimages => 
./hcloudimages' >> go.mod
+    echo 'replace github.com/apricote/hcloud-upload-image/hcloudimages/v2 => 
./hcloudimages' >> go.mod
   '';
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image.md 
new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image.md
--- old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image.md     
2026-05-14 20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image.md     
2026-06-21 17:36:47.000000000 +0200
@@ -17,4 +17,5 @@
 
 * [hcloud-upload-image cleanup](hcloud-upload-image_cleanup.md)         - 
Remove any temporary resources that were left over
 * [hcloud-upload-image upload](hcloud-upload-image_upload.md)   - Upload the 
specified disk image into your Hetzner Cloud project.
+* [hcloud-upload-image write-to-disk](hcloud-upload-image_write-to-disk.md)    
 - Write the specified disk image to the root disk of the specified server.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image_upload.md 
new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image_upload.md
--- 
old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image_upload.md  
    2026-05-14 20:52:38.000000000 +0200
+++ 
new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image_upload.md  
    2026-06-21 17:36:47.000000000 +0200
@@ -36,10 +36,10 @@
       --architecture string     CPU architecture of the disk image [choices: 
x86, arm]
       --compression string      Type of compression that was used on the disk 
image [choices: bz2, xz, zstd]
       --description string      Description for the resulting image
-      --format string           Format of the image. [default: raw, choices: 
qcow2]
+      --format string           Format of the disk image. [default: raw, 
choices: qcow2]
   -h, --help                    help for upload
-      --image-path string       Local path to the disk image that should be 
uploaded
-      --image-url string        Remote URL of the disk image that should be 
uploaded
+      --image-path string       Local path to the disk image
+      --image-url string        Remote URL of the disk image
       --labels stringToString   Labels for the resulting image (default [])
       --location string         Datacenter location for the temporary server 
[default: fsn1, choices: fsn1, nbg1, hel1, ash, hil, sin]
       --server-type string      Explicitly use this server type to generate 
the image. Mutually exclusive with --architecture.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image_write-to-disk.md
 
new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image_write-to-disk.md
--- 
old/hcloud-upload-image-1.4.0/docs/reference/cli/hcloud-upload-image_write-to-disk.md
       1970-01-01 01:00:00.000000000 +0100
+++ 
new/hcloud-upload-image-1.5.0/docs/reference/cli/hcloud-upload-image_write-to-disk.md
       2026-06-21 17:36:47.000000000 +0200
@@ -0,0 +1,53 @@
+## hcloud-upload-image write-to-disk
+
+Write the specified disk image to the root disk of the specified server.
+
+### Synopsis
+
+This command writes the specified image to the target servers root disk. Think 
of
+it as a one-off "upload".
+
+#### Image Size
+
+The image size for raw disk images is only limited by the servers root disk.
+
+The image size for qcow2 images is limited to the rescue systems root disk.
+This is currently a memory-backed file system with **960 MB** of space. A qcow2
+image not be larger than this size, or the process will error. There is a
+warning being logged if hcloud-upload-image can detect that your file is larger
+than this size.
+
+
+```
+hcloud-upload-image write-to-disk (--image-path=<local-path> | 
--image-url=<url>) --server <id-or-name> [flags]
+```
+
+### Examples
+
+```
+  hcloud-upload-image write-to-disk --image-path 
/home/you/images/custom-linux-image-x86.bz2 --compression bz2 --server my-server
+  hcloud-upload-image write-to-disk --image-url 
https://examples.com/image-arm.raw --server my-arm-server
+  hcloud-upload-image write-to-disk --image-url 
https://examples.com/image-x86.qcow2 --format qcow2 --server my-x86-server
+```
+
+### Options
+
+```
+      --compression string   Type of compression that was used on the disk 
image [choices: bz2, xz, zstd]
+      --format string        Format of the disk image. [default: raw, choices: 
qcow2]
+  -h, --help                 help for write-to-disk
+      --image-path string    Local path to the disk image
+      --image-url string     Remote URL of the disk image
+      --server string        ID or name of target server
+```
+
+### Options inherited from parent commands
+
+```
+  -v, --verbose count   verbose debug output, can be specified up to 2 times
+```
+
+### SEE ALSO
+
+* [hcloud-upload-image](hcloud-upload-image.md)         - Manage custom OS 
images on Hetzner Cloud.
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/go.mod 
new/hcloud-upload-image-1.5.0/go.mod
--- old/hcloud-upload-image-1.4.0/go.mod        2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/go.mod        2026-06-21 17:36:47.000000000 
+0200
@@ -2,12 +2,13 @@
 
 go 1.25.0
 
-toolchain go1.26.3
+toolchain go1.26.4
 
 require (
-       github.com/apricote/hcloud-upload-image/hcloudimages v1.4.0
+       github.com/apricote/hcloud-upload-image/hcloudimages/v2 v2.0.1
        github.com/hetznercloud/hcloud-go/v2 v2.40.0
        github.com/spf13/cobra v1.10.2
+       github.com/spf13/pflag v1.0.9
 )
 
 require (
@@ -21,7 +22,6 @@
        github.com/prometheus/common v0.66.1 // indirect
        github.com/prometheus/procfs v0.16.1 // indirect
        github.com/russross/blackfriday/v2 v2.1.0 // indirect
-       github.com/spf13/pflag v1.0.9 // indirect
        go.yaml.in/yaml/v2 v2.4.2 // indirect
        go.yaml.in/yaml/v3 v3.0.4 // indirect
        golang.org/x/crypto v0.51.0 // indirect
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/go.sum 
new/hcloud-upload-image-1.5.0/go.sum
--- old/hcloud-upload-image-1.4.0/go.sum        2026-05-14 20:52:38.000000000 
+0200
+++ new/hcloud-upload-image-1.5.0/go.sum        2026-06-21 17:36:47.000000000 
+0200
@@ -1,5 +1,5 @@
-github.com/apricote/hcloud-upload-image/hcloudimages v1.4.0 
h1:h9cxSQo8uYn7y5zLEOQF/GjLuXAqU9eVHt6vY/XIM1Y=
-github.com/apricote/hcloud-upload-image/hcloudimages v1.4.0/go.mod 
h1:9452aAvFQ28wGKZOMWS6djWvoQwqhbfUg6I2zEQIUXI=
+github.com/apricote/hcloud-upload-image/hcloudimages/v2 v2.0.1 
h1:IX6IfhGIfA7gw51KlyBCU25dJuDEWIAtkWM9/F8aHC4=
+github.com/apricote/hcloud-upload-image/hcloudimages/v2 v2.0.1/go.mod 
h1:OBnyHwXlvUFZPqptHRGjDTPM0DWHOOhym2RVp8X+q5s=
 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=
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/hcloudimages/CHANGELOG.md 
new/hcloud-upload-image-1.5.0/hcloudimages/CHANGELOG.md
--- old/hcloud-upload-image-1.4.0/hcloudimages/CHANGELOG.md     2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/CHANGELOG.md     2026-06-21 
17:36:47.000000000 +0200
@@ -1,5 +1,23 @@
 # Changelog
 
+## 
[2.0.1](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v2.0.0...hcloudimages/v2.0.1)
 (2026-06-17)
+
+
+### Bug Fixes
+
+* update hcloudimages module path for v2 
([#184](https://github.com/apricote/hcloud-upload-image/issues/184)) 
([f4fd485](https://github.com/apricote/hcloud-upload-image/commit/f4fd4854492fd53221293cd828db16305c5accf5))
+
+## 
[2.0.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.4.0...hcloudimages/v2.0.0)
 (2026-06-17)
+
+
+### ⚠ BREAKING CHANGES
+
+* new write-to-disk command 
([#178](https://github.com/apricote/hcloud-upload-image/issues/178))
+
+### Features
+
+* new write-to-disk command 
([#178](https://github.com/apricote/hcloud-upload-image/issues/178)) 
([d65441f](https://github.com/apricote/hcloud-upload-image/commit/d65441fc41e377e4a51c4b817383eb7167264c97)),
 closes [#157](https://github.com/apricote/hcloud-upload-image/issues/157)
+
 ## 
[1.4.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v1.3.0...hcloudimages/v1.4.0)
 (2026-05-14)
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/hcloudimages/client.go 
new/hcloud-upload-image-1.5.0/hcloudimages/client.go
--- old/hcloud-upload-image-1.4.0/hcloudimages/client.go        2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/client.go        2026-06-21 
17:36:47.000000000 +0200
@@ -13,12 +13,12 @@
        "github.com/hetznercloud/hcloud-go/v2/hcloud/exp/kit/sshutil"
        "golang.org/x/crypto/ssh"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
-       
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/actionutil"
-       "github.com/apricote/hcloud-upload-image/hcloudimages/internal/control"
-       
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/labelutil"
-       "github.com/apricote/hcloud-upload-image/hcloudimages/internal/randomid"
-       
"github.com/apricote/hcloud-upload-image/hcloudimages/internal/sshsession"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
+       
"github.com/apricote/hcloud-upload-image/hcloudimages/v2/internal/actionutil"
+       
"github.com/apricote/hcloud-upload-image/hcloudimages/v2/internal/control"
+       
"github.com/apricote/hcloud-upload-image/hcloudimages/v2/internal/labelutil"
+       
"github.com/apricote/hcloud-upload-image/hcloudimages/v2/internal/randomid"
+       
"github.com/apricote/hcloud-upload-image/hcloudimages/v2/internal/sshsession"
 )
 
 const (
@@ -49,7 +49,7 @@
        rescueSystemRootDiskSizeMB int64 = 960
 )
 
-type UploadOptions struct {
+type WriteOptions struct {
        // ImageURL must be publicly available. The instance will download the 
image from this endpoint.
        ImageURL *url.URL
 
@@ -65,9 +65,12 @@
        // Can be optionally set to make the client validate that the image can 
be written to the server.
        ImageSize int64
 
-       // Possible future additions:
-       // ImageSignatureVerification
-       // ImageLocalPath
+       // Server the image is written to.
+       Server *hcloud.Server
+}
+
+type UploadOptions struct {
+       WriteOptions
 
        // Architecture should match the architecture of the Image. This 
decides if the Snapshot can later be
        // used with [hcloud.ArchitectureX86] or [hcloud.ArchitectureARM] 
servers.
@@ -122,7 +125,7 @@
        // FormatQCOW2 allows to upload images in the qcow2 format directly.
        //
        // The qcow2 image must fit on the disk available in the rescue system. 
"qemu-img dd", which is used to convert
-       // qcow2 to raw, requires a file as an input. If 
[UploadOption.ImageSize] is set and FormatQCOW2 is used, there is a
+       // qcow2 to raw, requires a file as an input. If 
[WriteOptions.ImageSize] is set and FormatQCOW2 is used, there is a
        // warning message displayed if there is a high probability of issues.
        FormatQCOW2 Format = "qcow2"
 )
@@ -138,46 +141,58 @@
        c *hcloud.Client
 }
 
-// Upload the specified image into a snapshot on Hetzner Cloud.
-//
-// As the Hetzner Cloud API has no direct way to upload images, we create a 
temporary server,
-// overwrite the root disk and take a snapshot of that disk instead.
+// WriteToDisk writes the specified image onto the root disk of an existing 
server on Hetzner Cloud.
 //
-// The temporary server costs money. If the upload fails, we might be unable 
to delete the server. Check out
-// CleanupTempResources for a helper in this case.
-func (s *Client) Upload(ctx context.Context, options UploadOptions) 
(*hcloud.Image, error) {
+// The server will be rebooted multiple times and any existing data is lost.
+func (s *Client) WriteToDisk(ctx context.Context, options WriteOptions) error {
+       id, err := randomid.Generate()
+       if err != nil {
+               return err
+       }
+
        logger := contextlogger.From(ctx).With(
                "library", "hcloudimages",
-               "method", "upload",
+               "method", "write",
+               "run-id", id,
        )
+       ctx = contextlogger.New(ctx, logger)
 
-       id, err := randomid.Generate()
+       resourceName := resourcePrefix + id
+
+       // 1. Create SSH Key
+       key, privateKey, keyCleanup, err := s.generateSSHKey(ctx, 1, 
resourceName, DefaultLabels)
        if err != nil {
-               return nil, err
+               return err
        }
-       logger = logger.With("run-id", id)
-       // For simplicity, we use the name random name for SSH Key + Server
-       resourceName := resourcePrefix + id
-       labels := labelutil.Merge(DefaultLabels, options.Labels)
+       defer keyCleanup(false)
 
-       // 0. Validations
-       if options.ImageFormat == FormatQCOW2 && options.ImageSize > 0 {
-               if options.ImageSize > rescueSystemRootDiskSizeMB*1024*1024 {
-                       // Just a warning, because the size might change with 
time.
-                       // Alternatively one could add an override flag for the 
check and make this an error.
-                       logger.WarnContext(ctx,
-                               fmt.Sprintf("image must be smaller than %d MB 
(rescue system root disk) for qcow2", rescueSystemRootDiskSizeMB),
-                               "maximum-size", rescueSystemRootDiskSizeMB,
-                               "actual-size", options.ImageSize/(1024*1024),
-                       )
-               }
+       // 2. Power off Server
+       logger.InfoContext(ctx, "# Step 2: Shutting down server")
+       powerOffAction, _, err := s.c.Server.Poweroff(ctx, options.Server)
+       if err != nil {
+               return fmt.Errorf("stopping the server failed: %w", err)
        }
 
+       logger.DebugContext(ctx, "power off requested, waiting on action")
+
+       err = s.c.Action.WaitFor(ctx, powerOffAction)
+       if err != nil {
+               return fmt.Errorf("stopping the server failed: %w", err)
+       }
+       logger.DebugContext(ctx, "action finished, server is powered off")
+
+       // 3-8
+       return s.write(ctx, options, 3, key, privateKey)
+}
+
+func (s *Client) generateSSHKey(ctx context.Context, step int, resourceName 
string, labels map[string]string) (*hcloud.SSHKey, []byte, func(bool), error) {
+       logger := contextlogger.From(ctx)
+
        // 1. Create SSH Key
-       logger.InfoContext(ctx, "# Step 1: Generating SSH Key")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Generating SSH Key", 
step))
        privateKey, publicKey, err := sshutil.GenerateKeyPair()
        if err != nil {
-               return nil, fmt.Errorf("failed to generate temporary ssh key 
pair: %w", err)
+               return nil, nil, nil, fmt.Errorf("failed to generate temporary 
ssh key pair: %w", err)
        }
 
        key, _, err := s.c.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{
@@ -186,12 +201,12 @@
                Labels:    labels,
        })
        if err != nil {
-               return nil, fmt.Errorf("failed to submit temporary ssh key to 
API: %w", err)
+               return nil, nil, nil, fmt.Errorf("failed to submit temporary 
ssh key to API: %w", err)
        }
        logger.DebugContext(ctx, "Uploaded ssh key", "ssh-key-id", key.ID)
-       defer func() {
+       return key, privateKey, func(skipCleanup bool) {
                // Cleanup SSH Key
-               if options.DebugSkipResourceCleanup {
+               if skipCleanup {
                        logger.InfoContext(ctx, "Cleanup: Skipping cleanup of 
temporary ssh key")
                        return
                }
@@ -203,112 +218,64 @@
                        logger.WarnContext(ctx, "Cleanup: ssh key could not be 
deleted", "error", err)
                        // TODO
                }
-       }()
-
-       // 2. Create Server
-       logger.InfoContext(ctx, "# Step 2: Creating Server")
-       var serverType *hcloud.ServerType
-       if options.ServerType != nil {
-               serverType = options.ServerType
-       } else {
-               var ok bool
-               serverType, ok = serverTypePerArchitecture[options.Architecture]
-               if !ok {
-                       return nil, fmt.Errorf("unknown architecture %q, valid 
options: %q, %q", options.Architecture, hcloud.ArchitectureX86, 
hcloud.ArchitectureARM)
-               }
-       }
-
-       location := defaultLocation
-       if options.Location != nil {
-               location = options.Location
-       }
-
-       logger.DebugContext(ctx, "creating server with config",
-               "image", defaultImage.Name,
-               "location", location.Name,
-               "serverType", serverType.Name,
-       )
-       serverCreateResult, _, err := s.c.Server.Create(ctx, 
hcloud.ServerCreateOpts{
-               Name:       resourceName,
-               ServerType: serverType,
-
-               // Not used, but without this the user receives an email with a 
password for every created server
-               SSHKeys: []*hcloud.SSHKey{key},
-
-               // We need to enable rescue system first
-               StartAfterCreate: hcloud.Ptr(false),
-               // Image will never be booted, we only boot into rescue system
-               Image:    defaultImage,
-               Location: location,
-               Labels:   labels,
-       })
-       if err != nil {
-               return nil, fmt.Errorf("creating the temporary server failed: 
%w", err)
-       }
-       logger = logger.With("server", serverCreateResult.Server.ID)
-       logger.DebugContext(ctx, "Created Server")
-
-       logger.DebugContext(ctx, "waiting on actions")
-       err = s.c.Action.WaitFor(ctx, append(serverCreateResult.NextActions, 
serverCreateResult.Action)...)
-       if err != nil {
-               return nil, fmt.Errorf("creating the temporary server failed: 
%w", err)
-       }
-       logger.DebugContext(ctx, "actions finished")
-
-       server := serverCreateResult.Server
-       defer func() {
-               // Cleanup Server
-               if options.DebugSkipResourceCleanup {
-                       logger.InfoContext(ctx, "Cleanup: Skipping cleanup of 
temporary server")
-                       return
-               }
+       }, nil
+}
 
-               logger.InfoContext(ctx, "Cleanup: Deleting temporary server")
+// write is the internal utility function to actually write the image to a 
root disk. It expects a powered off server and returns a powered off server.
+func (s *Client) write(ctx context.Context, options WriteOptions, initialStep 
int, key *hcloud.SSHKey, privateKey []byte) error {
+       logger := contextlogger.From(ctx)
 
-               _, _, err := s.c.Server.DeleteWithResult(ctx, server)
-               if err != nil {
-                       logger.WarnContext(ctx, "Cleanup: server could not be 
deleted", "error", err)
+       // 0. Validations
+       if options.ImageFormat == FormatQCOW2 && options.ImageSize > 0 {
+               if options.ImageSize > rescueSystemRootDiskSizeMB*1024*1024 {
+                       // Just a warning, because the size might change with 
time.
+                       // Alternatively one could add an override flag for the 
check and make this an error.
+                       logger.WarnContext(ctx,
+                               fmt.Sprintf("image must be smaller than %d MB 
(rescue system root disk) for qcow2", rescueSystemRootDiskSizeMB),
+                               "maximum-size", rescueSystemRootDiskSizeMB,
+                               "actual-size", options.ImageSize/(1024*1024),
+                       )
                }
-       }()
+       }
 
        // 3. Activate Rescue System
-       logger.InfoContext(ctx, "# Step 3: Activating Rescue System")
-       enableRescueResult, _, err := s.c.Server.EnableRescue(ctx, server, 
hcloud.ServerEnableRescueOpts{
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Activating Rescue 
System", initialStep+0))
+       enableRescueResult, _, err := s.c.Server.EnableRescue(ctx, 
options.Server, hcloud.ServerEnableRescueOpts{
                Type:    defaultRescueType,
                SSHKeys: []*hcloud.SSHKey{key},
        })
        if err != nil {
-               return nil, fmt.Errorf("enabling the rescue system on the 
temporary server failed: %w", err)
+               return fmt.Errorf("enabling the rescue system on the temporary 
server failed: %w", err)
        }
 
        logger.DebugContext(ctx, "rescue system requested, waiting on action")
 
        err = s.c.Action.WaitFor(ctx, enableRescueResult.Action)
        if err != nil {
-               return nil, fmt.Errorf("enabling the rescue system on the 
temporary server failed: %w", err)
+               return fmt.Errorf("enabling the rescue system on the temporary 
server failed: %w", err)
        }
        logger.DebugContext(ctx, "action finished, rescue system enabled")
 
        // 4. Boot Server
-       logger.InfoContext(ctx, "# Step 4: Booting Server")
-       powerOnAction, _, err := s.c.Server.Poweron(ctx, server)
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Booting Server", 
initialStep+1))
+       powerOnAction, _, err := s.c.Server.Poweron(ctx, options.Server)
        if err != nil {
-               return nil, fmt.Errorf("starting the temporary server failed: 
%w", err)
+               return fmt.Errorf("starting the temporary server failed: %w", 
err)
        }
 
        logger.DebugContext(ctx, "boot requested, waiting on action")
 
        err = s.c.Action.WaitFor(ctx, powerOnAction)
        if err != nil {
-               return nil, fmt.Errorf("starting the temporary server failed: 
%w", err)
+               return fmt.Errorf("starting the temporary server failed: %w", 
err)
        }
        logger.DebugContext(ctx, "action finished, server is booting")
 
        // 5. Open SSH Session
-       logger.InfoContext(ctx, "# Step 5: Opening SSH Connection")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Opening SSH 
Connection", initialStep+2))
        signer, err := ssh.ParsePrivateKey(privateKey)
        if err != nil {
-               return nil, fmt.Errorf("parsing the automatically generated 
temporary private key failed: %w", err)
+               return fmt.Errorf("parsing the automatically generated 
temporary private key failed: %w", err)
        }
 
        sshClientConfig := &ssh.ClientConfig{
@@ -329,53 +296,158 @@
                100, // ~ 3 minutes
                func() error {
                        var err error
-                       logger.DebugContext(ctx, "trying to connect to server", 
"ip", server.PublicNet.IPv4.IP)
-                       sshClient, err = ssh.Dial("tcp", 
server.PublicNet.IPv4.IP.String()+":ssh", sshClientConfig)
+                       logger.DebugContext(ctx, "trying to connect to server", 
"ip", options.Server.PublicNet.IPv4.IP)
+                       sshClient, err = ssh.Dial("tcp", 
options.Server.PublicNet.IPv4.IP.String()+":ssh", sshClientConfig)
                        return err
                },
        )
        if err != nil {
-               return nil, fmt.Errorf("failed to ssh into temporary server: 
%w", err)
+               return fmt.Errorf("failed to ssh into temporary server: %w", 
err)
        }
        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")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Cleaning existing 
disk", initialStep+3))
 
-       output, err := sshsession.Run(sshClient, "blkdiscard /dev/sda", nil)
+       output, err := sshsession.Run(sshClient, "blkdiscard --force /dev/sda", 
nil)
        logger.DebugContext(ctx, string(output))
        if err != nil {
-               return nil, fmt.Errorf("failed to clean existing disk: %w", err)
+               return fmt.Errorf("failed to clean existing disk: %w", err)
        }
 
        // 7. SSH On Server: Download Image, Decompress, Write to Root Disk
-       logger.InfoContext(ctx, "# Step 7: Downloading image and writing to 
disk")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Downloading image and 
writing to disk", initialStep+4))
 
        cmd, err := assembleCommand(options)
        if err != nil {
-               return nil, err
+               return err
        }
 
        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")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Finished writing image 
to disk", initialStep+4))
        logger.DebugContext(ctx, string(output))
        if err != nil {
-               return nil, fmt.Errorf("failed to download and write the image: 
%w", err)
+               return fmt.Errorf("failed to download and write the image: %w", 
err)
        }
 
        // 8. SSH On Server: Shutdown
-       logger.InfoContext(ctx, "# Step 8: Shutting down server")
+       logger.InfoContext(ctx, fmt.Sprintf("# Step %d: Shutting down server", 
initialStep+5))
        _, 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)
        }
 
+       return nil
+}
+
+// Upload the specified image into a snapshot on Hetzner Cloud.
+//
+// As the Hetzner Cloud API has no direct way to upload images, we create a 
temporary server,
+// overwrite the root disk and take a snapshot of that disk instead.
+//
+// The temporary server costs money. If the upload fails, we might be unable 
to delete the server. Check out
+// CleanupTempResources for a helper in this case.
+func (s *Client) Upload(ctx context.Context, options UploadOptions) 
(*hcloud.Image, error) {
+       id, err := randomid.Generate()
+       if err != nil {
+               return nil, err
+       }
+
+       logger := contextlogger.From(ctx).With(
+               "library", "hcloudimages",
+               "method", "upload",
+               "run-id", id,
+       )
+       ctx = contextlogger.New(ctx, logger)
+
+       // For simplicity, we use the same random name for SSH Key + Server
+       resourceName := resourcePrefix + id
+       labels := labelutil.Merge(DefaultLabels, options.Labels)
+
+       key, privateKey, keyCleanup, err := s.generateSSHKey(ctx, 1, 
resourceName, labels)
+       if err != nil {
+               return nil, err
+       }
+       defer keyCleanup(options.DebugSkipResourceCleanup)
+
+       // 2. Create Server
+       logger.InfoContext(ctx, "# Step 2: Creating Server")
+       var serverType *hcloud.ServerType
+       if options.ServerType != nil {
+               serverType = options.ServerType
+       } else {
+               var ok bool
+               serverType, ok = serverTypePerArchitecture[options.Architecture]
+               if !ok {
+                       return nil, fmt.Errorf("unknown architecture %q, valid 
options: %q, %q", options.Architecture, hcloud.ArchitectureX86, 
hcloud.ArchitectureARM)
+               }
+       }
+
+       location := defaultLocation
+       if options.Location != nil {
+               location = options.Location
+       }
+
+       logger.DebugContext(ctx, "creating server with config",
+               "image", defaultImage.Name,
+               "location", location.Name,
+               "serverType", serverType.Name,
+       )
+       serverCreateResult, _, err := s.c.Server.Create(ctx, 
hcloud.ServerCreateOpts{
+               Name:       resourceName,
+               ServerType: serverType,
+
+               // Not used, but without this the user receives an email with a 
password for every created server
+               SSHKeys: []*hcloud.SSHKey{key},
+
+               // We need to enable rescue system first
+               StartAfterCreate: hcloud.Ptr(false),
+               // Image will never be booted, we only boot into rescue system
+               Image:    defaultImage,
+               Location: location,
+               Labels:   labels,
+       })
+       if err != nil {
+               return nil, fmt.Errorf("creating the temporary server failed: 
%w", err)
+       }
+       logger = logger.With("server", serverCreateResult.Server.ID)
+       logger.DebugContext(ctx, "Created Server")
+
+       logger.DebugContext(ctx, "waiting on actions")
+       err = s.c.Action.WaitFor(ctx, append(serverCreateResult.NextActions, 
serverCreateResult.Action)...)
+       if err != nil {
+               return nil, fmt.Errorf("creating the temporary server failed: 
%w", err)
+       }
+       logger.DebugContext(ctx, "actions finished")
+
+       options.Server = serverCreateResult.Server
+       defer func() {
+               // Cleanup Server
+               if options.DebugSkipResourceCleanup {
+                       logger.InfoContext(ctx, "Cleanup: Skipping cleanup of 
temporary server")
+                       return
+               }
+
+               logger.InfoContext(ctx, "Cleanup: Deleting temporary server")
+
+               _, _, err := s.c.Server.DeleteWithResult(ctx, options.Server)
+               if err != nil {
+                       logger.WarnContext(ctx, "Cleanup: server could not be 
deleted", "error", err)
+               }
+       }()
+
+       // Steps 3-8
+       err = s.write(ctx, options.WriteOptions, 3, key, privateKey)
+       if err != nil {
+               return nil, err
+       }
+
        // 9. Create Image from Server
        logger.InfoContext(ctx, "# Step 9: Creating Image")
-       createImageResult, _, err := s.c.Server.CreateImage(ctx, server, 
&hcloud.ServerCreateImageOpts{
+       createImageResult, _, err := s.c.Server.CreateImage(ctx, 
options.Server, &hcloud.ServerCreateImageOpts{
                Type:        hcloud.ImageTypeSnapshot,
                Description: options.Description,
                Labels:      labels,
@@ -520,7 +592,7 @@
        return nil
 }
 
-func assembleCommand(options UploadOptions) (string, error) {
+func assembleCommand(options WriteOptions) (string, error) {
        // Make sure that we fail early, ie. if the image url does not work
        cmd := "set -euo pipefail && "
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/hcloudimages/client_test.go 
new/hcloud-upload-image-1.5.0/hcloudimages/client_test.go
--- old/hcloud-upload-image-1.4.0/hcloudimages/client_test.go   2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/client_test.go   2026-06-21 
17:36:47.000000000 +0200
@@ -17,32 +17,32 @@
 func TestAssembleCommand(t *testing.T) {
        tests := []struct {
                name    string
-               options UploadOptions
+               options WriteOptions
                want    string
                wantErr bool
        }{
                {
                        name:    "local raw",
-                       options: UploadOptions{},
+                       options: WriteOptions{},
                        want:    "bash -c 'set -euo pipefail && dd of=/dev/sda 
bs=4M conv=sparse && sync'",
                },
                {
                        name: "remote raw",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                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 conv=sparse && 
sync'",
                },
                {
                        name: "local xz",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageCompression: CompressionXZ,
                        },
                        want: "bash -c 'set -euo pipefail && xz -cd | dd 
of=/dev/sda bs=4M conv=sparse && sync'",
                },
                {
                        name: "remote xz",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageURL:         
mustParseURL("https://example.com/image.xz";),
                                ImageCompression: CompressionXZ,
                        },
@@ -50,14 +50,14 @@
                },
                {
                        name: "local zstd",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageCompression: CompressionZSTD,
                        },
                        want: "bash -c 'set -euo pipefail && zstd -cd | dd 
of=/dev/sda bs=4M conv=sparse && sync'",
                },
                {
                        name: "remote zstd",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageURL:         
mustParseURL("https://example.com/image.zst";),
                                ImageCompression: CompressionZSTD,
                        },
@@ -65,14 +65,14 @@
                },
                {
                        name: "local bz2",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageCompression: CompressionBZ2,
                        },
                        want: "bash -c 'set -euo pipefail && bzip2 -cd | dd 
of=/dev/sda bs=4M conv=sparse && sync'",
                },
                {
                        name: "remote bz2",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageURL:         
mustParseURL("https://example.com/image.bz2";),
                                ImageCompression: CompressionBZ2,
                        },
@@ -80,14 +80,14 @@
                },
                {
                        name: "local qcow2",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                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{
+                       options: WriteOptions{
                                ImageURL:    
mustParseURL("https://example.com/image.qcow2";),
                                ImageFormat: FormatQCOW2,
                        },
@@ -96,7 +96,7 @@
 
                {
                        name: "unknown compression",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageCompression: "noodle",
                        },
                        wantErr: true,
@@ -104,7 +104,7 @@
 
                {
                        name: "unknown format",
-                       options: UploadOptions{
+                       options: WriteOptions{
                                ImageFormat: "poodle",
                        },
                        wantErr: true,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/hcloudimages/doc.go 
new/hcloud-upload-image-1.5.0/hcloudimages/doc.go
--- old/hcloud-upload-image-1.4.0/hcloudimages/doc.go   2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/doc.go   2026-06-21 
17:36:47.000000000 +0200
@@ -36,7 +36,7 @@
 //
 // By default, nothing is logged. As the update process takes a bit of time 
you might want to gain some insight into
 // the process. For this we provide optional logs through [log/slog]. You can 
set a [log/slog.Logger] in the
-// [context.Context] through 
[github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger.New].
+// [context.Context] through 
[github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger.New].
 //
 // [Hetzner Cloud website]: https://www.hetzner.com/cloud/
 package hcloudimages
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/hcloudimages/doc_test.go 
new/hcloud-upload-image-1.5.0/hcloudimages/doc_test.go
--- old/hcloud-upload-image-1.4.0/hcloudimages/doc_test.go      2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/doc_test.go      2026-06-21 
17:36:47.000000000 +0200
@@ -7,7 +7,7 @@
 
        "github.com/hetznercloud/hcloud-go/v2/hcloud"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2"
 )
 
 func ExampleClient_Upload() {
@@ -21,10 +21,12 @@
        }
 
        image, err := client.Upload(context.TODO(), hcloudimages.UploadOptions{
-               ImageURL:         imageURL,
-               ImageCompression: hcloudimages.CompressionBZ2,
-               Architecture:     hcloud.ArchitectureX86,
-               Location:         &hcloud.Location{Name: "nbg1"}, // Optional: 
defaults to fsn1
+               WriteOptions: hcloudimages.WriteOptions{
+                       ImageURL:         imageURL,
+                       ImageCompression: hcloudimages.CompressionBZ2,
+               },
+               Architecture: hcloud.ArchitectureX86,
+               Location:     &hcloud.Location{Name: "nbg1"}, // Optional: 
defaults to fsn1
        })
        if err != nil {
                panic(err)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/hcloud-upload-image-1.4.0/hcloudimages/go.mod 
new/hcloud-upload-image-1.5.0/hcloudimages/go.mod
--- old/hcloud-upload-image-1.4.0/hcloudimages/go.mod   2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/go.mod   2026-06-21 
17:36:47.000000000 +0200
@@ -1,8 +1,8 @@
-module github.com/apricote/hcloud-upload-image/hcloudimages
+module github.com/apricote/hcloud-upload-image/hcloudimages/v2
 
 go 1.25.0
 
-toolchain go1.26.3
+toolchain go1.26.4
 
 require (
        github.com/hetznercloud/hcloud-go/v2 v2.40.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/hcloudimages/internal/control/retry.go 
new/hcloud-upload-image-1.5.0/hcloudimages/internal/control/retry.go
--- old/hcloud-upload-image-1.4.0/hcloudimages/internal/control/retry.go        
2026-05-14 20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/hcloudimages/internal/control/retry.go        
2026-06-21 17:36:47.000000000 +0200
@@ -10,7 +10,7 @@
 
        "github.com/hetznercloud/hcloud-go/v2/hcloud"
 
-       "github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
+       "github.com/apricote/hcloud-upload-image/hcloudimages/v2/contextlogger"
 )
 
 // Retry executes f at most maxTries times.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/hcloud-upload-image-1.4.0/internal/version/version.go 
new/hcloud-upload-image-1.5.0/internal/version/version.go
--- old/hcloud-upload-image-1.4.0/internal/version/version.go   2026-05-14 
20:52:38.000000000 +0200
+++ new/hcloud-upload-image-1.5.0/internal/version/version.go   2026-06-21 
17:36:47.000000000 +0200
@@ -2,7 +2,7 @@
 
 var (
        // version is a semver version (https://semver.org).
-       version = "1.4.0" // x-release-please-version
+       version = "1.5.0" // x-release-please-version
 
        // versionPrerelease is a semver version pre-release identifier 
(https://semver.org).
        //

++++++ hcloud-upload-image.obsinfo ++++++
--- /var/tmp/diff_new_pack.MYCqvo/_old  2026-06-22 17:35:31.642547656 +0200
+++ /var/tmp/diff_new_pack.MYCqvo/_new  2026-06-22 17:35:31.646547797 +0200
@@ -1,5 +1,5 @@
 name: hcloud-upload-image
-version: 1.4.0
-mtime: 1778784758
-commit: 73af6a8a750a37d062bbb8e8dd4d09f17274f5a3
+version: 1.5.0
+mtime: 1782056207
+commit: 97083898f202471b7f7b96fa9417c1c0f1c025fc
 

++++++ vendor.tar.gz ++++++
++++ 1920 lines of diff (skipped)

Reply via email to