Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Versioning follows [SemVer](https://semver.org/). Sections: **Added**, **Changed**, **Deprecated**, **Fixed**, **Removed**, **Known Limitations**, **Dependencies**. Only user-visible changes listed. Older entries may use non-standard section names.

## [v6.10.3] - August 2026

### Added
- Confidential Computing support:
- `image upload --confidential` uploads to the `/confidential-images/` FTP directory. Restricted to QCOW2 images (which must carry a `LAUNCH_ARTIFACTS` partition), and forces the image's mutable properties to the only values the platform accepts: `cloud-init NONE`, all hot-plug disabled, and legacy BIOS off. Conflicting explicit flags are rejected.
- `server create --confidential` creates a Confidential VM from a confidential boot image. Requires `--type ENTERPRISE` and `--image-id` (a private, SEV-SNP image; `--image-id` tab-completion is filtered to these). A boot volume is built from the image and attached in the same request (size it with `--size`/`--storage-type`), so the API can derive `cores` and `cpuFamily` from the image's `launch-config.json` — `--cores` and `--cpu-family` must not be set.
- `RequiredFeatures` and `EnabledFeatures` columns.

## [v6.10.2] - June 2026

### Added
Expand Down
124 changes: 124 additions & 0 deletions commands/compute/image/confidential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package image

import (
"bufio"
"bytes"
"testing"

"github.com/ionos-cloud/ionosctl/v6/internal/constants"
"github.com/ionos-cloud/ionosctl/v6/internal/core"
cloudapiv6 "github.com/ionos-cloud/ionosctl/v6/services/cloudapi-v6"
"github.com/ionos-cloud/ionosctl/v6/services/cloudapi-v6/resources"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
)

func TestForceConfidentialImageProperties(t *testing.T) {
p := resources.ImageProperties{}
forceConfidentialImageProperties(&p)

assert.Equal(t, "NONE", *p.CloudInit)
assert.False(t, *p.CpuHotPlug)
assert.False(t, *p.RamHotPlug)
assert.False(t, *p.NicHotPlug)
assert.False(t, *p.DiscVirtioHotPlug)
assert.False(t, *p.DiscScsiHotPlug)
assert.False(t, *p.CpuHotUnplug)
assert.False(t, *p.RamHotUnplug)
assert.False(t, *p.NicHotUnplug)
assert.False(t, *p.DiscVirtioHotUnplug)
assert.False(t, *p.DiscScsiHotUnplug)
assert.False(t, *p.RequireLegacyBios)
}

func TestAllImageColsHasRequiredFeatures(t *testing.T) {
var found bool
for _, c := range allImageCols {
if c.Name == "RequiredFeatures" {
found = true
}
}
assert.True(t, found, "allImageCols must expose a RequiredFeatures column")
}

// preRunImageUploadConfidential registers the flags PreRunImageUpload touches, applies the given
// setup, and returns the resulting error.
func runPreRunImageUpload(t *testing.T, setup func(cfg *core.PreCommandConfig)) error {
t.Helper()
var b bytes.Buffer
w := bufio.NewWriter(&b)
var got error
core.PreCmdConfigTest(t, w, func(cfg *core.PreCommandConfig) {
viper.Reset()
viper.Set(constants.ArgOutput, constants.DefaultOutputFormat)

fs := cfg.Command.Command.Flags()
fs.StringSlice(FlagImage, nil, "")
fs.String(constants.FlagCloudInit, "", "")
fs.Bool(cloudapiv6.ArgCpuHotPlug, false, "")
fs.Bool(cloudapiv6.ArgRamHotPlug, false, "")
fs.Bool(cloudapiv6.ArgNicHotPlug, false, "")
fs.Bool(cloudapiv6.ArgDiscVirtioHotPlug, false, "")
fs.Bool(cloudapiv6.ArgDiscScsiHotPlug, false, "")
fs.Bool(cloudapiv6.ArgRequireLegacyBios, false, "")

setup(cfg)
got = PreRunImageUpload(cfg)
})
return got
}

func TestPreRunImageUploadConfidential(t *testing.T) {
t.Run("rejects non-qcow2", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, constants.FlagConfidential), true)
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.iso"})
})
assert.Error(t, err)
})

t.Run("rejects cloud-init V1", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, constants.FlagConfidential), true)
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.qcow2"})
viper.Set(core.GetFlagName(cfg.NS, constants.FlagCloudInit), "V1")
_ = cfg.Command.Command.Flags().Set(constants.FlagCloudInit, "V1")
})
assert.Error(t, err)
})

