Skip to content

Add support for split image publishing #2013

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 3, 2025
4 changes: 4 additions & 0 deletions cmd/incus/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type cmdPublish struct {
flagMakePublic bool
flagForce bool
flagReuse bool
flagFormat string
}

// Command returns a cobra.Command for use with (*cobra.Command).AddCommand.
Expand All @@ -41,6 +42,7 @@ func (c *cmdPublish) Command() *cobra.Command {
cmd.Flags().StringVar(&c.flagCompressionAlgorithm, "compression", "", i18n.G("Compression algorithm to use (`none` for uncompressed)"))
cmd.Flags().StringVar(&c.flagExpiresAt, "expire", "", i18n.G("Image expiration date (format: rfc3339)")+"``")
cmd.Flags().BoolVar(&c.flagReuse, "reuse", false, i18n.G("If the image alias already exists, delete and create a new one"))
cmd.Flags().StringVar(&c.flagFormat, "format", "unified", i18n.G("Image format")+"``")

cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
Expand Down Expand Up @@ -257,6 +259,8 @@ func (c *cmdPublish) Run(cmd *cobra.Command, args []string) error {
return fmt.Errorf(i18n.G("Aliases already exists: %s"), strings.Join(names, ", "))
}

req.Format = c.flagFormat

op, err := s.CreateImage(req, nil)
if err != nil {
return err
Expand Down
137 changes: 120 additions & 17 deletions cmd/incusd/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,16 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
projectName := request.ProjectParam(r)
name := req.Source.Name
ctype := req.Source.Type
imageType := req.Format

if ctype == "" || name == "" {
return nil, fmt.Errorf("No source provided")
}

if imageType != "" && imageType != "unified" && imageType != "split" {
return nil, fmt.Errorf("Invalid image format")
}

switch ctype {
case "snapshot":
if !internalInstance.IsSnapshot(name) {
Expand Down Expand Up @@ -241,12 +247,18 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
info.Type = c.Type().String()

// Build the actual image file
imageFile, err := os.CreateTemp(builddir, "incus_build_image_")
metaFile, err := os.CreateTemp(builddir, "incus_build_image_")
if err != nil {
return nil, err
}

defer func() { _ = os.Remove(imageFile.Name()) }()
rootfsFile, err := os.CreateTemp(builddir, "incus_build_image_")
if err != nil {
return nil, err
}

defer func() { _ = os.Remove(metaFile.Name()) }()
defer func() { _ = os.Remove(rootfsFile.Name()) }()

// Calculate (close estimate of) total size of input to image
totalSize := int64(0)
Expand All @@ -265,7 +277,27 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r

// Track progress creating image.
metadata := make(map[string]any)
imageProgressWriter := &ioprogress.ProgressWriter{
metaProgressWriter := &ioprogress.ProgressWriter{
Tracker: &ioprogress.ProgressTracker{
Handler: func(value, speed int64) {
percent := int64(0)
var processed int64

if totalSize > 0 {
percent = value
processed = totalSize * (percent / 100.0)
} else {
processed = value
}

operations.SetProgressMetadata(metadata, "create_image_from_container_pack", "Image pack", percent, processed, speed)
_ = op.UpdateMetadata(metadata)
},
Length: totalSize,
},
}

rootfsProgressWriter := &ioprogress.ProgressWriter{
Tracker: &ioprogress.ProgressTracker{
Handler: func(value, speed int64) {
percent := int64(0)
Expand All @@ -287,7 +319,8 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r

hash256 := sha256.New()
var compress string
var writer io.Writer
var metaWriter io.Writer
var rootfsWriter io.Writer

if req.CompressionAlgorithm != "" {
compress = req.CompressionAlgorithm
Expand Down Expand Up @@ -320,21 +353,52 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
if compress != "none" {
wg.Add(1)
tarReader, tarWriter := io.Pipe()
imageProgressWriter.WriteCloser = tarWriter
writer = imageProgressWriter
compressWriter := io.MultiWriter(imageFile, hash256)

metaProgressWriter.WriteCloser = tarWriter
metaWriter = metaProgressWriter

var compressWriter io.Writer
if imageType != "split" {
compressWriter = io.MultiWriter(metaFile, hash256)
} else {
compressWriter = io.MultiWriter(metaFile)
}

go func() {
defer wg.Done()
compressErr = compressFile(compress, tarReader, compressWriter)

// If a compression error occurred, close the writer to end the instance export.
if compressErr != nil {
_ = imageProgressWriter.Close()
_ = metaProgressWriter.Close()
}
}()
} else {
metaProgressWriter.WriteCloser = metaFile
if imageType != "split" {
metaWriter = io.MultiWriter(metaProgressWriter, hash256)
} else {
metaWriter = io.MultiWriter(metaProgressWriter)
}
}
if compress != "none" && c.Info().Type.String() != "virtual-machine" {
wg.Add(1)
tarReader, tarWriter := io.Pipe()
rootfsProgressWriter.WriteCloser = tarWriter
rootfsWriter = rootfsProgressWriter
compressWriter := io.MultiWriter(rootfsFile)

go func() {
defer wg.Done()
compressErr = compressFile(compress, tarReader, compressWriter)
// If a compression error occurred, close the writer to end the instance export.
if compressErr != nil {
_ = rootfsProgressWriter.Close()
}
}()
} else {
imageProgressWriter.WriteCloser = imageFile
writer = io.MultiWriter(imageProgressWriter, hash256)
rootfsProgressWriter.WriteCloser = rootfsFile
rootfsWriter = io.MultiWriter(rootfsProgressWriter)
}

// Tracker instance for the export phase.
Expand All @@ -348,15 +412,22 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
// Export instance to writer.
var meta *api.ImageMetadata

writer = internalIO.NewQuotaWriter(writer, budget)
meta, err = c.Export(writer, req.Properties, req.ExpiresAt, tracker)
metaWriter = internalIO.NewQuotaWriter(metaWriter, budget)
rootfsWriter = internalIO.NewQuotaWriter(rootfsWriter, budget)
if imageType != "split" {
meta, err = c.Export(metaWriter, nil, req.Properties, req.ExpiresAt, tracker)
} else {
meta, err = c.Export(metaWriter, rootfsWriter, req.Properties, req.ExpiresAt, tracker)
}

// Clean up file handles.
// When compression is used, Close on imageProgressWriter/tarWriter is required for compressFile/gzip to
// know it is finished. Otherwise it is equivalent to imageFile.Close.
_ = imageProgressWriter.Close()
_ = metaProgressWriter.Close()
_ = rootfsProgressWriter.Close()
wg.Wait() // Wait until compression helper has finished if used.
_ = imageFile.Close()
_ = metaFile.Close()
_ = rootfsFile.Close()

// Check compression errors.
if compressErr != nil {
Expand All @@ -373,12 +444,36 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
info.ExpiresAt = time.Unix(meta.ExpiryDate, 0)
}

fi, err := os.Stat(imageFile.Name())
fi, err := os.Stat(metaFile.Name())
if err != nil {
return nil, err
}

info.Size = fi.Size()
// Make sure both files are included for size and hash when using split format
if imageType == "split" {
rootfsFi, err := os.Stat(rootfsFile.Name())
if err != nil {
return nil, err
}

info.Size += rootfsFi.Size()

metaData, err := os.ReadFile(metaFile.Name())
if err != nil {
return nil, err
}

hash256.Write(metaData)

rootfsData, err := os.ReadFile(rootfsFile.Name())
if err != nil {
return nil, err
}

hash256.Write(rootfsData)
}

info.Fingerprint = fmt.Sprintf("%x", hash256.Sum(nil))
info.CreatedAt = time.Now().UTC()

Expand All @@ -396,12 +491,20 @@ func imgPostInstanceInfo(ctx context.Context, s *state.State, r *http.Request, r
}

/* rename the file to the expected name so our caller can use it */
finalName := internalUtil.VarPath("images", info.Fingerprint)
err = internalUtil.FileMove(imageFile.Name(), finalName)
metaFinalName := internalUtil.VarPath("images", info.Fingerprint)
err = internalUtil.FileMove(metaFile.Name(), metaFinalName)
if err != nil {
return nil, err
}

if imageType == "split" {
rootfsFinalName := internalUtil.VarPath("images", info.Fingerprint+".rootfs")
err = internalUtil.FileMove(rootfsFile.Name(), rootfsFinalName)
if err != nil {
return nil, err
}
}

info.Architecture, _ = osarch.ArchitectureName(c.Architecture())
info.Properties = meta.Properties

Expand Down
4 changes: 4 additions & 0 deletions doc/api-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2799,3 +2799,7 @@ This adds memory hotplugging for VMs, allowing them to add memory at runtime wit
## `instance_nic_routed_host_tables`

This adds support for specifying host-routing tables on `nic` devices that use the routed mode.

## `instance_publish_split`

This adds support for creating a split format image out of an existing instance.
5 changes: 5 additions & 0 deletions doc/rest-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,11 @@ definitions:
example: image.tar.xz
type: string
x-go-name: Filename
format:
description: Type of image format
example: split
type: string
x-go-name: Format
profiles:
description: List of profiles to use when creating from this image (if none provided by user)
example:
Expand Down
Loading