Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
131 changes: 121 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,35 @@
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,
cloudapiv6.ArgCpuHotUnplug, cloudapiv6.ArgRamHotUnplug, cloudapiv6.ArgNicHotUnplug,
cloudapiv6.ArgDiscVirtioHotUnplug, cloudapiv6.ArgDiscScsiHotUnplug,
} {
if changed(f) && viper.GetBool(core.GetFlagName(c.NS, f)) {
return fmt.Errorf("--%s images cannot enable hot-plug/hot-unplug; 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