From 603f591ebf980fc55b5a3d43404845c9426c6998 Mon Sep 17 00:00:00 2001 From: wanyaoqi <18528551+wanyaoqi@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:35:27 +0800 Subject: [PATCH] Cherry pick of #23094 upstream release 4.0 1770106850 (#24452) * feat(region, climc): Add kickstart parameters and metadata - Add kickstart parameters and metadata support - Add kickstart CLI options and management commands - Set kickstart metadata in PostCreate - Add related kickstart metadata fields * feat(host): Implement automated VM installation - Mounts the installation ISO to a unique temp path, reusing existing mounts. - Extracts kernel (`vmlinuz`, `initrd`) to configure QEMU boot. - Injects kernel parameters to trigger the autoinstall. - Add kickstart configuration validation. Modified `guestLauncher` script to handle kernel parameters with spaces that were previously truncated by the Python script. * feat(host): Implement Kickstart monitoring - Automatically updates the VM status upon receiving `KICKSTART_SUCCESS` or `KICKSTART_FAILED` signals. - On success, cleans up temporary mount points and restarts the server via the Region API. * feat(region): add kickstart type metadata Adds the `VM_METADATA_KICKSTART_TYPE` to distinguish between kickstart configurations passed by URL or by direct content. * feat(host): Handle kickstart installing and timeout - Set status to 'installing' upon successful kickstart boot - Handle KICKSTART_INSTALLING serial message for status updates - Add timeout handling for kickstart status monitoring * feat(host): provide Kickstart config via content Embed the provided config content as a file in a labeled ISO (mkisofs), then mount the ISO during the Kickstart installation to supply the config. * refactor(host): Enhance the kickstart installation - Integrates kickstart status directly into the VM status, replacing metadata. - Unifies locations for kickstart logs and temporary files. - Uses regex for more robust status matching. - Moves helper functions to `kickstart_helper.go`. - Fixes an incorrect kickstart configuration filename. - Adjusts kickstart logging. * feat(region, host): add kickstart support for openEuler * refactor(host): extract kickstart cleanup function * feat(region, climc): add kickstart-complete cli Add this command to manually update the kickstart status and reboot VM if needed. This is mainly used by users to manually update the kickstart status when kickstart status detection is abnormal. * refactor(region): reduce code duplication in Kickstart validation - unify two validateKickstartConfig functions - extract determineKickstartType function for type determination * fix(region, host): fix kickstart auto-reboot - Adjusted the timing of setting the status to `kickstart_pending` to avoid incorrect reboot checks - Skip setting kickstart if the status is already `kickstart_completed` to prevent unnecessary reboots - Added cleanup of kickstart files upon VM deletion * feat(host): download kickstart config from URL - download content when passing config via URL - unified boot parameters * feat(host): copy kickstart kernel files * feat(host): ensure kickstart completion signal in config --------- Co-authored-by: happygame --- cmd/climc/shell/compute/servers.go | 4 + pkg/apis/compute/api.go | 47 ++ pkg/apis/compute/guest_const.go | 30 + pkg/compute/guestdrivers/base.go | 2 +- pkg/compute/models/guest_actions.go | 216 ++++++- pkg/compute/models/guests.go | 230 ++++++++ pkg/hostman/guestman/kickstart_helper.go | 713 +++++++++++++++++++++++ pkg/hostman/guestman/qemu-kvm.go | 41 +- pkg/hostman/guestman/qemu-kvmhelper.go | 251 +++++++- pkg/hostman/guestman/qemu/generate.go | 52 ++ pkg/mcclient/options/compute/servers.go | 82 +++ 11 files changed, 1661 insertions(+), 7 deletions(-) create mode 100644 pkg/hostman/guestman/kickstart_helper.go diff --git a/cmd/climc/shell/compute/servers.go b/cmd/climc/shell/compute/servers.go index c99f60682a..ef67a61a33 100644 --- a/cmd/climc/shell/compute/servers.go +++ b/cmd/climc/shell/compute/servers.go @@ -133,6 +133,10 @@ func init() { cmd.BatchPerform("set-root-disk-matcher", &options.ServerSetRootDiskMatcher{}) cmd.Perform("disable-auto-merge-snapshot", &options.ServerDisableAutoMergeSnapshot{}) cmd.BatchPerform("set-tpm", &options.ServerSetTpmOptions{}) + cmd.Perform("set-kickstart", &options.ServerKickstartConfigOptions{}) + cmd.Perform("delete-kickstart", &options.ServerIdOptions{}) + cmd.Perform("kickstart-complete", &options.ServerKickstartCompleteOptions{}) + cmd.Get("kickstart", new(options.ServerIdOptions)) cmd.Get("vnc", new(options.ServerVncOptions)) cmd.Get("desc", new(options.ServerIdOptions)) cmd.Get("status", new(options.ServerIdOptions)) diff --git a/pkg/apis/compute/api.go b/pkg/apis/compute/api.go index 7dd3ada1eb..90012372a8 100644 --- a/pkg/apis/compute/api.go +++ b/pkg/apis/compute/api.go @@ -478,6 +478,37 @@ type DeployConfig struct { Content string `json:"content"` } +// KickstartConfig Kickstart/Autoinstall自动化安装配置 +type KickstartConfig struct { + // 配置文件内容 (当用户直接提供配置时使用) + // required: false + Config string `json:"config,omitempty"` + + // 配置文件 URL (当配置文件位于外部服务器时使用) + // required: false + ConfigURL string `json:"config_url,omitempty"` + + // 操作系统类型 (用于确定内核参数和文件路径) + // enum: centos,rhel,fedora,openeuler,ubuntu + // required: true + OSType string `json:"os_type" validate:"required,oneof=centos rhel fedora openeuler ubuntu"` + + // 是否启用 (用于临时禁用而不删除配置) + // default: true + // required: false + Enabled *bool `json:"enabled,omitempty"` + + // 最大重试次数 + // default: 3 + // required: false + MaxRetries int `json:"max_retries,omitempty"` + + // 安装超时时间 (分钟) + // default: 60 + // required: false + TimeoutMinutes int `json:"timeout_minutes,omitempty"` +} + type ServerCreateInput struct { apis.VirtualResourceCreateInput DeletePreventableCreateInput @@ -689,9 +720,25 @@ type ServerCreateInput struct { // 指定用于新建主机的主机镜像ID GuestImageID string `json:"guest_image_id"` + // Kickstart/Autoinstall自动化安装配置 + // required: false + KickstartConfig *KickstartConfig `json:"kickstart_config,omitempty"` + Pod *PodCreateInput `json:"pod"` } +// ServerUpdateKickstartStatusInput 更新虚拟机 kickstart 状态的输入 +type ServerUpdateKickstartStatusInput struct { + // kickstart 状态 + // enum: kickstart_pending,kickstart_installing,kickstart_completed,kickstart_failed + // required: true + Status string `json:"status" validate:"required,oneof=kickstart_pending kickstart_installing kickstart_completed kickstart_failed"` + + // 错误信息(可选) + // required: false + ErrorMessage string `json:"error_message,omitempty"` +} + func (input *ServerCreateInput) AfterUnmarshal() { if input.Baremetal { input.Hypervisor = HYPERVISOR_BAREMETAL diff --git a/pkg/apis/compute/guest_const.go b/pkg/apis/compute/guest_const.go index f2bd9f5273..4e2cd0904d 100644 --- a/pkg/apis/compute/guest_const.go +++ b/pkg/apis/compute/guest_const.go @@ -180,6 +180,12 @@ const ( VM_QGA_SET_NETWORK = "qga_set_network" VM_QGA_SET_NETWORK_FAILED = "qga_set_network_failed" + // Kickstart自动安装相关状态 + VM_KICKSTART_PENDING = "kickstart_pending" // kickstart配置完成,等待VM启动 + VM_KICKSTART_INSTALLING = "kickstart_installing" // kickstart正在安装 + VM_KICKSTART_COMPLETED = "kickstart_completed" // kickstart安装完成,准备转为running + VM_KICKSTART_FAILED = "kickstart_failed" // kickstart安装失败 + SHUTDOWN_STOP = "stop" SHUTDOWN_TERMINATE = "terminate" SHUTDOWN_STOP_RELEASE_GPU = "stop_release_gpu" @@ -274,6 +280,7 @@ const ( var VM_RUNNING_STATUS = []string{VM_START_START, VM_STARTING, VM_RUNNING, VM_BLOCK_STREAM, VM_BLOCK_STREAM_FAIL} var VM_CREATING_STATUS = []string{VM_CREATE_NETWORK, VM_CREATE_DISK, VM_START_DEPLOY, VM_DEPLOYING} +var VM_KICKSTART_STATUS = []string{VM_KICKSTART_PENDING, VM_KICKSTART_INSTALLING, VM_KICKSTART_COMPLETED, VM_KICKSTART_FAILED} var HYPERVISORS = []string{ HYPERVISOR_KVM, @@ -349,8 +356,31 @@ const ( VM_METADATA_CPU_NUMA_PIN_TYPE = "__cpu_numa_pin_type" VM_METADATA_VIRTUAL_ISOLATED_DEVICE_CONFIG = "__virtual_isolated_device_config" + // Kickstart相关metadata常量 + VM_METADATA_KICKSTART_CONFIG = "kickstart_config" + VM_METADATA_KICKSTART_TYPE = "kickstart_type" + VM_METADATA_KICKSTART_ATTEMPT = "kickstart_attempt" + VM_METADATA_KICKSTART_COMPLETED_FLAG = "kickstart_completed_flag" ) +const ( + KICKSTART_TYPE_URL = "url" + KICKSTART_TYPE_CONTENT = "content" +) + +var KICKSTART_VALID_TYPES = []string{ + KICKSTART_TYPE_URL, + KICKSTART_TYPE_CONTENT, +} + +var KICKSTART_VALID_OS_TYPES = []string{ + "centos", + "rhel", + "fedora", + "ubuntu", + "openeuler", +} + // windows allow a maximal length of 15 // http://support.microsoft.com/kb/909264 const MAX_WINDOWS_COMPUTER_NAME_LENGTH = 15 diff --git a/pkg/compute/guestdrivers/base.go b/pkg/compute/guestdrivers/base.go index 0ab71c3596..6e50cf4ea4 100644 --- a/pkg/compute/guestdrivers/base.go +++ b/pkg/compute/guestdrivers/base.go @@ -719,7 +719,7 @@ func (base *SBaseGuestDriver) RequestUploadGuestStatus(ctx context.Context, gues } func (base *SBaseGuestDriver) CanStop(guest *models.SGuest) error { - if utils.IsInStringArray(guest.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED, api.POD_STATUS_CRASH_LOOP_BACK_OFF, api.POD_STATUS_CONTAINER_EXITED}) { + if utils.IsInStringArray(guest.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED, api.POD_STATUS_CRASH_LOOP_BACK_OFF, api.POD_STATUS_CONTAINER_EXITED, api.VM_KICKSTART_INSTALLING, api.VM_KICKSTART_FAILED, api.VM_KICKSTART_COMPLETED}) { return nil } return errors.Wrapf(errors.ErrInvalidStatus, "Cannot stop server in status %s", guest.Status) diff --git a/pkg/compute/models/guest_actions.go b/pkg/compute/models/guest_actions.go index da2013803b..f6d5aac25b 100644 --- a/pkg/compute/models/guest_actions.go +++ b/pkg/compute/models/guest_actions.go @@ -1202,7 +1202,8 @@ func (self *SGuest) PerformStart( query jsonutils.JSONObject, input api.GuestPerformStartInput, ) (jsonutils.JSONObject, error) { - if utils.IsInStringArray(self.Status, []string{api.VM_READY, api.VM_START_FAILED, api.VM_SAVE_DISK_FAILED, api.VM_SUSPEND}) { + validStartStatuses := []string{api.VM_READY, api.VM_START_FAILED, api.VM_SAVE_DISK_FAILED, api.VM_SUSPEND, api.VM_KICKSTART_PENDING, api.VM_KICKSTART_FAILED} + if utils.IsInStringArray(self.Status, validStartStatuses) { if err := self.ValidateEncryption(ctx, userCred); err != nil { return nil, errors.Wrap(httperrors.ErrForbidden, "encryption key not accessible") } @@ -1429,6 +1430,38 @@ func (self *SGuest) GetDetailsIso(ctx context.Context, userCred mcclient.TokenCr return desc, nil } +// 获取Kickstart信息 +func (self *SGuest) GetDetailsKickstart(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (jsonutils.JSONObject, error) { + result := jsonutils.NewDict() + + kickstartConfig, err := self.GetKickstartConfig(ctx, userCred) + if err != nil { + return nil, httperrors.NewInternalServerError("Failed to get kickstart config: %v", err) + } + + status := self.GetKickstartStatus(ctx, userCred) + + attempt := self.GetMetadata(ctx, api.VM_METADATA_KICKSTART_ATTEMPT, userCred) + if attempt == "" { + attempt = "0" + } + + kickstartType := self.GetKickstartType(ctx, userCred) + + if kickstartConfig != nil { + configDict := jsonutils.Marshal(kickstartConfig) + result.Set("config", configDict) + } else { + result.Set("config", jsonutils.NewDict()) + } + + result.Set("status", jsonutils.NewString(status)) + result.Set("attempt", jsonutils.NewString(attempt)) + result.Set("type", jsonutils.NewString(kickstartType)) + + return result, nil +} + // 挂载ISO镜像 func (self *SGuest) PerformInsertiso(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) { if !utils.IsInStringArray(self.Hypervisor, []string{api.HYPERVISOR_KVM, api.HYPERVISOR_BAREMETAL}) { @@ -3592,7 +3625,7 @@ func (self *SGuest) PerformReset(ctx context.Context, userCred mcclient.TokenCre return nil, err } isHard := jsonutils.QueryBoolean(data, "is_hard", false) - if self.Status == api.VM_RUNNING || self.Status == api.VM_STOP_FAILED { + if utils.IsInStringArray(self.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED, api.VM_KICKSTART_INSTALLING, api.VM_KICKSTART_FAILED, api.VM_KICKSTART_COMPLETED}) { drv.StartGuestResetTask(self, ctx, userCred, isHard, "") return nil, nil } @@ -3688,6 +3721,11 @@ func (g *SGuest) SetStatusFromHost(ctx context.Context, userCred mcclient.TokenC statusStr = api.VM_UNKNOWN } } + // Do not override kickstart_installing with running + if statusStr == api.VM_RUNNING && g.Status == api.VM_KICKSTART_INSTALLING { + statusStr = g.Status + log.Infof("guest %s is installing, skip set running status", g.Name) + } if !hasParentTask { // migrating status hack // not change migrating when: @@ -3778,6 +3816,12 @@ func (self *SGuest) PerformStatus(ctx context.Context, userCred mcclient.TokenCr } } + // Do not override kickstart_installing with running + if input.Status == api.VM_RUNNING && self.Status == api.VM_KICKSTART_INSTALLING { + log.Debugf("guest %s is in kickstart_installing state, skip set running status", self.Name) + return nil, nil + } + preStatus := self.Status _, err := self.SVirtualResourceBase.PerformStatus(ctx, userCred, query, input.PerformStatusInput) if err != nil { @@ -3876,7 +3920,7 @@ func (self *SGuest) StartGuestStopAndFreezeTask(ctx context.Context, userCred mc // 重启 func (self *SGuest) PerformRestart(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) { isForce := jsonutils.QueryBoolean(data, "is_force", false) - if utils.IsInStringArray(self.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED}) || (isForce && self.Status == api.VM_STOPPING) { + if utils.IsInStringArray(self.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED, api.VM_KICKSTART_INSTALLING, api.VM_KICKSTART_FAILED, api.VM_KICKSTART_COMPLETED}) || (isForce && self.Status == api.VM_STOPPING) { driver, err := self.GetDriver() if err != nil { return nil, err @@ -7165,3 +7209,169 @@ func (self *SGuest) PerformDisableAutoMergeSnapshots(ctx context.Context, userCr disableAutoMergeSnapshot := jsonutils.QueryBoolean(data, "disable_auto_merge_snapshot", false) return nil, self.SetMetadata(ctx, api.VM_METADATA_DISABLE_AUTO_MERGE_SNAPSHOT, disableAutoMergeSnapshot, userCred) } + +func (self *SGuest) PerformSetKickstart(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.KickstartConfig) (jsonutils.JSONObject, error) { + // Check if kickstart has already been completed + currentFlag := self.GetMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, userCred) + if currentFlag == "true" { + return jsonutils.Marshal(map[string]string{ + "message": "Kickstart has already been completed for this VM", + "status": "failed", + }), nil + } + + if err := self.SetKickstartConfig(ctx, &input, userCred); err != nil { + return nil, errors.Wrap(err, "set kickstart config") + } + + // Determine and set kickstart type based on input + kickstartType := determineKickstartType(&input) + + if err := self.SetKickstartType(ctx, kickstartType, userCred); err != nil { + return nil, errors.Wrap(err, "set kickstart type") + } + + if err := self.SetMetadata(ctx, api.VM_METADATA_KICKSTART_ATTEMPT, "0", userCred); err != nil { + return nil, errors.Wrap(err, "set kickstart attempt") + } + + db.OpsLog.LogEvent(self, db.ACT_UPDATE, "set kickstart config", userCred) + + // Check if VM needs to be restarted + needRestart := false + if utils.IsInStringArray(self.Status, []string{ + api.VM_RUNNING, + api.VM_KICKSTART_INSTALLING, + api.VM_KICKSTART_COMPLETED, + api.VM_KICKSTART_FAILED, + api.VM_BLOCK_STREAM, + api.VM_MIGRATING, + }) { + needRestart = true + } + + if err := self.SetKickstartStatus(ctx, api.VM_KICKSTART_PENDING, userCred); err != nil { + return nil, errors.Wrap(err, "set kickstart status") + } + + // If VM is running, restart it to apply kickstart config + if needRestart { + driver, err := self.GetDriver() + if err != nil { + return nil, errors.Wrap(err, "get driver for restart") + } + + if err := driver.StartGuestRestartTask(self, ctx, userCred, false, "kickstart config updated"); err != nil { + return nil, errors.Wrap(err, "start restart task") + } + + return jsonutils.Marshal(map[string]string{ + "status": "success", + "message": "kickstart config updated, restarting VM", + }), nil + } + + return jsonutils.Marshal(map[string]string{ + "status": "success", + }), nil +} + +func (self *SGuest) PerformKickstartComplete(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input jsonutils.JSONObject) (jsonutils.JSONObject, error) { + // Check if kickstart_completed_flag is already set to true + currentFlag := self.GetMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, userCred) + if currentFlag == "true" { + return jsonutils.Marshal(map[string]string{ + "status": "success", + "message": "kickstart is already marked as completed", + }), nil + } + + // Set the kickstart_completed_flag metadata to true + if err := self.SetMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, "true", userCred); err != nil { + return nil, errors.Wrap(err, "set kickstart completed flag") + } + + // Update status to VM_KICKSTART_COMPLETED if in kickstart status + if self.IsInKickstartStatus() && self.Status != api.VM_KICKSTART_COMPLETED { + if err := self.SetStatus(ctx, userCred, api.VM_KICKSTART_COMPLETED, "kickstart manually marked as completed"); err != nil { + return nil, errors.Wrap(err, "update kickstart status") + } + } + + // Check if restart is requested (default is true) + restart := jsonutils.QueryBoolean(input, "restart", true) + if restart { + // Trigger VM restart to complete the kickstart process + if utils.IsInStringArray(self.Status, []string{api.VM_RUNNING, api.VM_KICKSTART_INSTALLING, api.VM_KICKSTART_COMPLETED, api.VM_KICKSTART_FAILED}) { + driver, err := self.GetDriver() + if err != nil { + return nil, errors.Wrap(err, "get driver") + } + driver.StartGuestRestartTask(self, ctx, userCred, false, "") + return jsonutils.Marshal(map[string]string{ + "status": "success", + "message": "kickstart marked as completed, restarting VM", + }), nil + } + } + + db.OpsLog.LogEvent(self, db.ACT_UPDATE, "kickstart manually marked as completed", userCred) + + return jsonutils.Marshal(map[string]string{ + "status": "success", + "message": "kickstart marked as completed", + }), nil +} + +func (self *SGuest) PerformDeleteKickstart(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, data jsonutils.JSONObject) (jsonutils.JSONObject, error) { + if self.Status == api.VM_KICKSTART_INSTALLING { + return nil, httperrors.NewInvalidStatusError("cannot delete kickstart config while installation is in progress") + } + + if err := self.SetKickstartConfig(ctx, nil, userCred); err != nil { + return nil, errors.Wrap(err, "delete kickstart config") + } + + // 如果当前是kickstart状态,需要转换回适当的状态 + if self.IsInKickstartStatus() { + if self.Status == api.VM_KICKSTART_PENDING { + self.SetStatus(ctx, userCred, api.VM_READY, "kickstart config deleted") + } else if self.Status == api.VM_KICKSTART_COMPLETED { + self.SetStatus(ctx, userCred, api.VM_RUNNING, "kickstart config deleted") + } else if self.Status == api.VM_KICKSTART_FAILED { + self.SetStatus(ctx, userCred, api.VM_READY, "kickstart config deleted") + } + } + + // 清理kickstart相关metadata + self.RemoveMetadata(ctx, api.VM_METADATA_KICKSTART_ATTEMPT, userCred) + self.RemoveMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, userCred) + + db.OpsLog.LogEvent(self, db.ACT_DELETE, "delete kickstart config", userCred) + + return jsonutils.Marshal(map[string]string{ + "status": "success", + }), nil +} + +func (self *SGuest) PerformUpdateKickstartStatus(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.ServerUpdateKickstartStatusInput) (jsonutils.JSONObject, error) { + if err := self.SetKickstartStatus(ctx, input.Status, userCred); err != nil { + return nil, errors.Wrap(err, "update kickstart status") + } + + if input.Status == api.VM_KICKSTART_COMPLETED { + if err := self.SetMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, "true", userCred); err != nil { + log.Errorf("Failed to set kickstart completed flag for VM %s: %v", self.Name, err) + } + } + + if input.ErrorMessage != "" { + db.OpsLog.LogEvent(self, db.ACT_UPDATE, fmt.Sprintf("kickstart status: %s, error: %s", input.Status, input.ErrorMessage), userCred) + } else { + db.OpsLog.LogEvent(self, db.ACT_UPDATE, fmt.Sprintf("kickstart status: %s", input.Status), userCred) + } + + return jsonutils.Marshal(map[string]string{ + "status": input.Status, + }), nil +} diff --git a/pkg/compute/models/guests.go b/pkg/compute/models/guests.go index fd4ff47170..28a9958654 100644 --- a/pkg/compute/models/guests.go +++ b/pkg/compute/models/guests.go @@ -19,7 +19,10 @@ import ( "context" "database/sql" "fmt" + "net/http" + "net/url" "regexp" + "strconv" "strings" "time" @@ -2296,6 +2299,13 @@ func (manager *SGuestManager) validateCreateData( return nil, httperrors.NewInputParameterError("Invalid userdata: %v", err) } + // validate KickstartConfig + if input.KickstartConfig != nil { + if err := validateKickstartConfig(input.KickstartConfig); err != nil { + return nil, httperrors.NewInputParameterError("Invalid kickstart config: %v", err) + } + } + err = manager.ValidatePolicyDefinitions(ctx, userCred, ownerId, query, input) if err != nil { return nil, err @@ -2396,6 +2406,125 @@ func (manager *SGuestManager) ValidateCreateData(ctx context.Context, userCred m return input.JSON(input), nil } +func validateKickstartConfig(config *api.KickstartConfig) error { + if config.OSType == "" { + return fmt.Errorf("os_type is required") + } + + if !utils.IsInStringArray(config.OSType, api.KICKSTART_VALID_OS_TYPES) { + return fmt.Errorf("unsupported os_type: %s, supported types: %v", config.OSType, api.KICKSTART_VALID_OS_TYPES) + } + + // 验证配置内容和URL二选一 + if config.Config == "" && config.ConfigURL == "" { + return fmt.Errorf("either config or config_url must be provided") + } + + if config.Config != "" && config.ConfigURL != "" { + return fmt.Errorf("config and config_url cannot be both provided, choose one") + } + + if config.Config != "" { + const maxConfigSize = 64 * 1024 + if len(config.Config) > maxConfigSize { + return fmt.Errorf("config content too large: %d bytes, maximum allowed: %d bytes", len(config.Config), maxConfigSize) + } + if len(strings.TrimSpace(config.Config)) == 0 { + return fmt.Errorf("config content cannot be empty") + } + } + + if config.ConfigURL != "" { + if len(config.ConfigURL) > 2048 { + return fmt.Errorf("config URL too long: %d characters, maximum allowed: 2048", len(config.ConfigURL)) + } + if strings.TrimSpace(config.ConfigURL) == "" { + return fmt.Errorf("config URL cannot be empty") + } + + parsedURL, err := url.Parse(config.ConfigURL) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("invalid URL scheme: %s, only http and https are allowed", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return fmt.Errorf("URL must specify a host") + } + + if err := checkKickstartURLContentSize(config.ConfigURL); err != nil { + return fmt.Errorf("URL content validation failed: %v", err) + } + } + + // 设置默认值 + if config.Enabled == nil { + enabled := true + config.Enabled = &enabled + } + + if config.MaxRetries <= 0 { + config.MaxRetries = 3 + } + + if config.TimeoutMinutes <= 0 { + config.TimeoutMinutes = 60 + } + + return nil +} + +// determineKickstartType determines kickstart type based on config content +func determineKickstartType(config *api.KickstartConfig) string { + if config.Config != "" { + return api.KICKSTART_TYPE_CONTENT + } + return api.KICKSTART_TYPE_URL +} + +func checkKickstartURLContentSize(configURL string) error { + const maxURLContentSize = 64 * 1024 + const requestTimeout = 10 * time.Second + + client := &http.Client{Timeout: requestTimeout} + + req, err := http.NewRequest("HEAD", configURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + log.Warningf("Failed to check URL content size for %s: %v", configURL, err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("URL returned status code: %d", resp.StatusCode) + } + + contentLengthStr := resp.Header.Get("Content-Length") + if contentLengthStr != "" { + contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) + if err != nil { + log.Warningf("Failed to parse Content-Length header: %v", err) + return nil + } + + if contentLength > maxURLContentSize { + return fmt.Errorf("URL content too large: %d bytes, maximum allowed: %d bytes", contentLength, maxURLContentSize) + } + + log.Infof("URL content size validated: %d bytes", contentLength) + } else { + log.Warningf("URL %s does not provide Content-Length header, size validation skipped", configURL) + } + + return nil +} + func (manager *SGuestManager) validateEip(ctx context.Context, userCred mcclient.TokenCredential, input *api.ServerCreateInput, preferRegionId string, preferManagerId string) error { driver, err := GetDriver(input.Hypervisor, input.Provider) @@ -2671,6 +2800,34 @@ func (guest *SGuest) PostCreate(ctx context.Context, userCred mcclient.TokenCred } } } + // set kickstart metadata + kickstartConfigJson, _ := data.Get("kickstart_config") + if kickstartConfigJson != nil { + kickstartConfig := &api.KickstartConfig{} + if err := kickstartConfigJson.Unmarshal(kickstartConfig); err != nil { + log.Errorf("unmarshal kickstart config fail: %s", err) + } else { + if err := guest.SetKickstartConfig(ctx, kickstartConfig, userCred); err != nil { + log.Errorf("Failed to set kickstart config for guest %s: %v", guest.Name, err) + } else { + if err := guest.SetKickstartStatus(ctx, api.VM_KICKSTART_PENDING, userCred); err != nil { + log.Errorf("Failed to set kickstart status for guest %s: %v", guest.Name, err) + } + if err := guest.SetMetadata(ctx, api.VM_METADATA_KICKSTART_COMPLETED_FLAG, "false", userCred); err != nil { + log.Errorf("Failed to set kickstart completed flag for guest %s: %v", guest.Name, err) + } + + // Determine and set kickstart type based on config + kickstartType := determineKickstartType(kickstartConfig) + + if err := guest.SetKickstartType(ctx, kickstartType, userCred); err != nil { + log.Errorf("Failed to set kickstart type for guest %s: %v", guest.Name, err) + } + + log.Debugf("Successfully set kickstart config for guest %s with OS type %s", guest.Name, kickstartConfig.OSType) + } + } + } input := struct { PreferZone string @@ -7240,6 +7397,79 @@ func (guest *SGuest) SetGuestBackupMirrorJobInProgress(ctx context.Context, user return guest.SetMetadata(ctx, api.MIRROR_JOB, api.MIRROR_JOB_INPROGRESS, userCred) } +func (guest *SGuest) SetKickstartConfig(ctx context.Context, config *api.KickstartConfig, userCred mcclient.TokenCredential) error { + if config == nil { + return guest.RemoveMetadata(ctx, api.VM_METADATA_KICKSTART_CONFIG, userCred) + } + + if err := validateKickstartConfig(config); err != nil { + return errors.Wrap(err, "validate kickstart config") + } + + configJson := jsonutils.Marshal(config) + return guest.SetMetadata(ctx, api.VM_METADATA_KICKSTART_CONFIG, configJson, userCred) +} + +func (guest *SGuest) GetKickstartConfig(ctx context.Context, userCred mcclient.TokenCredential) (*api.KickstartConfig, error) { + configJson := guest.GetMetadataJson(ctx, api.VM_METADATA_KICKSTART_CONFIG, userCred) + if configJson == nil { + return nil, nil + } + + config := &api.KickstartConfig{} + if err := configJson.Unmarshal(config); err != nil { + return nil, errors.Wrap(err, "unmarshal kickstart config") + } + + return config, nil +} + +func (guest *SGuest) SetKickstartStatus(ctx context.Context, status string, userCred mcclient.TokenCredential) error { + if !utils.IsInStringArray(status, api.VM_KICKSTART_STATUS) { + return errors.Errorf("invalid kickstart status: %s", status) + } + return guest.SetStatus(ctx, userCred, status, "") +} + +func (guest *SGuest) GetKickstartStatus(ctx context.Context, userCred mcclient.TokenCredential) string { + if utils.IsInStringArray(guest.Status, api.VM_KICKSTART_STATUS) { + return guest.Status + } + return "" +} + +func (guest *SGuest) IsInKickstartStatus() bool { + return utils.IsInStringArray(guest.Status, api.VM_KICKSTART_STATUS) +} + +func (guest *SGuest) SetKickstartType(ctx context.Context, kickstartType string, userCred mcclient.TokenCredential) error { + if !utils.IsInStringArray(kickstartType, api.KICKSTART_VALID_TYPES) { + return errors.Errorf("invalid kickstart type: %s", kickstartType) + } + return guest.SetMetadata(ctx, api.VM_METADATA_KICKSTART_TYPE, kickstartType, userCred) +} + +func (guest *SGuest) GetKickstartType(ctx context.Context, userCred mcclient.TokenCredential) string { + kickstartType := guest.GetMetadata(ctx, api.VM_METADATA_KICKSTART_TYPE, userCred) + if kickstartType == "" { + return api.KICKSTART_TYPE_URL + } + return kickstartType +} + +func (guest *SGuest) IsKickstartEnabled(ctx context.Context, userCred mcclient.TokenCredential) bool { + config, err := guest.GetKickstartConfig(ctx, userCred) + if err != nil || config == nil { + return false + } + + if config.Enabled == nil { + return true + } + + return *config.Enabled +} + func (guest *SGuest) SetGuestBackupMirrorJobNotReady(ctx context.Context, userCred mcclient.TokenCredential) error { return guest.SetMetadata(ctx, api.MIRROR_JOB, "", userCred) } diff --git a/pkg/hostman/guestman/kickstart_helper.go b/pkg/hostman/guestman/kickstart_helper.go new file mode 100644 index 0000000000..5ea881a4b2 --- /dev/null +++ b/pkg/hostman/guestman/kickstart_helper.go @@ -0,0 +1,713 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package guestman + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "runtime/debug" + "strings" + "time" + + "gopkg.in/yaml.v3" + "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/errors" + + api "yunion.io/x/onecloud/pkg/apis/compute" + "yunion.io/x/onecloud/pkg/hostman/hostutils" + modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute" + "yunion.io/x/onecloud/pkg/util/fileutils2" + "yunion.io/x/onecloud/pkg/util/mountutils" +) + +const ( + KICKSTART_MONITOR_TIMEOUT = 60 * time.Minute + SERIAL_FILE_CHECK_INTERVAL = 5 * time.Second + KICKSTART_BASE_DIR = "/tmp/kickstart" + KICKSTART_ISO_MOUNT_DIR = "iso-mount" + KICKSTART_ISO_BUILD_DIR = "iso-build" + KICKSTART_ISO_FILENAME = "config.iso" + REDHAT_KICKSTART_ISO_VOLUME_LABEL = "OEMDRV" + UBUNTU_KICKSTART_ISO_VOLUME_LABEL = "CIDATA" +) + +var ( + kickstartInstallingRegex = regexp.MustCompile(`(?i).*KICKSTART_INSTALLING.*`) + kickstartCompletedRegex = regexp.MustCompile(`(?i).*KICKSTART_COMPLETED.*`) + kickstartFailedRegex = regexp.MustCompile(`(?i).*KICKSTART_FAILED.*`) +) + +// ensureKickstartCompletionSignal ensures that the kickstart config contains the completion signal +// 1. If already contains completion signal, return as is +// 1. If no %post section, append one with completion signal +// 2. If %post exists but no %end, append completion signal before end of file +// 3. If both %post and %end exist, insert completion signal before %end +func ensureKickstartCompletionSignal(config string) string { + completionSignals := []string{ + "echo \"KICKSTART_COMPLETED\" > /dev/ttyS1", + "echo 'KICKSTART_COMPLETED' > /dev/ttyS1", + "echo KICKSTART_COMPLETED > /dev/ttyS1", + } + + for _, signal := range completionSignals { + if strings.Contains(config, signal) { + return config + } + } + + const standardCompletionSignal = "echo \"KICKSTART_COMPLETED\" > /dev/ttyS1" + + postIdx := strings.Index(config, "%post") + if postIdx == -1 { + return config + fmt.Sprintf("\n\n%%post\n%s\n%%end", standardCompletionSignal) + } + + endIdx := strings.Index(config[postIdx:], "%end") + if endIdx == -1 { + return config + fmt.Sprintf("\n%s\n%%end", standardCompletionSignal) + } + + // Find line index of %end within %post section + lines := strings.Split(config, "\n") + absoluteEndIdx := postIdx + endIdx + endLineIdx := -1 + currentPos := 0 + + for i, line := range lines { + lineEndPos := currentPos + len(line) + 1 + if currentPos <= absoluteEndIdx && absoluteEndIdx < lineEndPos { + if strings.TrimSpace(line) == "%end" { + endLineIdx = i + break + } + } + currentPos = lineEndPos + } + + if endLineIdx != -1 { + newLines := make([]string, len(lines)+1) + copy(newLines, lines[:endLineIdx]) + newLines[endLineIdx] = standardCompletionSignal + copy(newLines[endLineIdx+1:], lines[endLineIdx:]) + return strings.Join(newLines, "\n") + } + + return config +} + +// ensureAutoinstallCompletionSignal ensures that the autoinstall config contains the completion signal +// 1. If already contains completion signal, return as is +// 2. If no late-commands section, create one with completion signal +// 3. If late-commands exists but no completion signal, append completion signal +func ensureAutoinstallCompletionSignal(config string) string { + const standardCompletionSignal = "echo \"KICKSTART_COMPLETED\" > /dev/ttyS1" + + completionSignals := []string{ + "echo \"KICKSTART_COMPLETED\" > /dev/ttyS1", + "echo 'KICKSTART_COMPLETED' > /dev/ttyS1", + "echo KICKSTART_COMPLETED > /dev/ttyS1", + } + + // Check if completion signal already exists + for _, signal := range completionSignals { + if strings.Contains(config, signal) { + return config + } + } + + // Parse YAML using string keys, use yaml.v3 for better compatibility + var yamlData map[string]interface{} + err := yaml.Unmarshal([]byte(config), &yamlData) + if err != nil { + log.Warningf("Failed to parse autoinstall config as YAML: %v\n", err) + return config + } + + autoinstall, ok := yamlData["autoinstall"].(map[string]interface{}) + if !ok { + log.Warningf("No autoinstall section found in config\n") + return config + } + + lateCommands, exists := autoinstall["late-commands"] + if !exists { + autoinstall["late-commands"] = []interface{}{standardCompletionSignal} + } else { + if commands, ok := lateCommands.([]interface{}); ok { + autoinstall["late-commands"] = append(commands, standardCompletionSignal) + } else { + autoinstall["late-commands"] = []interface{}{standardCompletionSignal} + } + } + + result, err := yaml.Marshal(yamlData) + if err != nil { + log.Warningf("Failed to marshal autoinstall config as YAML: %v\n", err) + return config + } + + // #cloud-config is required at the top if originally present + return "#cloud-config\n" + string(result) +} + +// ensureCompletionSignal ensures that the configuration contains the completion signal based on OS type +func ensureCompletionSignal(osType, config string) string { + switch osType { + case "centos", "rhel", "fedora", "openeuler": + return ensureKickstartCompletionSignal(config) + case "ubuntu": + return ensureAutoinstallCompletionSignal(config) + default: + return config // No validation for unsupported OS types + } +} + +type SKickstartSerialMonitor struct { + serverId string + serialFilePath string + + ctx context.Context + cancel context.CancelFunc +} + +// NewKickstartSerialMonitor creates a new kickstart serial monitor +func NewKickstartSerialMonitor(serverId string) *SKickstartSerialMonitor { + ctx, cancel := context.WithCancel(context.Background()) + + // Get the server instance to access its homeDir + var serialFilePath string + server, exists := guestManager.GetServer(serverId) + if exists { + serialFilePath = path.Join(server.HomeDir(), "kickstart-serial.log") + } else { + // Fallback to use /tmp if server not found + serialFilePath = fmt.Sprintf("/tmp/kickstart-serial-%s.log", serverId) + } + + return &SKickstartSerialMonitor{ + serverId: serverId, + serialFilePath: serialFilePath, + ctx: ctx, + cancel: cancel, + } +} + +func (m *SKickstartSerialMonitor) GetSerialFilePath() string { + return m.serialFilePath +} + +// getKickstartTimeout gets kickstart timeout from kickstart config, defaults to KICKSTART_MONITOR_TIMEOUT +func (m *SKickstartSerialMonitor) getKickstartTimeout() time.Duration { + server, exists := guestManager.GetServer(m.serverId) + if !exists { + return KICKSTART_MONITOR_TIMEOUT + } + + kvmGuest, ok := server.(*SKVMGuestInstance) + if !ok { + return KICKSTART_MONITOR_TIMEOUT + } + + kickstartConfigStr, exists := kvmGuest.Desc.Metadata[api.VM_METADATA_KICKSTART_CONFIG] + if !exists || kickstartConfigStr == "" { + return KICKSTART_MONITOR_TIMEOUT + } + + configObj, err := jsonutils.ParseString(kickstartConfigStr) + if err != nil { + return KICKSTART_MONITOR_TIMEOUT + } + + var config api.KickstartConfig + if err := configObj.Unmarshal(&config); err != nil { + return KICKSTART_MONITOR_TIMEOUT + } + + if config.TimeoutMinutes <= 0 { + return KICKSTART_MONITOR_TIMEOUT + } + + timeout := time.Duration(config.TimeoutMinutes) * time.Minute + log.Debugf("Using kickstart timeout %v for server %s", timeout, m.serverId) + return timeout +} + +func (m *SKickstartSerialMonitor) ensureSerialFile() error { + if _, err := os.Stat(m.serialFilePath); os.IsNotExist(err) { + file, err := os.Create(m.serialFilePath) + if err != nil { + return errors.Wrapf(err, "create serial file %s", m.serialFilePath) + } + file.Close() + log.Debugf("Created kickstart serial file %s for server %s", m.serialFilePath, m.serverId) + } + return nil +} + +func (m *SKickstartSerialMonitor) monitorSerialFile() { + defer func() { + if r := recover(); r != nil { + log.Errorf("KickstartSerialMonitor monitor %v %s", r, debug.Stack()) + } + }() + + var lastSize int64 = 0 + + ticker := time.NewTicker(SERIAL_FILE_CHECK_INTERVAL) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + if err := m.scanSerialForStatus(&lastSize); err != nil { + log.Errorf("Failed to scan serial file for status for server %s: %v", m.serverId, err) + } + } + } +} + +// scanSerialForStatus scans the serial file for a kickstart status update. +// It reads new content since the last check, parses it for status keywords, +// and triggers status updates and cleanup when a final status is detected. +func (m *SKickstartSerialMonitor) scanSerialForStatus(lastSize *int64) error { + fileInfo, err := os.Stat(m.serialFilePath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + currentSize := fileInfo.Size() + if currentSize <= *lastSize { + return nil + } + + // Read new content + file, err := os.Open(m.serialFilePath) + if err != nil { + return err + } + defer file.Close() + + _, err = file.Seek(*lastSize, 0) + if err != nil { + return err + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if len(line) == 0 { + continue + } + + var status string + var shouldClose bool = false + var matched bool = false + + if kickstartInstallingRegex.MatchString(line) { + status = api.VM_KICKSTART_INSTALLING + shouldClose = false + matched = true + } else if kickstartCompletedRegex.MatchString(line) { + status = api.VM_KICKSTART_COMPLETED + shouldClose = true + matched = true + // Unmount ISO and restart VM if kickstart install successfully + if err := m.handleKickstartCompleted(); err != nil { + log.Errorf("Failed to handle kickstart success for server %s: %v", m.serverId, err) + } + } else if kickstartFailedRegex.MatchString(line) { + // TODO: auto retry or alert and stop + status = api.VM_KICKSTART_FAILED + shouldClose = true + matched = true + } + + if matched { + log.Infof("Kickstart status update for server %s: %s", m.serverId, status) + + if err := m.updateKickstartStatus(status); err != nil { + log.Errorf("Failed to update kickstart status for server %s: %v", m.serverId, err) + } else { + if shouldClose { + m.Close() + return nil + } + } + } + } + + *lastSize = currentSize + return scanner.Err() +} + +func (m *SKickstartSerialMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + + if m.serialFilePath != "" { + if err := os.Remove(m.serialFilePath); err != nil && !os.IsNotExist(err) { + log.Warningf("Failed to remove serial file %s: %v", m.serialFilePath, err) + } + } + + log.Debugf("Kickstart monitor closed for server %s", m.serverId) + return nil +} + +// updateKickstartStatus updates the kickstart status via Region API +func (m *SKickstartSerialMonitor) updateKickstartStatus(status string) error { + ctx := context.Background() + session := hostutils.GetComputeSession(ctx) + + input := api.ServerUpdateKickstartStatusInput{ + Status: status, + } + + _, err := modules.Servers.PerformAction(session, m.serverId, "update-kickstart-status", jsonutils.Marshal(input)) + if err != nil { + return errors.Wrapf(err, "failed to update kickstart status for server %s", m.serverId) + } + return nil +} + +// Start starts the kickstart serial monitor +func (m *SKickstartSerialMonitor) Start() error { + log.Debugf("Starting kickstart monitor for server %s, serial file: %s", m.serverId, m.serialFilePath) + + if err := m.ensureSerialFile(); err != nil { + return errors.Wrap(err, "ensure serial file") + } + + go m.monitorSerialFile() + + // Setup timeout handler + go func() { + timeout := m.getKickstartTimeout() + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-m.ctx.Done(): + return + case <-timer.C: + log.Warningf("Kickstart monitor timeout (%v) for server %s, setting status to failed", timeout, m.serverId) + if err := m.updateKickstartStatus(api.VM_KICKSTART_FAILED); err != nil { + log.Errorf("Failed to update kickstart status to failed on timeout for server %s: %v", m.serverId, err) + } + m.Close() + } + }() + + return nil +} + +// handleKickstartCompleted handles the successful kickstart completion +// It unmounts the ISO image and restarts the VM +func (m *SKickstartSerialMonitor) handleKickstartCompleted() error { + // Clean up kickstart files after successful installation + CleanupKickstartFiles(m.serverId) + + log.Debugf("Restarting VM %s after successful kickstart", m.serverId) + + if err := m.restartServer(); err != nil { + return errors.Wrapf(err, "failed to restart server %s", m.serverId) + } + + return nil +} + +// restartServer restarts the server using Region API, +// because the kickstart process requires a fully reboot +// to regenerate qemu parameters +func (m *SKickstartSerialMonitor) restartServer() error { + ctx := context.Background() + session := hostutils.GetComputeSession(ctx) + + input := jsonutils.NewDict() + input.Set("is_force", jsonutils.JSONFalse) + + _, err := modules.Servers.PerformAction(session, m.serverId, "restart", input) + if err != nil { + return errors.Wrapf(err, "failed to restart server %s via API", m.serverId) + } + + log.Infof("Server %s restarted after kickstart completion", m.serverId) + return nil +} + +// downloadKickstartConfigFromURL downloads kickstart configuration content from the given URL +func downloadKickstartConfigFromURL(configURL string, osType string) (string, error) { + const downloadTimeout = 10 * time.Second + const maxConfigSize = 64 * 1024 + + // For Ubuntu systems, append "/user-data" to the URL + if osType == "ubuntu" && !strings.HasSuffix(configURL, "/user-data") { + configURL = configURL + "/user-data" + } + + client := &http.Client{Timeout: downloadTimeout} + + req, err := http.NewRequest("GET", configURL, nil) + if err != nil { + return "", errors.Wrapf(err, "failed to create download request for %s", configURL) + } + + resp, err := client.Do(req) + if err != nil { + return "", errors.Wrapf(err, "failed to download config from %s", configURL) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errors.Errorf("download failed: URL returned status code: %d", resp.StatusCode) + } + + limitedReader := io.LimitReader(resp.Body, int64(maxConfigSize)+1) + content, err := io.ReadAll(limitedReader) + if err != nil { + return "", errors.Wrapf(err, "failed to read config content from %s", configURL) + } + + if len(content) > maxConfigSize { + return "", errors.Errorf("downloaded content too large: %d bytes, maximum allowed: %d bytes", len(content), maxConfigSize) + } + + if len(strings.TrimSpace(string(content))) == 0 { + return "", errors.Error("downloaded config content is empty") + } + + log.Debugf("Successfully downloaded kickstart config from %s: %d bytes", configURL, len(content)) + return string(content), nil +} + +// CreateKickstartConfigISO creates an ISO image containing kickstart configuration files +// For Red Hat systems: creates ks.cfg in a ISO with label 'OEMDRV' +// For Ubuntu systems: creates user-data and meta-data files in a ISO with label 'CIDATA' +// reference: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/10/html/automatically_installing_rhel/starting-kickstart-installations#starting-a-kickstart-installation-automatically-using-a-local-volume +// https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/10/html/automatically_installing_rhel/starting-kickstart-installations#starting-a-kickstart-installation-automatically-using-a-local-volume +func CreateKickstartConfigISO(config *api.KickstartConfig, serverId string) (string, error) { + log.Debugf("Creating kickstart ISO for server %s with OS type %s", serverId, config.OSType) + + if config == nil { + return "", errors.Errorf("kickstart config is nil") + } + + // Download config content from URL if necessary + if config.Config == "" && config.ConfigURL != "" { + log.Debugf("Downloading kickstart config from URL: %s", config.ConfigURL) + downloadedContent, err := downloadKickstartConfigFromURL(config.ConfigURL, config.OSType) + if err != nil { + return "", errors.Wrapf(err, "failed to download kickstart config from URL") + } + config.Config = downloadedContent + log.Debugf("Kickstart config downloaded and set, length: %d", len(config.Config)) + } + + if config.Config == "" { + return "", errors.Errorf("kickstart config content is empty") + } + + // Ensure completion signal is present + validatedConfig := ensureCompletionSignal(config.OSType, config.Config) + + // Create temporary directory for ISO contents + tmpDir := filepath.Join(KICKSTART_BASE_DIR, serverId, KICKSTART_ISO_BUILD_DIR) + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return "", errors.Wrapf(err, "failed to create temp directory %s", tmpDir) + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + log.Warningf("Failed to cleanup temp directory %s: %v", tmpDir, err) + } + }() + + var filePaths []string + var volumeLabel string + + switch config.OSType { + case "centos", "rhel", "fedora", "openeuler": + // Create anaconda-ks.cfg for Red Hat systems + ksFilePath := filepath.Join(tmpDir, "anaconda-ks.cfg") + if err := os.WriteFile(ksFilePath, []byte(validatedConfig), 0644); err != nil { + return "", errors.Wrapf(err, "failed to write kickstart file %s", ksFilePath) + } + filePaths = []string{ksFilePath} + volumeLabel = REDHAT_KICKSTART_ISO_VOLUME_LABEL + + case "ubuntu": + // Create user-data file for Ubuntu systems + userDataPath := filepath.Join(tmpDir, "user-data") + if err := os.WriteFile(userDataPath, []byte(validatedConfig), 0644); err != nil { + return "", errors.Wrapf(err, "failed to write user-data file %s", userDataPath) + } + + // Create empty meta-data file + metaDataPath := filepath.Join(tmpDir, "meta-data") + if err := os.WriteFile(metaDataPath, []byte(""), 0644); err != nil { + return "", errors.Wrapf(err, "failed to write meta-data file %s", metaDataPath) + } + filePaths = []string{userDataPath, metaDataPath} + volumeLabel = UBUNTU_KICKSTART_ISO_VOLUME_LABEL + + default: + return "", errors.Errorf("unsupported OS type: %s", config.OSType) + } + + // Create ISO using mkisofs + isoPath := filepath.Join(KICKSTART_BASE_DIR, serverId, KICKSTART_ISO_FILENAME) + args := []string{ + "-o", isoPath, + "-V", volumeLabel, + "-r", + "-J", + } + args = append(args, filePaths...) + + cmd := exec.Command("mkisofs", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return "", errors.Wrapf(err, "mkisofs failed: %s", string(output)) + } + + log.Debugf("Successfully created kickstart ISO: %s", isoPath) + return isoPath, nil +} + +// ComputeKickstartKernelInitrdPaths returns absolute kernel and initrd paths by combining +// mountPath with OS-specific relative paths and validating that files exist. +// GetKernelInitrdPaths resolves absolute kernel and initrd paths under a mounted ISO. +func GetKernelInitrdPaths(mountPath, osType string) (string, string, error) { + var kernelRelPath, initrdRelPath string + switch osType { + case "centos", "rhel", "fedora", "openeuler": + kernelRelPath = "images/pxeboot/vmlinuz" + initrdRelPath = "images/pxeboot/initrd.img" + case "ubuntu": + kernelRelPath = "casper/vmlinuz" + initrdRelPath = "casper/initrd" + default: + return "", "", errors.Errorf("unsupported OS type: %s", osType) + } + + kernelPath := path.Join(mountPath, kernelRelPath) + initrdPath := path.Join(mountPath, initrdRelPath) + + if !fileutils2.Exists(kernelPath) { + return "", "", errors.Errorf("kernel file not found: %s", kernelPath) + } + if !fileutils2.Exists(initrdPath) { + return "", "", errors.Errorf("initrd file not found: %s", initrdPath) + } + return kernelPath, initrdPath, nil +} + +// BuildKickstartAppendArgs builds kernel append args for kickstart/autoinstall +// based on OS type and whether a local config ISO is present. +// isoPath non-empty indicates a locally attached config ISO. +func BuildKickstartAppendArgs(config *api.KickstartConfig, isoPath string) string { + if config == nil { + return "" + } + baseArgs := []string{} + var kickstartArgs []string + switch config.OSType { + case "centos", "rhel", "fedora", "openeuler": + if isoPath != "" { + kickstartArgs = append(kickstartArgs, fmt.Sprintf("inst.ks=hd:LABEL=%s:/anaconda-ks.cfg", REDHAT_KICKSTART_ISO_VOLUME_LABEL)) + } else if config.ConfigURL != "" { + // Fallback to URL directly if no local ISO available + kickstartArgs = append(kickstartArgs, fmt.Sprintf("inst.ks=%s", config.ConfigURL)) + } else { + kickstartArgs = append(kickstartArgs, "inst.ks=cdrom:/anaconda-ks.cfg") + } + case "ubuntu": + if isoPath != "" { + kickstartArgs = append(kickstartArgs, "autoinstall") + } else if config.ConfigURL != "" { + // Fallback to URL directly if no local ISO available + kickstartArgs = append(kickstartArgs, + "autoinstall", + "ip=dhcp", + fmt.Sprintf("ds=nocloud-net;s=%s", config.ConfigURL), + ) + } else { + kickstartArgs = append(kickstartArgs, "autoinstall", "ip=dhcp", "ds=nocloud;s=/cdrom/") + } + } + return strings.Join(append(baseArgs, kickstartArgs...), " ") +} + +// CleanupKickstartFiles cleans up all kickstart-related temporary files and directories +func CleanupKickstartFiles(serverId string) { + if serverId == "" { + log.Warningf("Empty serverId provided for kickstart cleanup") + return + } + + kickstartDir := filepath.Join(KICKSTART_BASE_DIR, serverId) + + mountPoint := filepath.Join(kickstartDir, KICKSTART_ISO_MOUNT_DIR) + if fileutils2.Exists(mountPoint) { + log.Infof("Unmounting kickstart ISO at %s for server %s", mountPoint, serverId) + if err := mountutils.Unmount(mountPoint, true); err != nil { + log.Errorf("Failed to unmount kickstart ISO at %s: %v", mountPoint, err) + } else { + log.Debugf("Successfully unmounted kickstart ISO at %s for server %s", mountPoint, serverId) + } + } + + if server, exists := guestManager.GetServer(serverId); exists { + if kvmGuest, ok := server.(*SKVMGuestInstance); ok && len(kvmGuest.Desc.Cdroms) > 0 { + for _, cdrom := range kvmGuest.Desc.Cdroms { + if cdrom.Path != "" { + filename := path.Base(cdrom.Path) + if strings.HasPrefix(filename, "kickstart-") { + if err := os.Remove(cdrom.Path); err != nil && !os.IsNotExist(err) { + log.Errorf("Failed to cleanup kickstart ISO %s: %v", cdrom.Path, err) + } else { + log.Debugf("Successfully removed kickstart ISO %s for server %s", cdrom.Path, serverId) + } + break + } + } + } + } + } + + if fileutils2.Exists(kickstartDir) { + log.Infof("Removing kickstart directory %s for server %s", kickstartDir, serverId) + if err := os.RemoveAll(kickstartDir); err != nil { + log.Errorf("Failed to remove kickstart directory %s: %v", kickstartDir, err) + } else { + log.Debugf("Successfully removed kickstart directory %s for server %s", kickstartDir, serverId) + } + } + + log.Debugf("Kickstart files cleanup completed for server %s", serverId) +} diff --git a/pkg/hostman/guestman/qemu-kvm.go b/pkg/hostman/guestman/qemu-kvm.go index cd82f41aec..678f9fe771 100644 --- a/pkg/hostman/guestman/qemu-kvm.go +++ b/pkg/hostman/guestman/qemu-kvm.go @@ -111,8 +111,9 @@ type SKVMGuestInstance struct { SKVMInstanceRuntime *sBaseGuestInstance - Monitor monitor.Monitor - guestAgent *qga.QemuGuestAgent + Monitor monitor.Monitor + guestAgent *qga.QemuGuestAgent + kickstartMonitor *SKickstartSerialMonitor archMan arch.Arch } @@ -1278,6 +1279,8 @@ func (s *SKVMGuestInstance) onMonitorConnected(ctx context.Context) { s.Monitor.GetVersion(func(v string) { s.onGetQemuVersion(ctx, v) }) + + s.startKickstartMonitorIfNeeded() } func (s *SKVMGuestInstance) setDestMigrateTLS(ctx context.Context, data *jsonutils.JSONDict) { @@ -2092,6 +2095,7 @@ func (s *SKVMGuestInstance) ForceStop() bool { } func (s *SKVMGuestInstance) ExitCleanup(clear bool) { + s.cleanupKickstartMonitor() if clear { pid := s.GetPid() if pid > 0 { @@ -2192,6 +2196,8 @@ func (s *SKVMGuestInstance) delFlatFiles(ctx context.Context) error { } func (s *SKVMGuestInstance) Delete(ctx context.Context, migrated, recycle bool) error { + CleanupKickstartFiles(s.Id) + if err := s.delTmpDisks(ctx, migrated); err != nil { return errors.Wrap(err, "delTmpDisks") } @@ -3752,3 +3758,34 @@ func (s *SKVMGuestInstance) HandleGuestStatus(ctx context.Context, resp *api.Hos } return resp } + +func (s *SKVMGuestInstance) startKickstartMonitorIfNeeded() { + if s.kickstartMonitor == nil { + return + } + + if !s.shouldUseKickstart() { + log.Infof("Kickstart not needed for server %s, skip starting monitor", s.Id) + return + } + + log.Infof("Starting kickstart monitor for server %s", s.Id) + + if err := s.kickstartMonitor.updateKickstartStatus(api.VM_KICKSTART_INSTALLING); err != nil { + log.Errorf("Failed to update kickstart status to installing for server %s: %v", s.Id, err) + } else { + log.Debugf("Kickstart status updated to installing for server %s", s.Id) + } + + if err := s.kickstartMonitor.Start(); err != nil { + log.Errorf("Failed to start kickstart monitor for server %s: %s", s.Id, err) + } +} + +func (s *SKVMGuestInstance) cleanupKickstartMonitor() { + if s.kickstartMonitor != nil { + s.kickstartMonitor.Close() + s.kickstartMonitor = nil + log.Infof("Kickstart monitor cleaned up for server %s", s.Id) + } +} diff --git a/pkg/hostman/guestman/qemu-kvmhelper.go b/pkg/hostman/guestman/qemu-kvmhelper.go index 76a31b3f50..3ac1ac4e4b 100644 --- a/pkg/hostman/guestman/qemu-kvmhelper.go +++ b/pkg/hostman/guestman/qemu-kvmhelper.go @@ -18,7 +18,9 @@ import ( "context" "fmt" "net" + "os" "path" + "path/filepath" "strconv" "strings" "time" @@ -44,6 +46,7 @@ import ( "yunion.io/x/onecloud/pkg/hostman/options" "yunion.io/x/onecloud/pkg/hostman/storageman" "yunion.io/x/onecloud/pkg/util/fileutils2" + "yunion.io/x/onecloud/pkg/util/mountutils" "yunion.io/x/onecloud/pkg/util/procutils" "yunion.io/x/onecloud/pkg/util/qemutils" ) @@ -72,10 +75,12 @@ import sys import os import time import subprocess +import shlex with open(os.devnull, 'w') as FNULL: try: - cmd = subprocess.check_output(['bash', '%s'], stderr=FNULL).split() + cmd_str = subprocess.check_output(['bash', '%s'], stderr=FNULL).decode('utf-8').strip() + cmd = shlex.split(cmd_str) except BaseException as e: sys.stderr.write('%%s' %% e) sys.exit(1) @@ -619,6 +624,11 @@ function start_swtpm() { input.RescueKernelPath = s.getRescueKernelPath() } + // check if kickstart is needed for KVM guests + if err := s.configureKickstartBoot(input); err != nil { + return "", errors.Wrap(err, "handle kickstart mount") + } + qemuOpts, err := qemu.GenerateStartOptions(input) if err != nil { return "", errors.Wrap(err, "GenerateStartCommand") @@ -631,6 +641,218 @@ function start_swtpm() { return cmd, nil } +// shouldUseKickstart 判断是否需要启用kickstart自动化安装 +// 启动kickstart的条件:1. 虚拟机处于KVM虚拟化环境;2. 存在kickstart配置且未禁用;3. kickstart未完成 +func (s *SKVMGuestInstance) shouldUseKickstart() bool { + // 只在KVM虚拟化环境下处理kickstart + if s.Desc.Hypervisor != api.HYPERVISOR_KVM { + return false + } + + // 检查是否存在kickstart配置 + kickstartConfigStr, configExists := s.Desc.Metadata[api.VM_METADATA_KICKSTART_CONFIG] + if !configExists || kickstartConfigStr == "" { + return false + } else { + kickstartConfigJson, err := jsonutils.ParseString(kickstartConfigStr) + if err != nil { + log.Errorf("Failed to parse kickstart config for VM %s: %v", s.Id, err) + return false + } + kickstartConfig := &api.KickstartConfig{} + if err := kickstartConfigJson.Unmarshal(kickstartConfig); err != nil { + log.Errorf("Failed to unmarshal kickstart config for VM %s: %v", s.Id, err) + return false + } + if kickstartConfig.Enabled != nil && !*kickstartConfig.Enabled { + log.Debugf("Kickstart is disabled in config for VM %s, skipping kickstart boot", s.Id) + return false + } + } + + kickstartCompleted, completedExists := s.Desc.Metadata[api.VM_METADATA_KICKSTART_COMPLETED_FLAG] + if completedExists && kickstartCompleted == "true" { + log.Debugf("Kickstart already completed for VM %s, skipping kickstart boot", s.Id) + return false + } + + log.Debugf("VM %s needs kickstart: config exists and not completed yet", s.Id) + return true +} + +// configureKickstartBoot 配置 kickstart 自动化安装的启动流程 +// 1. 挂载安装 ISO,获取内核和 initrd 路径 +// 2. 生成内核启动参数 +// 3. 创建 kickstart 监控器 +// 4. 如果包含完整配置,生成 kickstart 配置 ISO 并添加为 CDROM 设备 +func (s *SKVMGuestInstance) configureKickstartBoot(input *qemu.GenerateStartOptionsInput) error { + if !s.shouldUseKickstart() { + return nil + } + + log.Debugf("Enabling kickstart boot for VM %s", s.Id) + + kickstartConfigStr := s.Desc.Metadata[api.VM_METADATA_KICKSTART_CONFIG] + + kickstartConfigJson, err := jsonutils.ParseString(kickstartConfigStr) + if err != nil { + return errors.Wrap(err, "parse kickstart config") + } + + kickstartConfig := &api.KickstartConfig{} + if err := kickstartConfigJson.Unmarshal(kickstartConfig); err != nil { + return errors.Wrap(err, "unmarshal kickstart config") + } + + // Find ISO file for kickstart installation from CDROM devices + var isoPath string + if len(s.Desc.Cdroms) > 0 { + for _, cdrom := range s.Desc.Cdroms { + if cdrom.Path != "" { + isoPath = cdrom.Path + break + } + } + } + + if isoPath == "" { + log.Warningf("no ISO path found for kickstart, skip") + return nil + } + + kickstartDir := filepath.Join(KICKSTART_BASE_DIR, s.GetId()) + mountPoint := filepath.Join(kickstartDir, KICKSTART_ISO_MOUNT_DIR) + + // Check if mount point already exists and is mounted + if fileutils2.Exists(mountPoint) { + mountFile := "/proc/mounts" + if data, err := os.ReadFile(mountFile); err == nil { + lines := strings.Split(string(data), "\n") + mounted := false + for _, line := range lines { + parts := strings.Split(line, " ") + if len(parts) >= 2 && parts[1] == mountPoint { + mounted = true + break + } + } + if mounted { + log.Debugf("Reusing existing kickstart ISO mount at %s for guest %s", mountPoint, s.GetName()) + } else { + os.RemoveAll(mountPoint) + if err := os.MkdirAll(mountPoint, 0755); err != nil { + return errors.Wrap(err, "create mount point") + } + if err := mountutils.MountWithParams(isoPath, mountPoint, "iso9660", []string{"-o", "loop,ro"}); err != nil { + os.RemoveAll(mountPoint) + return errors.Wrapf(err, "mount ISO %s to %s", isoPath, mountPoint) + } + log.Debugf("Successfully mounted kickstart ISO %s to %s for guest %s", isoPath, mountPoint, s.GetName()) + } + } + } else { + if err := os.MkdirAll(mountPoint, 0755); err != nil { + return errors.Wrap(err, "create mount point") + } + if err := mountutils.MountWithParams(isoPath, mountPoint, "iso9660", []string{"-o", "loop,ro"}); err != nil { + os.RemoveAll(mountPoint) + return errors.Wrapf(err, "mount ISO %s to %s", isoPath, mountPoint) + } + log.Debugf("Successfully mounted kickstart ISO %s to %s for guest %s", isoPath, mountPoint, s.GetName()) + } + + mountPath := mountPoint + + // get kernel and initrd path from mounted ISO + kernelPath, initrdPath, err := GetKernelInitrdPaths(mountPath, kickstartConfig.OSType) + if err != nil { + return errors.Wrap(err, "get kickstart kernel paths") + } + + // Copy kernel and initrd files to local directory and unmount ISO + kernelCopyDir := filepath.Join(kickstartDir, "bootfiles") + if err := os.MkdirAll(kernelCopyDir, 0755); err != nil { + return errors.Wrap(err, "create kernel copy directory") + } + + kernelFileName := filepath.Base(kernelPath) + initrdFileName := filepath.Base(initrdPath) + + copiedKernelPath := filepath.Join(kernelCopyDir, kernelFileName) + copiedInitrdPath := filepath.Join(kernelCopyDir, initrdFileName) + + if err := procutils.NewCommand("cp", kernelPath, copiedKernelPath).Run(); err != nil { + return errors.Wrapf(err, "copy kernel from %s to %s", kernelPath, copiedKernelPath) + } + log.Debugf("Successfully copied kernel from %s to %s for guest %s", kernelPath, copiedKernelPath, s.GetName()) + + if err := procutils.NewCommand("cp", initrdPath, copiedInitrdPath).Run(); err != nil { + return errors.Wrapf(err, "copy initrd from %s to %s", initrdPath, copiedInitrdPath) + } + log.Debugf("Successfully copied initrd from %s to %s for guest %s", initrdPath, copiedInitrdPath, s.GetName()) + + // Unmount ISO after copying files + log.Infof("Unmounting kickstart ISO at %s after copying kernel and initrd for guest %s", mountPoint, s.GetName()) + if err := mountutils.Unmount(mountPoint, true); err != nil { + log.Warningf("Failed to unmount kickstart ISO at %s: %v", mountPoint, err) + } else { + log.Debugf("Successfully unmounted kickstart ISO at %s for guest %s", mountPoint, s.GetName()) + // Remove mount point directory after unmounting + if err := os.RemoveAll(mountPoint); err != nil { + log.Warningf("Failed to remove mount point directory %s: %v", mountPoint, err) + } + } + + // Use copied file paths instead of mounted paths + kernelPath = copiedKernelPath + initrdPath = copiedInitrdPath + + var kickstartConfigIsoPath string + log.Debugf("Kickstart config for guest %s: Config length=%d, ConfigURL=%s", + s.GetName(), len(kickstartConfig.Config), kickstartConfig.ConfigURL) + + if kickstartConfig.Config != "" { + isoPath, err := CreateKickstartConfigISO(kickstartConfig, s.Id) + if err != nil { + log.Errorf("Failed to create kickstart config ISO for guest %s: %v, falling back to URL/cdrom method", s.GetName(), err) + } else { + kickstartConfigIsoPath = isoPath + log.Debugf("Successfully created kickstart ISO for guest %s: %s", s.GetName(), isoPath) + } + } + + kernelArgs := BuildKickstartAppendArgs(kickstartConfig, kickstartConfigIsoPath) + log.Debugf("Generated kickstart kernel args for guest %s: %s", s.GetName(), kernelArgs) + + // Create kickstart serial monitor for status monitoring + kickstartMonitor := NewKickstartSerialMonitor(s.Id) + serialFilePath := kickstartMonitor.GetSerialFilePath() + + input.KickstartBoot = &qemu.KickstartBootInfo{ + Config: kickstartConfig, + MountPath: mountPath, + KernelPath: kernelPath, + InitrdPath: initrdPath, + KernelArgs: kernelArgs, + SerialFilePath: serialFilePath, + ConfigIsoPath: kickstartConfigIsoPath, + } + + // Add kickstart config ISO as additional CDROM device if created + if kickstartConfigIsoPath != "" { + if err := s.attachKickstartISO(kickstartConfigIsoPath); err != nil { + log.Warningf("Failed to attach kickstart config ISO %s: %v", kickstartConfigIsoPath, err) + } + } + + s.kickstartMonitor = kickstartMonitor + + log.Debugf("Kickstart boot configured for guest %s: kernel=%s, initrd=%s, args=%s, isoPath=%s", + s.GetName(), kernelPath, initrdPath, kernelArgs, kickstartConfigIsoPath) + + return nil +} + func (s *SKVMGuestInstance) getRescueInitrdPath() string { return path.Join(s.GetRescueDirPath(), api.GUEST_RESCUE_INITRAMFS) } @@ -1282,3 +1504,30 @@ func (s *SKVMGuestInstance) setUefiBootOrder(ctx context.Context) error { } return nil } + +// attachKickstartISO attaches the kickstart ISO as an additional CDROM device +// if the kickstart is not provided by URL +func (s *SKVMGuestInstance) attachKickstartISO(isoPath string) error { + cdromId := fmt.Sprintf("kickstart_iso_%s", s.Id) + + log.Debugf("Attaching kickstart ISO %s as CDROM device for guest %s", isoPath, s.GetName()) + + kickstartCdrom := &desc.SGuestCdrom{ + Id: cdromId, + Path: isoPath, + Ordinal: int64(len(s.Desc.Cdroms)), + Scsi: desc.NewScsiDevice("scsi.0", "scsi-cd", fmt.Sprintf("scsi-cd-%s", cdromId)), + DriveOptions: map[string]string{ + "readonly": "on", + "media": "cdrom", + "if": "none", + }, + } + + s.Desc.Cdroms = append(s.Desc.Cdroms, kickstartCdrom) + + log.Debugf("Successfully attached kickstart ISO %s as SCSI CDROM device %s (ordinal=%d) for guest %s", + isoPath, cdromId, kickstartCdrom.Ordinal, s.GetName()) + + return nil +} diff --git a/pkg/hostman/guestman/qemu/generate.go b/pkg/hostman/guestman/qemu/generate.go index f105d334b7..7cdc294e96 100644 --- a/pkg/hostman/guestman/qemu/generate.go +++ b/pkg/hostman/guestman/qemu/generate.go @@ -261,6 +261,18 @@ func generateInitrdOptions(drvOpt QemuOptions, initrdPath, kernel string) []stri return opts } +func generateKickstartBootOptions(drvOpt QemuOptions, kickstartBoot *KickstartBootInfo) []string { + opts := make([]string, 0) + opts = append(opts, drvOpt.Kernel(kickstartBoot.KernelPath)) + opts = append(opts, drvOpt.Initrd(kickstartBoot.InitrdPath)) + if kickstartBoot.KernelArgs != "" { + // due to blank space in kickstart args, '' is needed + opts = append(opts, fmt.Sprintf("-append '%s'", kickstartBoot.KernelArgs)) + } + + return opts +} + func generateDisksOptions(drvOpt QemuOptions, disks []*desc.SGuestDisk, isEncrypt, isMaster bool, osName string) []string { opts := make([]string, 0) for _, disk := range disks { @@ -632,6 +644,25 @@ func generateISASerialOptions(isaSerial *desc.SGuestIsaSerial) []string { return opts } +func generateKickstartSerialOptions(kickstartBoot *KickstartBootInfo) []string { + if kickstartBoot == nil || kickstartBoot.SerialFilePath == "" { + return nil + } + + opts := make([]string, 0) + chardevId := "kickstart_serial" + + // Create chardev with file backend + chardevOpt := fmt.Sprintf("-chardev file,path=%s,id=%s", kickstartBoot.SerialFilePath, chardevId) + opts = append(opts, chardevOpt) + + // Create ISA serial device + serialOpt := fmt.Sprintf("-device isa-serial,chardev=%s,id=kickstart_serial_device", chardevId) + opts = append(opts, serialOpt) + + return opts +} + func generatePvpanicDeviceOption(pvpanic *desc.SGuestPvpanic) string { return fmt.Sprintf("-device pvpanic,id=%s,ioport=0x%x", pvpanic.Id, pvpanic.Ioport) } @@ -697,6 +728,17 @@ type GenerateStartOptionsInput struct { RescueInitrdPath string // rescue initramfs path RescueKernelPath string // rescue kernel path + + KickstartBoot *KickstartBootInfo +} +type KickstartBootInfo struct { + Config *api.KickstartConfig + MountPath string + KernelPath string + InitrdPath string + KernelArgs string + SerialFilePath string + ConfigIsoPath string } func (input *GenerateStartOptionsInput) HasBootIndex() bool { @@ -827,6 +869,11 @@ func GenerateStartOptions( input.RescueInitrdPath, input.RescueKernelPath, )...) + } else if input.KickstartBoot != nil { + opts = append(opts, generateKickstartBootOptions( + drvOpt, + input.KickstartBoot, + )...) } // generate disk options @@ -889,6 +936,11 @@ func GenerateStartOptions( opts = append(opts, generateISASerialOptions(input.GuestDesc.IsaSerial)...) } + // kickstart serial device + if input.KickstartBoot != nil { + opts = append(opts, generateKickstartSerialOptions(input.KickstartBoot)...) + } + // migrate options opts = append(opts, getMigrateOptions(drvOpt, input)...) diff --git a/pkg/mcclient/options/compute/servers.go b/pkg/mcclient/options/compute/servers.go index 4acb9d1cee..c5c8145fe6 100644 --- a/pkg/mcclient/options/compute/servers.go +++ b/pkg/mcclient/options/compute/servers.go @@ -493,6 +493,14 @@ type ServerCreateOptionalOptions struct { AutoPrepaidRecycle bool `help:"automatically enable prepaid recycling after server is created successfully" json:"auto_prepaid_recycle,omitfalse"` + // Kickstart related options + KickstartOSType string `help:"Kickstart OS type" choices:"centos|rhel|fedora|openeuler|ubuntu" json:"-"` + KickstartConfig string `help:"Kickstart configuration content" json:"-"` + KickstartConfigURL string `help:"Kickstart configuration URL" json:"-"` + KickstartEnabled *bool `help:"Enable kickstart" json:"-"` + KickstartMaxRetries int `help:"Kickstart max retries" default:"3" json:"-"` + KickstartTimeoutMinutes int `help:"Kickstart timeout in minutes" default:"60" json:"-"` + GenerateName bool `help:"name is generated by pattern" json:"-"` EipBw int `help:"allocate EIP with bandwidth in MB when server is created" json:"eip_bw,omitzero"` @@ -663,6 +671,49 @@ func (opts *ServerCreateOptionalOptions) OptionalParams() (*computeapi.ServerCre params.IsSystem = &opts.System + // if kickstart os type is specified, then kickstart is enabled + if len(opts.KickstartOSType) > 0 { + if opts.KickstartConfig == "" && opts.KickstartConfigURL == "" { + return nil, fmt.Errorf("either --kickstart-config or --kickstart-config-url must be provided when --kickstart-os-type is specified") + } + if opts.KickstartConfig != "" && opts.KickstartConfigURL != "" { + return nil, fmt.Errorf("--kickstart-config and --kickstart-config-url cannot be both provided, choose one") + } + + kickstartConfig := &computeapi.KickstartConfig{ + OSType: opts.KickstartOSType, + } + + if opts.KickstartConfig != "" { + kickstartConfig.Config = opts.KickstartConfig + } + if opts.KickstartConfigURL != "" { + kickstartConfig.ConfigURL = opts.KickstartConfigURL + } + + // setting default value + if opts.KickstartEnabled != nil { + kickstartConfig.Enabled = opts.KickstartEnabled + } else { + enabled := true + kickstartConfig.Enabled = &enabled + } + + if opts.KickstartMaxRetries > 0 { + kickstartConfig.MaxRetries = opts.KickstartMaxRetries + } else { + kickstartConfig.MaxRetries = 3 + } + + if opts.KickstartTimeoutMinutes > 0 { + kickstartConfig.TimeoutMinutes = opts.KickstartTimeoutMinutes + } else { + kickstartConfig.TimeoutMinutes = 60 + } + + params.KickstartConfig = kickstartConfig + } + return params, nil } @@ -896,10 +947,41 @@ func (o *ServerSendKeyOptions) Params() (jsonutils.JSONObject, error) { return options.StructToParams(o) } +// Kickstart related options + +type ServerKickstartConfigOptions struct { + ServerIdOptions + OSType string `help:"Kickstart OS type" choices:"centos|rhel|fedora|openeuler|ubuntu" json:"os_type"` + Config string `help:"Kickstart configuration content" json:"config,omitempty"` + ConfigURL string `help:"Kickstart configuration URL" json:"config_url,omitempty"` + Enabled *bool `help:"Enable kickstart" json:"enabled,omitempty"` + MaxRetries int `help:"Kickstart max retries" default:"3" json:"max_retries,omitempty"` + TimeoutMinutes int `help:"Kickstart timeout in minutes" default:"60" json:"timeout_minutes,omitempty"` +} + +func (o *ServerKickstartConfigOptions) Params() (jsonutils.JSONObject, error) { + if o.Config == "" && o.ConfigURL == "" { + return nil, fmt.Errorf("either --config or --config-url must be provided") + } + if o.Config != "" && o.ConfigURL != "" { + return nil, fmt.Errorf("--config and --config-url cannot be both provided, choose one") + } + return options.StructToParams(o) +} + func (o *ServerSendKeyOptions) Description() string { return "Send keys to server" } +type ServerKickstartCompleteOptions struct { + ServerIdOptions + Restart bool `help:"Restart the server after marking kickstart as completed" default:"true" json:"restart"` +} + +func (o *ServerKickstartCompleteOptions) Params() (jsonutils.JSONObject, error) { + return options.StructToParams(o) +} + type ServerMonitorOptions struct { ServerIdOptions