t.Run("rejects hot-plug enabled", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, constants.FlagConfidential), true)
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.qcow2"})
viper.Set(core.GetFlagName(cfg.NS, cloudapiv6.ArgCpuHotPlug), true)
_ = cfg.Command.Command.Flags().Set(cloudapiv6.ArgCpuHotPlug, "true")
})
assert.Error(t, err)
})

t.Run("rejects require-legacy-bios true", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, constants.FlagConfidential), true)
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.qcow2"})
viper.Set(core.GetFlagName(cfg.NS, cloudapiv6.ArgRequireLegacyBios), true)
_ = cfg.Command.Command.Flags().Set(cloudapiv6.ArgRequireLegacyBios, "true")
})
assert.Error(t, err)
})

t.Run("accepts clean qcow2", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, constants.FlagConfidential), true)
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.qcow2"})
})
assert.NoError(t, err)
})

t.Run("no restriction without --confidential", func(t *testing.T) {
err := runPreRunImageUpload(t, func(cfg *core.PreCommandConfig) {
viper.Set(core.GetFlagName(cfg.NS, FlagImage), []string{"disk.iso"})
})
assert.NoError(t, err)
})
}
1 change: 1 addition & 0 deletions commands/compute/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var allImageCols = []table.Column{
{Name: "ExposeSerial", JSONPath: "properties.exposeSerial"},
{Name: "RequireLegacyBios", JSONPath: "properties.requireLegacyBios"},
{Name: "ApplicationType", JSONPath: "properties.applicationType"},
{Name: "RequiredFeatures", JSONPath: "properties.requiredFeatures"},
}

func ImageCmd() *core.Command {
Expand Down
129 changes: 119 additions & 10 deletions commands/compute/image/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
4. Print the resulting image objects to stdout in the chosen table or JSON format.

AUTH AND SAFETY
- The FTP server relies on API credentials via environment variables IONOS_USERNAME and IONOS_PASSWORD. You can debug your current setup with "ionosctl whoami --provenance".
- The FTP server relies on basic API credentials via environment variables IONOS_USERNAME and IONOS_PASSWORD. A bearer token (IONOS_TOKEN) cannot be used for the FTP upload, so if you authenticate with a token you must additionally set IONOS_USERNAME and IONOS_PASSWORD (they may be set alongside IONOS_TOKEN). You can debug your current setup with "ionosctl whoami --provenance".
- Use --skip-update to skip the API PATCH step if you only want to perform an FTP upload and not modify images through the API.
- Use --skip-verify to skip verifying the FTP server certificate. Only use that for trusted servers. Skipping certificate verification can expose you to man-in-the-middle attacks.
- If using a custom FTP server it is advised to use a self-signed certificate instead of --skip-verify. Provide its PEM file via --crt-path. The file should contain the server certificate in base64 PEM format.
Expand Down Expand Up @@ -225,6 +225,8 @@

upload.AddIntFlag(FlagFtpPort, "", 21, "FTP server port. Only valid together with --ftp-url, for custom FTP servers on non-standard ports")

upload.AddBoolFlag(constants.FlagConfidential, "", false, "Upload to the confidential-images/ directory for Confidential Computing (CoCo) images. Requires a QCOW2 image with an embedded LAUNCH_ARTIFACTS partition. Forces cloud-init NONE and disables hot-plug / legacy BIOS on the image.")

addPropertiesFlags(upload)

upload.Command.Flags().SortFlags = false // Hot Plugs generate a lot of flags to scroll through, put them at the end
Expand All @@ -233,6 +235,67 @@
return upload
}

// forceConfidentialImageProperties sets the only image properties the API accepts for a
// Confidential Computing image: cloud-init NONE, all hot-plug/hot-unplug disabled, and no legacy BIOS.
func forceConfidentialImageProperties(p *resources.ImageProperties) {
p.SetCloudInit("NONE")
p.SetCpuHotPlug(false)
p.SetRamHotPlug(false)
p.SetNicHotPlug(false)
p.SetDiscVirtioHotPlug(false)
p.SetDiscScsiHotPlug(false)
p.SetCpuHotUnplug(false)
p.SetRamHotUnplug(false)
p.SetNicHotUnplug(false)
p.SetDiscVirtioHotUnplug(false)
p.SetDiscScsiHotUnplug(false)
p.SetRequireLegacyBios(false)
}

