File hcloud-upload-image-1.1.0.obscpio of Package hcloud-upload-image
07070100000000000081A4000000000000000000000001681F464300000E9C000000000000000000000000000000000000002B00000000hcloud-upload-image-1.1.0/.goreleaser.yaml# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
before:
hooks:
- ./scripts/completions.sh
builds:
- id: default
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
mod_timestamp: "{{ .CommitTimestamp }}"
flags:
- -trimpath
ldflags:
- -X {{ .ModulePath }}/internal/version.version={{ .Version }}
- -X {{ .ModulePath }}/internal/version.versionPrerelease=
archives:
- formats: [ "tar.gz" ]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [ "zip" ]
files:
- README.md
- LICENSE
- completions/*
nfpms:
- id: default
file_name_template: "{{ .ConventionalFileName }}"
package_name: hcloud-upload-image
vendor: Julian Tölle
homepage: https://github.com/apricote/hcloud-upload-image
maintainer: Julian Tölle <julian.toelle97@gmail.com>
formats:
- deb
- rpm
- apk
description: Manage custom OS images on Hetzner Cloud.
license: MIT
dependencies:
- openssh
recommends:
- hcloud-cli
contents:
- src: ./completions/hcloud-upload-image.bash
dst: /usr/share/bash-completion/completions/hcloud-upload-image
file_info:
mode: 0644
- src: ./completions/hcloud-upload-image.fish
dst: /usr/share/fish/vendor_completions.d/hcloud-upload-image.fish
file_info:
mode: 0644
- src: ./completions/hcloud-upload-image.zsh
dst: /usr/share/zsh/vendor-completions/_hcloud-upload-image
file_info:
mode: 0644
- src: ./LICENSE
dst: /usr/share/doc/hcloud-upload-image/license
file_info:
mode: 0644
aurs:
- name: hcloud-upload-image-bin
homepage: "https://github.com/apricote/hcloud-upload-image"
description: Manage custom OS images on Hetzner Cloud.
maintainers:
- "Julian Tölle <julian.toelle97@gmail.com>"
license: MIT
private_key: "{{ .Env.AUR_SSH_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/hcloud-upload-image-bin.git"
depends:
- openssh
package: |-
# bin
install -Dm755 "./hcloud-upload-image" "${pkgdir}/usr/bin/hcloud-upload-image"
# license
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/hcloud-upload-image/LICENSE"
# completions
mkdir -p "${pkgdir}/usr/share/bash-completion/completions/"
mkdir -p "${pkgdir}/usr/share/zsh/site-functions/"
mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/"
install -Dm644 "./completions/hcloud-upload-image.bash" "${pkgdir}/usr/share/bash-completion/completions/hcloud-upload-image"
install -Dm644 "./completions/hcloud-upload-image.zsh" "${pkgdir}/usr/share/zsh/site-functions/_hcloud-upload-image"
install -Dm644 "./completions/hcloud-upload-image.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/hcloud-upload-image.fish"
kos:
- id: container-images
build: default
repositories:
- ghcr.io/apricote
platforms:
- linux/amd64
- linux/arm64
base_import_paths: true
labels:
org.opencontainers.image.source: https://github.com/apricote/hcloud-upload-image
tags:
- latest
- "{{.Tag}}"
snapshot:
version_template: "{{ .Version }}-dev+{{ .ShortCommit }}"
changelog:
# Generated by release-please
disable: true
07070100000001000081A4000000000000000000000001681F4643000016B4000000000000000000000000000000000000002700000000hcloud-upload-image-1.1.0/CHANGELOG.md# 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)
### Features
* **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))
* docs website ([#80](https://github.com/apricote/hcloud-upload-image/issues/80)) ([d144b85](https://github.com/apricote/hcloud-upload-image/commit/d144b85e3dfd933e8fbb09a0e6f5acacb4d05bea))
* publish container image ([#82](https://github.com/apricote/hcloud-upload-image/issues/82)) ([91df729](https://github.com/apricote/hcloud-upload-image/commit/91df729f1cfd636355fc8338f47aefa4ab8b3b84))
* upload qcow2 images ([#69](https://github.com/apricote/hcloud-upload-image/issues/69)) ([ac3e9dd](https://github.com/apricote/hcloud-upload-image/commit/ac3e9dd7ecd86d1538b6401c3073c7c078c40847))
## [0.3.1](https://github.com/apricote/hcloud-upload-image/compare/v0.3.0...v0.3.1) (2024-12-07)
### Bug Fixes
* **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))
## [0.3.0](https://github.com/apricote/hcloud-upload-image/compare/v0.2.1...v0.3.0) (2024-06-23)
### Features
* 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)
* 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))
### Bug Fixes
* 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)
## [0.2.1](https://github.com/apricote/hcloud-upload-image/compare/v0.2.0...v0.2.1) (2024-05-10)
### Bug Fixes
* **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))
## [0.2.0](https://github.com/apricote/hcloud-upload-image/compare/v0.1.1...v0.2.0) (2024-05-09)
### Features
* 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))
* upload local disk images ([#15](https://github.com/apricote/hcloud-upload-image/issues/15)) ([fcea3e3](https://github.com/apricote/hcloud-upload-image/commit/fcea3e3c6e5ba7383aa69838401903e3f54f910c))
* upload xz compressed images ([#16](https://github.com/apricote/hcloud-upload-image/issues/16)) ([1c943e4](https://github.com/apricote/hcloud-upload-image/commit/1c943e4480ba2042fc3feabf363ec88eb2efbaee))
### Bug Fixes
* 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))
## [0.1.1](https://github.com/apricote/hcloud-upload-image/compare/v0.1.0...v0.1.1) (2024-05-04)
### Bug Fixes
* 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))
## [0.1.0](https://github.com/apricote/hcloud-upload-image/compare/v0.0.1...v0.1.0) (2024-05-04)
### Features
* **cli:** docs grouping and version ([847b696](https://github.com/apricote/hcloud-upload-image/commit/847b696c74ce67c2f18aaa69af60f6c0c5b736c4))
* **cli:** hide redundant log attributes ([9e65452](https://github.com/apricote/hcloud-upload-image/commit/9e654521ae12debf40f181dfe291ad4ded0f7524))
* **cli:** upload command ([b6ae95f](https://github.com/apricote/hcloud-upload-image/commit/b6ae95f55ba134f5ef124d377ed3ad0a556b8cf4))
* documentation and cleanup command ([c9ab40b](https://github.com/apricote/hcloud-upload-image/commit/c9ab40b539bc51ea2611bb0b58ab8aef4ec06eea))
* initial library code ([4f57df5](https://github.com/apricote/hcloud-upload-image/commit/4f57df5b66ed1391155792758737b8f54b7ef2ab))
* log output ([904e5e0](https://github.com/apricote/hcloud-upload-image/commit/904e5e0bed6ba87e0f4063c27a0678a9c85b7371))
07070100000002000081A4000000000000000000000001681F464300000421000000000000000000000000000000000000002200000000hcloud-upload-image-1.1.0/LICENSECopyright (c) 2024 Julian Tölle
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
07070100000003000081A4000000000000000000000001681F4643000011AB000000000000000000000000000000000000002400000000hcloud-upload-image-1.1.0/README.md# `hcloud-upload-image`
<p align="center">
Quickly upload any raw disk images into your <a href="https://hetzner.com/cloud" target="_blank">Hetzner Cloud</a> projects!
</p>
<p align="center">
<a href="https://apricote.github.io/hcloud-upload-image" target="_blank"><img src="https://img.shields.io/badge/Documentation-brightgreen?style=flat-square" alt="Badge: Documentation"/></a>
<a href="https://github.com/apricote/hcloud-upload-image/releases" target="_blank"><img src="https://img.shields.io/github/v/release/apricote/hcloud-upload-image?sort=semver&display_name=release&style=flat-square&color=green" alt="Badge: Stable Release"/></a>
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="Badge: License MIT"/>
</p>
## About
The [Hetzner Cloud API](https://docs.hetzner.cloud/) does not support uploading disk images directly, and it only
provides a limited set of default images. The only option for custom disk images that users have is by taking a
"snapshot" of an existing servers root disk. These can then be used to create new servers.
To create a completely custom disk image, users have to follow these steps:
1. Create server with the correct server type
2. Enable rescue system for the server
3. Boot the server
4. Download the disk image from within the rescue system
5. Write disk image to servers root disk
6. Shut down the server
7. Take a snapshot of the servers root disk
8. Delete the server
This is an annoyingly long process. Many users have automated this with [Packer](https://www.packer.io/) &
[`packer-plugin-hcloud`](https://github.com/hetznercloud/packer-plugin-hcloud/) before, but Packer offers a lot of
additional complexity to wrap your head around.
This repository provides a simple CLI tool & Go library to do the above.
## Getting Started
### CLI
#### Binary
We provide pre-built `deb`, `rpm` and `apk` packages. Alternatively we also provide the binaries directly.
Check out the [GitHub release artifacts](https://github.com/apricote/hcloud-upload-image/releases/latest) for all of these files and archives.
##### Arch Linux
You can get [`hcloud-upload-image-bin`](https://aur.archlinux.org/packages/hcloud-upload-image-bin) from the AUR.
Use your preferred wrapper to install:
```shell
yay -S hcloud-upload-image-bin
```
#### `go install`
If you already have a recent Go toolchain installed, you can build & install the binary from source:
```shell
go install github.com/apricote/hcloud-upload-image@latest
```
#### Docker
There is a docker image published at `ghcr.io/apricote/hcloud-upload-image`.
```shell
docker run --rm -e HCLOUD_TOKEN="<your token>" ghcr.io/apricote/hcloud-upload-image:latest <command>
```
#### Usage
```shell
export HCLOUD_TOKEN="<your token>"
hcloud-upload-image upload \
--image-url "https://example.com/disk-image-x86.raw.bz2" \
--architecture x86 \
--compression bz2
```
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).:
```shell
hcloud-upload-image --help
hcloud-upload-image upload --help
hcloud-upload-image cleanup --help
```
### Go Library
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.
#### Install
```shell
go get github.com/apricote/hcloud-upload-image/hcloudimages
```
#### Usages
```go
package main
import (
"context"
"fmt"
"net/url"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/apricote/hcloud-upload-image/hcloudimages"
)
func main() {
client := hcloudimages.NewClient(
hcloud.NewClient(hcloud.WithToken("<your token>")),
)
imageURL, err := url.Parse("https://example.com/disk-image-x86.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)
}
```
## Contributing
If you have any questions, feedback or ideas, feel free to open an issue or pull request.
## License
This project is licensed under the MIT license, unless the file explicitly specifies another license.
## Support Disclaimer
This is not an official Hetzner Cloud product in any way and Hetzner Cloud does not provide support for this.
07070100000004000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000001E00000000hcloud-upload-image-1.1.0/cmd07070100000005000081A4000000000000000000000001681F464300000555000000000000000000000000000000000000002900000000hcloud-upload-image-1.1.0/cmd/cleanup.gopackage cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
)
// cleanupCmd represents the cleanup command
var cleanupCmd = &cobra.Command{
Use: "cleanup",
Short: "Remove any temporary resources that were left over",
Long: `If the upload fails at any point, there might still exist a server or
ssh key in your Hetzner Cloud project. This command cleans up any resources
that match the label "apricote.de/created-by=hcloud-upload-image".
If you want to see a preview of what would be removed, you can use the official hcloud CLI and run:
$ hcloud server list -l apricote.de/created-by=hcloud-upload-image
$ hcloud ssh-key list -l apricote.de/created-by=hcloud-upload-image
This command does not handle any parallel executions of hcloud-upload-image
and will remove in-use resources if called at the same time.`,
DisableAutoGenTag: true,
GroupID: "primary",
PreRun: initClient,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
logger := contextlogger.From(ctx)
err := client.CleanupTempResources(ctx)
if err != nil {
return fmt.Errorf("failed to clean up temporary resources: %w", err)
}
logger.InfoContext(ctx, "Successfully cleaned up all temporary resources!")
return nil
},
}
func init() {
RootCmd.AddCommand(cleanupCmd)
}
07070100000006000081A4000000000000000000000001681F464300000B84000000000000000000000000000000000000002600000000hcloud-upload-image-1.1.0/cmd/root.gopackage cmd
import (
"log/slog"
"os"
"time"
"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/internal/ui"
"github.com/apricote/hcloud-upload-image/internal/version"
)
const (
flagVerbose = "verbose"
)
var (
// 1 activates slog debug output
// 2 activates hcloud-go debug output
verbose int
)
// The pre-authenticated client. Set in the root command PersistentPreRun
var client *hcloudimages.Client
// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "hcloud-upload-image",
Short: `Manage custom OS images on Hetzner Cloud.`,
Long: `Manage custom OS images on Hetzner Cloud.`,
SilenceUsage: true,
DisableAutoGenTag: true,
Version: version.Version,
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()
slog.SetDefault(initLogger())
// Add logger to command context
logger := slog.Default()
ctx = contextlogger.New(ctx, logger)
cmd.SetContext(ctx)
},
}
func initLogger() *slog.Logger {
logLevel := slog.LevelInfo
if verbose >= 1 {
logLevel = slog.LevelDebug
}
return slog.New(ui.NewHandler(os.Stdout, &ui.HandlerOptions{
Level: logLevel,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Remove attributes that are unnecessary for the cli context
if a.Key == "library" || a.Key == "method" {
return slog.Attr{}
}
return a
},
}))
}
func initClient(cmd *cobra.Command, _ []string) {
if client != nil {
// Only init if not set.
// Theoretically this is not safe against data races and should use [sync.Once], but :shrug:
return
}
ctx := cmd.Context()
logger := contextlogger.From(ctx)
// Build hcloud-go client
if os.Getenv("HCLOUD_TOKEN") == "" {
logger.ErrorContext(ctx, "You need to set the HCLOUD_TOKEN environment variable to your Hetzner Cloud API Token.")
os.Exit(1)
}
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})}),
}
if os.Getenv("HCLOUD_DEBUG") != "" || verbose >= 2 {
opts = append(opts, hcloud.WithDebugWriter(os.Stderr))
}
client = hcloudimages.NewClient(hcloud.NewClient(opts...))
}
func Execute() {
err := RootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
RootCmd.SetErrPrefix("\033[1;31mError:")
RootCmd.PersistentFlags().CountVarP(&verbose, flagVerbose, "v", "verbose debug output, can be specified up to 2 times")
RootCmd.AddGroup(&cobra.Group{
ID: "primary",
Title: "Primary Commands:",
})
}
07070100000007000081A4000000000000000000000001681F464300001627000000000000000000000000000000000000002800000000hcloud-upload-image-1.1.0/cmd/upload.gopackage cmd
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"
)
const (
uploadFlagImageURL = "image-url"
uploadFlagImagePath = "image-path"
uploadFlagCompression = "compression"
uploadFlagFormat = "format"
uploadFlagArchitecture = "architecture"
uploadFlagServerType = "server-type"
uploadFlagDescription = "description"
uploadFlagLabels = "labels"
)
//go:embed upload.md
var longDescription 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,
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`,
DisableAutoGenTag: true,
GroupID: "primary",
PreRun: initClient,
RunE: func(cmd *cobra.Command, args []string) error {
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)
architecture, _ := cmd.Flags().GetString(uploadFlagArchitecture)
serverType, _ := cmd.Flags().GetString(uploadFlagServerType)
description, _ := cmd.Flags().GetString(uploadFlagDescription)
labels, _ := cmd.Flags().GetStringToString(uploadFlagLabels)
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
}
if architecture != "" {
options.Architecture = hcloud.Architecture(architecture)
} else if serverType != "" {
options.ServerType = &hcloud.ServerType{Name: serverType}
}
image, err := client.Upload(ctx, options)
if err != nil {
return fmt.Errorf("failed to upload the image: %w", err)
}
logger.InfoContext(ctx, "Successfully uploaded the image!", "image", image.ID)
return nil
},
}
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]")
_ = uploadCmd.RegisterFlagCompletionFunc(
uploadFlagCompression,
cobra.FixedCompletions([]string{string(hcloudimages.CompressionBZ2), string(hcloudimages.CompressionXZ)}, cobra.ShellCompDirectiveNoFileComp),
)
uploadCmd.Flags().String(uploadFlagFormat, "", "Format of the image. [choices: qcow2]")
_ = uploadCmd.RegisterFlagCompletionFunc(
uploadFlagFormat,
cobra.FixedCompletions([]string{string(hcloudimages.FormatQCOW2)}, cobra.ShellCompDirectiveNoFileComp),
)
uploadCmd.Flags().String(uploadFlagArchitecture, "", "CPU architecture of the disk image [choices: x86, arm]")
_ = uploadCmd.RegisterFlagCompletionFunc(
uploadFlagArchitecture,
cobra.FixedCompletions([]string{string(hcloud.ArchitectureX86), string(hcloud.ArchitectureARM)}, cobra.ShellCompDirectiveNoFileComp),
)
uploadCmd.Flags().String(uploadFlagServerType, "", "Explicitly use this server type to generate the image. Mutually exclusive with --architecture.")
// Only one of them needs to be set
uploadCmd.MarkFlagsOneRequired(uploadFlagArchitecture, uploadFlagServerType)
uploadCmd.MarkFlagsMutuallyExclusive(uploadFlagArchitecture, uploadFlagServerType)
uploadCmd.Flags().String(uploadFlagDescription, "", "Description for the resulting image")
uploadCmd.Flags().StringToString(uploadFlagLabels, map[string]string{}, "Labels for the resulting image")
}
07070100000008000081A4000000000000000000000001681F46430000022B000000000000000000000000000000000000002800000000hcloud-upload-image-1.1.0/cmd/upload.mdThis command implements a fake "upload", by going through a real server and
snapshots. This does cost a bit of money for the server.
#### 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.
07070100000009000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000001F00000000hcloud-upload-image-1.1.0/docs0707010000000A000081A4000000000000000000000001681F46430000000C000000000000000000000000000000000000002A00000000hcloud-upload-image-1.1.0/docs/.gitignorebook
https:
0707010000000B000081A4000000000000000000000001681F4643000002F7000000000000000000000000000000000000002A00000000hcloud-upload-image-1.1.0/docs/SUMMARY.md# Summary
[Introduction](introduction.md)
# Guides
- [Uploading Images](guides/README.md)
- [Fedora CoreOS ↗](https://docs.fedoraproject.org/en-US/fedora-coreos/provisioning-hetzner/#_creating_a_snapshot)
- [Flatcar Container Linux ↗](https://www.flatcar.org/docs/latest/installing/cloud/hetzner/#building-the-snapshots-1)
- [Talos Linux ↗](https://www.talos.dev/v1.10/talos-guides/install/cloud-platforms/hetzner/#hcloud-upload-image)
# Reference
- [CLI](reference/cli/hcloud-upload-image.md)
- [`upload`](reference/cli/hcloud-upload-image_upload.md)
- [`cleanup`](reference/cli/hcloud-upload-image_cleanup.md)
- [Go Library](reference/go-library.md)
---
[Changelog CLI](changelog.md)
[Changelog Go Library](changelog-hcloudimages.md)
0707010000000C000081A4000000000000000000000001681F4643000000AA000000000000000000000000000000000000002900000000hcloud-upload-image-1.1.0/docs/book.toml[book]
language = "en"
multilingual = false
src = "."
title = "hcloud-upload-image"
[output.html]
git-repository-url = "https://github.com/apricote/hcloud-upload-image"
0707010000000D000081A4000000000000000000000001681F464300000043000000000000000000000000000000000000003900000000hcloud-upload-image-1.1.0/docs/changelog-hcloudimages.md# Changelog Library
{{#include ../hcloudimages/CHANGELOG.md:2: }}
0707010000000E000081A4000000000000000000000001681F464300000032000000000000000000000000000000000000002C00000000hcloud-upload-image-1.1.0/docs/changelog.md# Changelog CLI
{{#include ../CHANGELOG.md:2: }}
0707010000000F000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002600000000hcloud-upload-image-1.1.0/docs/guides07070100000010000081A4000000000000000000000001681F4643000001C9000000000000000000000000000000000000003000000000hcloud-upload-image-1.1.0/docs/guides/README.md# Uploading Images
Check out these docs from other projects to learn how to use `hcloud-upload-image`:
- [Fedora CoreOS ↗](https://docs.fedoraproject.org/en-US/fedora-coreos/provisioning-hetzner/#_creating_a_snapshot)
- [Flatcar Container Linux ↗](https://www.flatcar.org/docs/latest/installing/cloud/hetzner/#building-the-snapshots-1)
- [Talos Linux ↗](https://www.talos.dev/v1.10/talos-guides/install/cloud-platforms/hetzner/#hcloud-upload-image)
07070100000011000081A4000000000000000000000001681F46430000002C000000000000000000000000000000000000002F00000000hcloud-upload-image-1.1.0/docs/introduction.md# Introduction
{{#include ../README.md:2:}}07070100000012000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002900000000hcloud-upload-image-1.1.0/docs/reference07070100000013000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002D00000000hcloud-upload-image-1.1.0/docs/reference/cli07070100000014000081A4000000000000000000000001681F46430000021B000000000000000000000000000000000000004400000000hcloud-upload-image-1.1.0/docs/reference/cli/hcloud-upload-image.md## hcloud-upload-image
Manage custom OS images on Hetzner Cloud.
### Synopsis
Manage custom OS images on Hetzner Cloud.
### Options
```
-h, --help help for hcloud-upload-image
-v, --verbose count verbose debug output, can be specified up to 2 times
```
### SEE ALSO
* [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.
07070100000015000081A4000000000000000000000001681F464300000408000000000000000000000000000000000000004C00000000hcloud-upload-image-1.1.0/docs/reference/cli/hcloud-upload-image_cleanup.md## hcloud-upload-image cleanup
Remove any temporary resources that were left over
### Synopsis
If the upload fails at any point, there might still exist a server or
ssh key in your Hetzner Cloud project. This command cleans up any resources
that match the label "apricote.de/created-by=hcloud-upload-image".
If you want to see a preview of what would be removed, you can use the official hcloud CLI and run:
$ hcloud server list -l apricote.de/created-by=hcloud-upload-image
$ hcloud ssh-key list -l apricote.de/created-by=hcloud-upload-image
This command does not handle any parallel executions of hcloud-upload-image
and will remove in-use resources if called at the same time.
```
hcloud-upload-image cleanup [flags]
```
### Options
```
-h, --help help for cleanup
```
### 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.
07070100000016000081A4000000000000000000000001681F4643000008AF000000000000000000000000000000000000004B00000000hcloud-upload-image-1.1.0/docs/reference/cli/hcloud-upload-image_upload.md## hcloud-upload-image upload
Upload the specified disk image into your Hetzner Cloud project.
### Synopsis
This command implements a fake "upload", by going through a real server and
snapshots. This does cost a bit of money for the server.
#### 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 upload (--image-path=<local-path> | --image-url=<url>) --architecture=<x86|arm> [flags]
```
### Examples
```
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
```
### Options
```
--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]
--description string Description for the resulting image
--format string Format of the image. [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
--labels stringToString Labels for the resulting image (default [])
--server-type string Explicitly use this server type to generate the image. Mutually exclusive with --architecture.
```
### 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.
07070100000017000081A4000000000000000000000001681F4643000000C0000000000000000000000000000000000000003700000000hcloud-upload-image-1.1.0/docs/reference/go-library.md# Go Library
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).
07070100000018000081A4000000000000000000000001681F464300000465000000000000000000000000000000000000002100000000hcloud-upload-image-1.1.0/go.modmodule github.com/apricote/hcloud-upload-image
go 1.23.0
toolchain go1.24.3
require (
github.com/apricote/hcloud-upload-image/hcloudimages v1.1.0
github.com/hetznercloud/hcloud-go/v2 v2.21.0
github.com/spf13/cobra v1.9.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
07070100000019000081A4000000000000000000000001681F4643000015BF000000000000000000000000000000000000002100000000hcloud-upload-image-1.1.0/go.sumgithub.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/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=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hetznercloud/hcloud-go/v2 v2.21.0 h1:wUpQT+fgAxIcdMtFvuCJ78ziqc/VARubpOQPQyj4Q84=
github.com/hetznercloud/hcloud-go/v2 v2.21.0/go.mod h1:WSM7w+9tT86sJTNcF8a/oHljC3HUmQfcLxYsgx6PpSc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
0707010000001A000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002700000000hcloud-upload-image-1.1.0/hcloudimages0707010000001B000081A4000000000000000000000001681F464300000AE1000000000000000000000000000000000000003400000000hcloud-upload-image-1.1.0/hcloudimages/CHANGELOG.md# 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)
### Features
* upload qcow2 images ([#69](https://github.com/apricote/hcloud-upload-image/issues/69)) ([ac3e9dd](https://github.com/apricote/hcloud-upload-image/commit/ac3e9dd7ecd86d1538b6401c3073c7c078c40847))
## [0.3.1](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.3.0...hcloudimages/v0.3.1) (2024-12-07)
### Bug Fixes
* **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))
## [0.3.0](https://github.com/apricote/hcloud-upload-image/compare/hcloudimages/v0.2.0...hcloudimages/v0.3.0) (2024-06-23)
### Features
* 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)
* 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))
### Bug Fixes
* 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)
0707010000001C000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002F00000000hcloud-upload-image-1.1.0/hcloudimages/backoff0707010000001D000081A4000000000000000000000001681F464300000381000000000000000000000000000000000000003A00000000hcloud-upload-image-1.1.0/hcloudimages/backoff/backoff.go// SPDX-License-Identifier: MPL-2.0
// From https://github.com/hetznercloud/terraform-provider-hcloud/blob/v1.46.1/internal/control/retry.go
// Copyright (c) Hetzner Cloud GmbH
package backoff
import (
"math"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
)
// ExponentialBackoffWithLimit returns a [hcloud.BackoffFunc] which implements an exponential
// backoff.
// 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
if current > limit {
return limit
} else {
return current
}
}
}
0707010000001E000081A4000000000000000000000001681F4643000044E7000000000000000000000000000000000000003100000000hcloud-upload-image-1.1.0/hcloudimages/client.gopackage hcloudimages
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"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"
)
const (
CreatedByLabel = "apricote.de/created-by"
CreatedByValue = "hcloud-upload-image"
resourcePrefix = "hcloud-upload-image-"
)
var (
DefaultLabels = map[string]string{
CreatedByLabel: CreatedByValue,
}
serverTypePerArchitecture = map[hcloud.Architecture]*hcloud.ServerType{
hcloud.ArchitectureX86: {Name: "cx22"},
hcloud.ArchitectureARM: {Name: "cax11"},
}
defaultImage = &hcloud.Image{Name: "ubuntu-24.04"}
defaultLocation = &hcloud.Location{Name: "fsn1"}
defaultRescueType = hcloud.ServerRescueTypeLinux64
defaultSSHDialTimeout = 1 * time.Minute
// Size observed on x86, 2025-05-03, no idea if that changes.
// Might be able to extends this to more of the available memory.
rescueSystemRootDiskSizeMB int64 = 960
)
type UploadOptions struct {
// ImageURL must be publicly available. The instance will download the image from this endpoint.
ImageURL *url.URL
// ImageReader
ImageReader io.Reader
// ImageCompression describes the compression of the referenced image file. It defaults to [CompressionNone]. If
// set to anything else, the file will be decompressed before written to the disk.
ImageCompression Compression
ImageFormat Format
// 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
// Architecture should match the architecture of the Image. This decides if the Snapshot can later be
// used with [hcloud.ArchitectureX86] or [hcloud.ArchitectureARM] servers.
//
// Internally this decides what server type is used for the temporary server.
//
// Optional if [UploadOptions.ServerType] is set.
Architecture hcloud.Architecture
// ServerType can be optionally set to override the default server type for the architecture.
// Situations where this makes sense:
//
// - Your image is larger than the root disk of the default server types.
// - The default server type is no longer available, or not temporarily out of stock.
ServerType *hcloud.ServerType
// Description is an optional description that the resulting image (snapshot) will have. There is no way to
// select images by its description, you should use Labels if you need to identify your image later.
Description *string
// Labels will be added to the resulting image (snapshot). Use these to filter the image list if you
// need to identify the image later on.
//
// We also always add a label `apricote.de/created-by=hcloud-image-upload` ([CreatedByLabel], [CreatedByValue]).
Labels map[string]string
// DebugSkipResourceCleanup will skip the cleanup of the temporary SSH Key and Server.
DebugSkipResourceCleanup bool
}
type Compression string
const (
CompressionNone Compression = ""
CompressionBZ2 Compression = "bz2"
CompressionXZ Compression = "xz"
// Possible future additions:
// zip,zstd
)
type Format string
const (
FormatRaw Format = ""
// 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
// warning message displayed if there is a high probability of issues.
FormatQCOW2 Format = "qcow2"
)
// NewClient instantiates a new client. It requires a working [*hcloud.Client] to interact with the Hetzner Cloud API.
func NewClient(c *hcloud.Client) *Client {
return &Client{
c: c,
}
}
type Client struct {
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.
//
// The temporary server costs money. If the upload fails, we might be unable to delete the server. Check out
// CleanupTempResources for a helper in this case.
func (s *Client) Upload(ctx context.Context, options UploadOptions) (*hcloud.Image, error) {
logger := contextlogger.From(ctx).With(
"library", "hcloudimages",
"method", "upload",
)
id, err := randomid.Generate()
if err != nil {
return nil, 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)
// 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),
)
}
}
// 1. Create SSH Key
logger.InfoContext(ctx, "# Step 1: Generating SSH Key")
privateKey, publicKey, err := sshutil.GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate temporary ssh key pair: %w", err)
}
key, _, err := s.c.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{
Name: resourceName,
PublicKey: string(publicKey),
Labels: labels,
})
if err != nil {
return 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() {
// Cleanup SSH Key
if options.DebugSkipResourceCleanup {
logger.InfoContext(ctx, "Cleanup: Skipping cleanup of temporary ssh key")
return
}
logger.InfoContext(ctx, "Cleanup: Deleting temporary ssh key")
_, err := s.c.SSHKey.Delete(ctx, key)
if err != nil {
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)
}
}
logger.DebugContext(ctx, "creating server with config",
"image", defaultImage.Name,
"location", defaultLocation.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: defaultLocation,
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
}
logger.InfoContext(ctx, "Cleanup: Deleting temporary server")
_, _, err := s.c.Server.DeleteWithResult(ctx, server)
if err != nil {
logger.WarnContext(ctx, "Cleanup: server could not be deleted", "error", err)
}
}()
// 3. Activate Rescue System
logger.InfoContext(ctx, "# Step 3: Activating Rescue System")
enableRescueResult, _, err := s.c.Server.EnableRescue(ctx, 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)
}
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)
}
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)
if err != nil {
return nil, 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)
}
logger.DebugContext(ctx, "action finished, server is booting")
// 5. Open SSH Session
logger.InfoContext(ctx, "# Step 5: Opening SSH Connection")
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("parsing the automatically generated temporary private key failed: %w", err)
}
sshClientConfig := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
// There is no way to get the host key of the rescue system beforehand
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: defaultSSHDialTimeout,
}
// the server needs some time until its properly started and ssh is available
var sshClient *ssh.Client
err = control.Retry(
contextlogger.New(ctx, logger.With("operation", "ssh")),
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)
return err
},
)
if err != nil {
return nil, 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")
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)
}
// 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
}
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.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")
_, 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")
createImageResult, _, err := s.c.Server.CreateImage(ctx, server, &hcloud.ServerCreateImageOpts{
Type: hcloud.ImageTypeSnapshot,
Description: options.Description,
Labels: labels,
})
if err != nil {
return nil, fmt.Errorf("failed to create snapshot: %w", err)
}
logger.DebugContext(ctx, "image creation requested, waiting on action")
err = s.c.Action.WaitFor(ctx, createImageResult.Action)
if err != nil {
return nil, fmt.Errorf("failed to create snapshot: %w", err)
}
logger.DebugContext(ctx, "action finished, image was created")
image := createImageResult.Image
logger.InfoContext(ctx, "# Image was created", "image", image.ID)
// Resource cleanup is happening in `defer`
return image, nil
}
// CleanupTempResources tries to delete any resources that were left over from previous calls to [Client.Upload].
// Upload tries to clean up any temporary resources it created at runtime, but might fail at any point.
// You can then use this command to make sure that all temporary resources are removed from your project.
//
// This method tries to delete any server or ssh keys that match the [DefaultLabels]
func (s *Client) CleanupTempResources(ctx context.Context) error {
logger := contextlogger.From(ctx).With(
"library", "hcloudimages",
"method", "cleanup",
)
selector := labelutil.Selector(DefaultLabels)
logger = logger.With("selector", selector)
logger.InfoContext(ctx, "# Cleaning up Servers")
err := s.cleanupTempServers(ctx, logger, selector)
if err != nil {
return fmt.Errorf("failed to clean up all servers: %w", err)
}
logger.DebugContext(ctx, "cleaned up all servers")
logger.InfoContext(ctx, "# Cleaning up SSH Keys")
err = s.cleanupTempSSHKeys(ctx, logger, selector)
if err != nil {
return fmt.Errorf("failed to clean up all ssh keys: %w", err)
}
logger.DebugContext(ctx, "cleaned up all ssh keys")
return nil
}
func (s *Client) cleanupTempServers(ctx context.Context, logger *slog.Logger, selector string) error {
servers, err := s.c.Server.AllWithOpts(ctx, hcloud.ServerListOpts{ListOpts: hcloud.ListOpts{
LabelSelector: selector,
}})
if err != nil {
return fmt.Errorf("failed to list servers: %w", err)
}
if len(servers) == 0 {
logger.InfoContext(ctx, "No servers found")
return nil
}
logger.InfoContext(ctx, "removing servers", "count", len(servers))
errs := []error{}
actions := make([]*hcloud.Action, 0, len(servers))
for _, server := range servers {
result, _, err := s.c.Server.DeleteWithResult(ctx, server)
if err != nil {
errs = append(errs, err)
logger.WarnContext(ctx, "failed to delete server", "server", server.ID, "error", err)
continue
}
actions = append(actions, result.Action)
}
successActions, errorActions, err := actionutil.Settle(ctx, &s.c.Action, actions...)
if err != nil {
return fmt.Errorf("failed to wait for server delete: %w", err)
}
if len(successActions) > 0 {
ids := make([]int64, 0, len(successActions))
for _, action := range successActions {
for _, resource := range action.Resources {
if resource.Type == hcloud.ActionResourceTypeServer {
ids = append(ids, resource.ID)
}
}
}
logger.InfoContext(ctx, "successfully deleted servers", "servers", ids)
}
if len(errorActions) > 0 {
for _, action := range errorActions {
errs = append(errs, action.Error())
}
}
if len(errs) > 0 {
// The returned message contains no info about the server IDs which failed
return fmt.Errorf("failed to delete some of the servers: %w", errors.Join(errs...))
}
return nil
}
func (s *Client) cleanupTempSSHKeys(ctx context.Context, logger *slog.Logger, selector string) error {
keys, _, err := s.c.SSHKey.List(ctx, hcloud.SSHKeyListOpts{ListOpts: hcloud.ListOpts{
LabelSelector: selector,
}})
if err != nil {
return fmt.Errorf("failed to list keys: %w", err)
}
if len(keys) == 0 {
logger.InfoContext(ctx, "No ssh keys found")
return nil
}
errs := []error{}
for _, key := range keys {
_, err := s.c.SSHKey.Delete(ctx, key)
if err != nil {
errs = append(errs, err)
logger.WarnContext(ctx, "failed to delete ssh key", "ssh-key", key.ID, "error", err)
continue
}
}
if len(errs) > 0 {
// The returned message contains no info about the server IDs which failed
return fmt.Errorf("failed to delete some of the ssh keys: %w", errors.Join(errs...))
}
return nil
}
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
}
0707010000001F000081A4000000000000000000000001681F464300000B37000000000000000000000000000000000000003600000000hcloud-upload-image-1.1.0/hcloudimages/client_test.gopackage hcloudimages
import (
"net/url"
"testing"
)
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
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)
}
})
}
}
07070100000020000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003500000000hcloud-upload-image-1.1.0/hcloudimages/contextlogger07070100000021000081A4000000000000000000000001681F4643000002A0000000000000000000000000000000000000004000000000hcloud-upload-image-1.1.0/hcloudimages/contextlogger/context.gopackage contextlogger
import (
"context"
"log/slog"
)
type key int
var loggerKey key
// New saves the logger as a value to the context. This can then be retrieved through [From].
func New(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
// From returns the [*slog.Logger] set on the context by [New]. If there is none,
// it returns a no-op logger that discards any output it receives.
func From(ctx context.Context) *slog.Logger {
if ctxLogger := ctx.Value(loggerKey); ctxLogger != nil {
if logger, ok := ctxLogger.(*slog.Logger); ok {
return logger
}
}
return slog.New(discardHandler{})
}
07070100000022000081A4000000000000000000000001681F46430000024A000000000000000000000000000000000000004000000000hcloud-upload-image-1.1.0/hcloudimages/contextlogger/discard.gopackage contextlogger
import (
"context"
"log/slog"
)
// discardHandler is a [slog.Handler] that just discards any input. It is a safe default if any library
// method does not get passed a logger through the context.
type discardHandler struct{}
func (discardHandler) Enabled(_ context.Context, _ slog.Level) bool { return false }
func (discardHandler) Handle(_ context.Context, _ slog.Record) error { return nil }
func (d discardHandler) WithAttrs(_ []slog.Attr) slog.Handler { return d }
func (d discardHandler) WithGroup(_ string) slog.Handler { return d }
07070100000023000081A4000000000000000000000001681F4643000007D0000000000000000000000000000000000000002E00000000hcloud-upload-image-1.1.0/hcloudimages/doc.go// Package hcloudimages is a library to upload Disk Images into your Hetzner Cloud project.
//
// # Overview
//
// The Hetzner Cloud API does not support uploading disk images directly, and it only provides a limited set of default
// images. The only option for custom disk images that users have is by taking a "snapshot" of an existing servers root
// disk. These can then be used to create new servers.
//
// To create a completely custom disk image, users have to follow these steps:
//
// 1. Create server with the correct server type
// 2. Enable rescue system for the server
// 3. Boot the server
// 4. Download the disk image from within the rescue system
// 5. Write disk image to servers root disk
// 6. Shut down the server
// 7. Take a snapshot of the servers root disk
// 8. Delete the server
//
// This is an annoyingly long process. Many users have automated this with Packer before, but Packer offers a lot of
// additional complexity to understand.
//
// This library is a single call to do the above: [Client.Upload]
//
// # Costs
//
// The temporary server and the snapshot itself cost money. See the [Hetzner Cloud website] for up-to-date pricing
// information.
//
// Usually the upload takes no more than a few minutes of server time, so you will only be billed for the first hour
// (<1ct for most cases). If this process fails, the server might stay around until you manually delete it. In that case
// it continues to cost its hourly price. There is a utility [Client.CleanupTemporaryResources] that removes any
// leftover resources.
//
// # Logging
//
// By default, nothing is logged. As the update process takes a bit of time you might want to gain some insight into
// the process. For this we provide optional logs through [log/slog]. You can set a [log/slog.Logger] in the
// [context.Context] through [github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger.New].
//
// [Hetzner Cloud website]: https://www.hetzner.com/cloud/
package hcloudimages
07070100000024000081A4000000000000000000000001681F4643000002A8000000000000000000000000000000000000003300000000hcloud-upload-image-1.1.0/hcloudimages/doc_test.gopackage 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)
}
07070100000025000081A4000000000000000000000001681F4643000003C0000000000000000000000000000000000000002E00000000hcloud-upload-image-1.1.0/hcloudimages/go.modmodule github.com/apricote/hcloud-upload-image/hcloudimages
go 1.23.0
toolchain go1.24.3
require (
github.com/hetznercloud/hcloud-go/v2 v2.21.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.37.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
07070100000026000081A4000000000000000000000001681F464300001169000000000000000000000000000000000000002E00000000hcloud-upload-image-1.1.0/hcloudimages/go.sumgithub.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=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hetznercloud/hcloud-go/v2 v2.21.0 h1:wUpQT+fgAxIcdMtFvuCJ78ziqc/VARubpOQPQyj4Q84=
github.com/hetznercloud/hcloud-go/v2 v2.21.0/go.mod h1:WSM7w+9tT86sJTNcF8a/oHljC3HUmQfcLxYsgx6PpSc=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
07070100000027000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003000000000hcloud-upload-image-1.1.0/hcloudimages/internal07070100000028000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003B00000000hcloud-upload-image-1.1.0/hcloudimages/internal/actionutil07070100000029000081A4000000000000000000000001681F46430000027E000000000000000000000000000000000000004500000000hcloud-upload-image-1.1.0/hcloudimages/internal/actionutil/action.gopackage actionutil
import (
"context"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
)
func Settle(ctx context.Context, client hcloud.IActionClient, actions ...*hcloud.Action) (successActions []*hcloud.Action, errorActions []*hcloud.Action, err error) {
err = client.WaitForFunc(ctx, func(update *hcloud.Action) error {
switch update.Status {
case hcloud.ActionStatusSuccess:
successActions = append(successActions, update)
case hcloud.ActionStatusError:
errorActions = append(errorActions, update)
}
return nil
}, actions...)
if err != nil {
return nil, nil, err
}
return successActions, errorActions, nil
}
0707010000002A000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003800000000hcloud-upload-image-1.1.0/hcloudimages/internal/control0707010000002B000081A4000000000000000000000001681F4643000003D8000000000000000000000000000000000000004100000000hcloud-upload-image-1.1.0/hcloudimages/internal/control/retry.go// SPDX-License-Identifier: MPL-2.0
// From https://github.com/hetznercloud/terraform-provider-hcloud/blob/v1.46.1/internal/control/retry.go
// Copyright (c) Hetzner Cloud GmbH
package control
import (
"context"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/apricote/hcloud-upload-image/hcloudimages/contextlogger"
)
// Retry executes f at most maxTries times.
func Retry(ctx context.Context, maxTries int, f func() error) error {
logger := contextlogger.From(ctx)
var err error
backoffFunc := hcloud.ExponentialBackoffWithOpts(hcloud.ExponentialBackoffOpts{Multiplier: 2, Base: 200 * time.Millisecond, Cap: 2 * time.Second})
for try := 0; try < maxTries; try++ {
if ctx.Err() != nil {
return ctx.Err()
}
err = f()
if err != nil {
sleep := backoffFunc(try)
logger.DebugContext(ctx, "operation failed, waiting before trying again", "try", try, "backoff", sleep)
time.Sleep(sleep)
continue
}
return nil
}
return err
}
0707010000002C000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003A00000000hcloud-upload-image-1.1.0/hcloudimages/internal/labelutil0707010000002D000081A4000000000000000000000001681F464300000210000000000000000000000000000000000000004400000000hcloud-upload-image-1.1.0/hcloudimages/internal/labelutil/labels.gopackage labelutil
import "fmt"
func Merge(a, b map[string]string) map[string]string {
result := make(map[string]string, len(a)+len(b))
for k, v := range a {
result[k] = v
}
for k, v := range b {
result[k] = v
}
return result
}
func Selector(labels map[string]string) string {
selector := make([]byte, 0, 64)
separator := ""
for k, v := range labels {
selector = fmt.Appendf(selector, "%s%s=%s", separator, k, v)
// Do not print separator on first element
separator = ","
}
return string(selector)
}
0707010000002E000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003900000000hcloud-upload-image-1.1.0/hcloudimages/internal/randomid0707010000002F000081A4000000000000000000000001681F4643000001E5000000000000000000000000000000000000004500000000hcloud-upload-image-1.1.0/hcloudimages/internal/randomid/randomid.go// SPDX-License-Identifier: MIT
// From https://gitlab.com/hetznercloud/fleeting-plugin-hetzner/-/blob/0f60204582289c243599f8ca0f5be4822789131d/internal/utils/random.go
// Copyright (c) 2024 Hetzner Cloud GmbH
package randomid
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
func Generate() (string, error) {
b := make([]byte, 4)
_, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("failed to generate random string: %w", err)
}
return hex.EncodeToString(b), nil
}
07070100000030000081A4000000000000000000000001681F464300000213000000000000000000000000000000000000004A00000000hcloud-upload-image-1.1.0/hcloudimages/internal/randomid/randomid_test.go// SPDX-License-Identifier: MIT
// From https://gitlab.com/hetznercloud/fleeting-plugin-hetzner/-/blob/0f60204582289c243599f8ca0f5be4822789131d/internal/utils/random_test.go
// Copyright (c) 2024 Hetzner Cloud GmbH
package randomid
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGenerateRandomID(t *testing.T) {
found1, err := Generate()
assert.NoError(t, err)
found2, err := Generate()
assert.NoError(t, err)
assert.Len(t, found1, 8)
assert.Len(t, found2, 8)
assert.NotEqual(t, found1, found2)
}
07070100000031000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000003B00000000hcloud-upload-image-1.1.0/hcloudimages/internal/sshsession07070100000032000081A4000000000000000000000001681F46430000014B000000000000000000000000000000000000004600000000hcloud-upload-image-1.1.0/hcloudimages/internal/sshsession/session.gopackage sshsession
import (
"io"
"golang.org/x/crypto/ssh"
)
func Run(client *ssh.Client, cmd string, stdin io.Reader) ([]byte, error) {
sess, err := client.NewSession()
if err != nil {
return nil, err
}
defer func() { _ = sess.Close() }()
if stdin != nil {
sess.Stdin = stdin
}
return sess.CombinedOutput(cmd)
}
07070100000033000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002300000000hcloud-upload-image-1.1.0/internal07070100000034000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002600000000hcloud-upload-image-1.1.0/internal/ui07070100000035000081A4000000000000000000000001681F464300001394000000000000000000000000000000000000003600000000hcloud-upload-image-1.1.0/internal/ui/slog_handler.gopackage ui
import (
"context"
"fmt"
"io"
"log/slog"
"sync"
)
// Developed with guidance from golang docs:
// https://github.com/golang/example/blob/32022caedd6a177a7717aa8680cbe179e1045935/slog-handler-guide/README.md
const (
ansiClear = "\033[0m"
ansiBold = "\033[1m"
ansiBoldYellow = "\033[1;93m"
ansiBoldRed = "\033[1;31m"
ansiThinGray = "\033[2;37m"
)
type Handler struct {
opts HandlerOptions
goas []groupOrAttrs
mu *sync.Mutex
out io.Writer
}
// HandlerOptions are a subset of [slog.HandlerOptions] that are implemented for the UI handler.
type HandlerOptions struct {
// Level reports the minimum record level that will be logged.
// The handler discards records with lower levels.
// If Level is nil, the handler assumes LevelInfo.
// The handler calls Level.Level for each record processed;
// to adjust the minimum level dynamically, use a LevelVar.
Level slog.Leveler
// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
// The attribute's value has been resolved (see [Value.Resolve]).
// If ReplaceAttr returns a zero Attr, the attribute is discarded.
//
// The built-in attributes with keys "time", "level", "source", and "msg"
// are passed to this function, except that time is omitted
// if zero, and source is omitted if AddSource is false.
//
// The first argument is a list of currently open groups that contain the
// Attr. It must not be retained or modified. ReplaceAttr is never called
// for Group attributes, only their contents. For example, the attribute
// list
//
// Int("a", 1), Group("g", Int("b", 2)), Int("c", 3)
//
// results in consecutive calls to ReplaceAttr with the following arguments:
//
// nil, Int("a", 1)
// []string{"g"}, Int("b", 2)
// nil, Int("c", 3)
//
// ReplaceAttr can be used to change the default keys of the built-in
// attributes, convert types (for example, to replace a `time.Time` with the
// integer seconds since the Unix epoch), sanitize personal information, or
// remove attributes from the output.
ReplaceAttr func(groups []string, a slog.Attr) slog.Attr
}
// groupOrAttrs holds either a group name or a list of [slog.Attr].
type groupOrAttrs struct {
group string // group name if non-empty
attrs []slog.Attr // attrs if non-empty
}
var _ slog.Handler = &Handler{}
func NewHandler(out io.Writer, opts *HandlerOptions) *Handler {
h := &Handler{
out: out,
mu: &sync.Mutex{},
}
if opts != nil {
h.opts = *opts
}
if h.opts.Level == nil {
h.opts.Level = slog.LevelInfo
}
return h
}
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.opts.Level.Level()
}
func (h *Handler) Handle(_ context.Context, record slog.Record) error {
buf := make([]byte, 0, 512)
formattingPrefix := ""
switch record.Level {
case slog.LevelInfo:
formattingPrefix = ansiBold
case slog.LevelWarn:
// Bold + Yellow
formattingPrefix = ansiBoldYellow
case slog.LevelError:
// Bold + Red
formattingPrefix = ansiBoldRed
}
// Print main message in formatted text
buf = fmt.Appendf(buf, "%s%s%s", formattingPrefix, record.Message, ansiClear)
// Add attributes in thin gray
buf = fmt.Append(buf, ansiThinGray)
// Attributes from [WithGroup] and [WithAttrs] calls
goas := h.goas
if record.NumAttrs() == 0 {
for len(goas) > 0 && goas[len(goas)-1].group != "" {
goas = goas[:len(goas)-1]
}
}
group := ""
for _, goa := range goas {
if goa.group != "" {
group = goa.group
} else {
for _, a := range goa.attrs {
buf = h.appendAttr(buf, group, a)
}
}
}
record.Attrs(func(a slog.Attr) bool {
buf = h.appendAttr(buf, group, a)
return true
})
buf = fmt.Appendf(buf, "%s\n", ansiClear)
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write(buf)
return err
}
func (h *Handler) appendAttr(buf []byte, group string, a slog.Attr) []byte {
a.Value = a.Value.Resolve()
if h.opts.ReplaceAttr != nil {
a = h.opts.ReplaceAttr([]string{group}, a)
}
// No-op if null attr
if a.Equal(slog.Attr{}) {
return buf
}
if group != "" {
group += "."
}
switch a.Value.Kind() {
case slog.KindString:
buf = fmt.Appendf(buf, " %s%s=%q", group, a.Key, a.Value)
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok {
buf = fmt.Appendf(buf, " %s%s=%q", group, a.Key, err.Error())
} else {
buf = fmt.Appendf(buf, " %s%s=%s", group, a.Key, a.Value)
}
default:
buf = fmt.Appendf(buf, " %s%s=%s", group, a.Key, a.Value)
}
return buf
}
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs})
}
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{group: name})
}
func (h *Handler) withGroupOrAttrs(goa groupOrAttrs) *Handler {
h2 := *h
h2.goas = make([]groupOrAttrs, len(h.goas)+1)
copy(h2.goas, h.goas)
h2.goas[len(h2.goas)-1] = goa
return &h2
}
07070100000036000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002B00000000hcloud-upload-image-1.1.0/internal/version07070100000037000081A4000000000000000000000001681F4643000001DA000000000000000000000000000000000000003600000000hcloud-upload-image-1.1.0/internal/version/version.gopackage version
var (
// version is a semver version (https://semver.org).
version = "1.1.0" // x-release-please-version
// versionPrerelease is a semver version pre-release identifier (https://semver.org).
//
// For final releases, we set this to an empty string.
versionPrerelease = "dev"
// Version of the hcloud-upload-image CLI.
Version = func() string {
if versionPrerelease != "" {
return version + "-" + versionPrerelease
}
return version
}()
)
07070100000038000081A4000000000000000000000001681F464300000068000000000000000000000000000000000000002200000000hcloud-upload-image-1.1.0/main.gopackage main
import (
"github.com/apricote/hcloud-upload-image/cmd"
)
func main() {
cmd.Execute()
}
07070100000039000081A4000000000000000000000001681F4643000002EF000000000000000000000000000000000000002800000000hcloud-upload-image-1.1.0/renovate.json{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits",
":semanticCommitTypeAll(chore)",
":semanticCommitScope(deps)",
":enableVulnerabilityAlerts"
],
"postUpdateOptions": [
"gomodTidy",
"gomodUpdateImportPaths"
],
"goGetDirs": ["./...", "./hcloudimages/..."],
"customManagers": [
{
"customType": "regex",
"fileMatch": [
"^\\.github\\/(?:workflows|actions)\\/.+\\.ya?ml$"
],
"matchStrings": [
"(?:version|VERSION): (?<currentValue>.+) # renovate: datasource=(?<datasource>[a-z-]+) depName=(?<depName>.+)(?: packageName=(?<packageName>.+))?(?: versioning=(?<versioning>[a-z-]+))?"
]
}
]
}
0707010000003A000041ED000000000000000000000002681F464300000000000000000000000000000000000000000000002200000000hcloud-upload-image-1.1.0/scripts0707010000003B000081A4000000000000000000000001681F4643000002A5000000000000000000000000000000000000003400000000hcloud-upload-image-1.1.0/scripts/cli-help-pages.gopackage main
import (
"fmt"
"os"
"github.com/spf13/cobra/doc"
"github.com/apricote/hcloud-upload-image/cmd"
)
func run() error {
// Define the directory where the docs will be generated
dir := "docs/reference/cli"
// Ensure the directory exists
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating docs directory: %v", err)
}
// Generate the docs
if err := doc.GenMarkdownTree(cmd.RootCmd, dir); err != nil {
return fmt.Errorf("error generating docs: %v", err)
}
fmt.Println("Docs generated successfully in", dir)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}
0707010000003C000081ED000000000000000000000001681F464300000099000000000000000000000000000000000000003100000000hcloud-upload-image-1.1.0/scripts/completions.sh#!/bin/sh
set -e
rm -rf completions
mkdir completions
for sh in bash zsh fish; do
go run . completion "$sh" >"completions/hcloud-upload-image.$sh"
done
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!180 blocks