// resolveFTPCredentials returns the username/password to use for the FTP upload. The FTP server
// cannot authenticate with a bearer token, so a token-authenticated client (IONOS_TOKEN set,
// username/password empty) would send an empty USER and fail with a 500. Fall back to the
// IONOS_USERNAME / IONOS_PASSWORD env vars, then to the current config profile's basic credentials,
// so token users can still upload by additionally providing basic credentials.
func resolveFTPCredentials() (string, string, error) {
cfg := client.Must().CloudClient.GetConfig()

var profUser, profPass string
if prof := client.Must().Config.GetCurrentProfile(); prof != nil {
profUser, profPass = prof.Credentials.Username, prof.Credentials.Password
}

user, pass, ok := pickFTPCredentials(
cfg.Username, cfg.Password,
os.Getenv(constants.EnvUsername), os.Getenv(constants.EnvPassword),
profUser, profPass,
)
if !ok {
return "", "", fmt.Errorf("FTP upload requires basic credentials (username & password); "+
"the FTP server does not accept a bearer token. Set %s and %s (they can be set alongside %s)",
constants.EnvUsername, constants.EnvPassword, constants.EnvToken)
}

return user, pass, nil
}

// pickFTPCredentials chooses the first source that provides a complete username+password pair, in
// precedence order: the client's loaded basic credentials, then the IONOS_USERNAME/IONOS_PASSWORD
// env vars, then the current config profile. Returns ok=false when no source is complete — which is
// exactly the token-only case, since FTP cannot use a bearer token.
func pickFTPCredentials(cfgUser, cfgPass, envUser, envPass, profUser, profPass string) (string, string, bool) {
for _, pair := range [][2]string{
{cfgUser, cfgPass},
{envUser, envPass},
{profUser, profPass},
} {
if pair[0] != "" && pair[1] != "" {
return pair[0], pair[1], true
}
}
return "", "", false
}

func updateImagesAfterUpload(c *core.CommandConfig, diffImgs []ionoscloud.Image, properties resources.ImageProperties) ([]ionoscloud.Image, error) {
// do a patch on the uploaded images
var imgs []ionoscloud.Image
Expand All @@ -257,8 +320,17 @@
aliases := viper.GetStringSlice(core.GetFlagName(c.NS, cloudapiv6.ArgImageAlias))
locations := viper.GetStringSlice(core.GetFlagName(c.NS, cloudapiv6.ArgLocation))
skipVerify := viper.GetBool(core.GetFlagName(c.NS, FlagSkipVerify))
confidential := viper.GetBool(core.GetFlagName(c.NS, constants.FlagConfidential))

ctx, cancel := context.WithTimeout(c.Context, time.Duration(viper.GetInt(core.GetFlagName(c.NS, constants.ArgTimeout)))*time.Second)
ftpUser, ftpPass, err := resolveFTPCredentials()
if err != nil {
return err
}

// --timeout is a global persistent flag bound to viper's flat "timeout" key (see commands/root.go).
// It must be read by that flat key, not the namespaced one, otherwise it reads 0 and the context
// expires immediately, surfacing as a misleading "i/o timeout" on the first FTP dial.
ctx, cancel := context.WithTimeout(c.Context, time.Duration(viper.GetInt(constants.ArgTimeout))*time.Second)
defer cancel()
c.Context = ctx

Expand Down Expand Up @@ -289,14 +361,17 @@
}
c.Verbose("Uploading %s to %s", img, url)

var isoOrHdd string
if ext := filepath.Ext(img); ext == ".iso" || ext == ".img" {
isoOrHdd = "iso"
} else {
isoOrHdd = "hdd"
var serverDir string
switch {
case confidential:
serverDir = "confidential-images/"
case filepath.Ext(img) == ".iso" || filepath.Ext(img) == ".img":
serverDir = "iso-images/"
default:
serverDir = "hdd-images/"
}

serverFilePath := fmt.Sprintf("%s-images/", isoOrHdd) // iso-images / hdd-images
serverFilePath := serverDir
if len(aliases) == 0 {
serverFilePath += filepath.Base(img) // If no custom alias, use the filename
} else {
Expand All @@ -319,8 +394,8 @@
Port: viper.GetInt(core.GetFlagName(c.NS, FlagFtpPort)),
SkipVerify: skipVerify,
ServerCertificate: certPool,
Username: client.Must().CloudClient.GetConfig().Username,
Password: client.Must().CloudClient.GetConfig().Password,
Username: ftpUser,
Password: ftpPass,
},
ImageFileProperties: resources.ImageFileProperties{
Path: serverFilePath,
Expand Down Expand Up @@ -362,6 +437,13 @@
}

properties := getDesiredImageAfterPatch(c, true)
if confidential {
// Confidential images have a fixed, restricted property set: the API rejects cloud-init V1,
// any hot-plug, and legacy BIOS. getDesiredImageAfterPatch sends flag defaults (cloud-init V1,
// hot-plug true), so override them to the only values the API accepts. Conflicting explicit
// flags are already rejected in PreRunImageUpload.
forceConfidentialImageProperties(&properties)
}
imgs, err := updateImagesAfterUpload(c, diffImgs, properties)
if err != nil {
return fmt.Errorf("failed updating image with given properties, but uploading to FTP successful: %w", err)
Expand Down Expand Up @@ -418,7 +500,7 @@
}
}

func PreRunImageUpload(c *core.PreCommandConfig) error {

Check failure on line 503 in commands/compute/image/upload.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 33 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=cli-ionosctl&issues=AZ9v4Vlno6v9Si67PSXS&open=AZ9v4Vlno6v9Si67PSXS&pullRequest=684
err := c.Command.Command.MarkFlagRequired(FlagImage)
if err != nil {
return err
Expand All @@ -441,6 +523,33 @@
return fmt.Errorf("%s is an invalid image extension. Valid extensions are: %s", strings.Join(invalidImages, ","), validExts)
}

// Confidential Computing images must be QCOW2 and carry a fixed, restricted property set.
// Reject conflicting explicit flags up front rather than silently overriding a user's choice.
if viper.GetBool(core.GetFlagName(c.NS, constants.FlagConfidential)) {
for _, img := range images {
if ext := filepath.Ext(img); ext != ".qcow2" && ext != ".qcow" {
return fmt.Errorf("--%s requires QCOW2 images; %s has extension %q", constants.FlagConfidential, img, ext)
}
}

changed := c.Command.Command.Flags().Changed
if changed(constants.FlagCloudInit) &&
strings.EqualFold(viper.GetString(core.GetFlagName(c.NS, constants.FlagCloudInit)), "V1") {
return fmt.Errorf("--%s images require cloud-init NONE; do not pass --%s V1", constants.FlagConfidential, constants.FlagCloudInit)
}
for _, f := range []string{
cloudapiv6.ArgCpuHotPlug, cloudapiv6.ArgRamHotPlug, cloudapiv6.ArgNicHotPlug,
cloudapiv6.ArgDiscVirtioHotPlug, cloudapiv6.ArgDiscScsiHotPlug,
} {
if changed(f) && viper.GetBool(core.GetFlagName(c.NS, f)) {
return fmt.Errorf("--%s images cannot enable hot-plug; do not pass --%s", constants.FlagConfidential, f)
}
}
Comment thread
Copilot marked this conversation as resolved.
if changed(cloudapiv6.ArgRequireLegacyBios) && viper.GetBool(core.GetFlagName(c.NS, cloudapiv6.ArgRequireLegacyBios)) {
return fmt.Errorf("--%s images require --%s=false", constants.FlagConfidential, cloudapiv6.ArgRequireLegacyBios)
}
}

// --ftp-port only makes sense with a custom --ftp-url
if viper.IsSet(core.GetFlagName(c.NS, FlagFtpPort)) {
if !viper.IsSet(core.GetFlagName(c.NS, FlagFtpUrl)) {
Expand Down
39 changes: 39 additions & 0 deletions commands/compute/image/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,45 @@ func TestDeduplicateLocations(t *testing.T) {
}
}

func TestPickFTPCredentials(t *testing.T) {
tests := []struct {
name string
cfgUser, cfgPass, envUser, envPass, profUser, profPass string
wantUser, wantPass string
wantOK bool
}{
{
name: "client basic creds win", cfgUser: "cfg", cfgPass: "cfgpw",
envUser: "env", envPass: "envpw", wantUser: "cfg", wantPass: "cfgpw", wantOK: true,
},
{
// token-only client: cfg user/pass empty -> fall back to env (the reported scenario).
name: "env fallback when client has token only", envUser: "env", envPass: "envpw",
wantUser: "env", wantPass: "envpw", wantOK: true,
},
{
name: "profile fallback when env incomplete", envUser: "env", // envPass missing
profUser: "prof", profPass: "profpw", wantUser: "prof", wantPass: "profpw", wantOK: true,
},
{
// pure token-only, nothing else set: FTP cannot authenticate.
name: "incomplete pair -> not ok", cfgUser: "onlyuser", wantOK: false,
},
{
name: "all empty -> not ok", wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, p, ok := pickFTPCredentials(tt.cfgUser, tt.cfgPass, tt.envUser, tt.envPass, tt.profUser, tt.profPass)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.wantUser, u)
assert.Equal(t, tt.wantPass, p)
})
}
}

func TestLookupAPI(t *testing.T) {
tests := []struct {
input string
Expand Down
Loading
Loading