diff --git a/build/docker/Dockerfile.host b/build/docker/Dockerfile.host index 3be41d5468..775ac80092 100644 --- a/build/docker/Dockerfile.host +++ b/build/docker/Dockerfile.host @@ -1,4 +1,4 @@ -FROM registry.cn-beijing.aliyuncs.com/yunionio/host-base:v0.7.0 +FROM registry.cn-beijing.aliyuncs.com/yunionio/host-base:v0.8.0 MAINTAINER "Yaoqi Wan wanyaoqi@yunionyun.com" diff --git a/build/docker/multi-arch/Dockerfile.host b/build/docker/multi-arch/Dockerfile.host index deb1a4c226..2471705849 100644 --- a/build/docker/multi-arch/Dockerfile.host +++ b/build/docker/multi-arch/Dockerfile.host @@ -7,7 +7,7 @@ COPY . /root/go/src/yunion.io/x/onecloud WORKDIR /root/go/src/yunion.io/x/onecloud RUN make cmd/host -FROM registry.cn-beijing.aliyuncs.com/yunionio/host-base:v0.7.0 +FROM registry.cn-beijing.aliyuncs.com/yunionio/host-base:v0.8.0 MAINTAINER "Yaoqi Wan wanyaoqi@yunionyun.com" diff --git a/cmd/climc/shell/compute/servers.go b/cmd/climc/shell/compute/servers.go index 0129009dfc..81602aaed2 100644 --- a/cmd/climc/shell/compute/servers.go +++ b/cmd/climc/shell/compute/servers.go @@ -131,6 +131,10 @@ func init() { cmd.BatchPerform("sync-os-info", &options.ServerIdsOptions{}) cmd.BatchPerform("set-root-disk-matcher", &options.ServerSetRootDiskMatcher{}) cmd.Perform("disable-auto-merge-snapshot", &options.ServerDisableAutoMergeSnapshot{}) + 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 cf899fbf94..b9d4766762 100644 --- a/pkg/apis/compute/api.go +++ b/pkg/apis/compute/api.go @@ -462,6 +462,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 @@ -672,9 +703,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 81f4102848..445fb40917 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" @@ -269,6 +275,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, @@ -338,11 +345,33 @@ const ( VM_METADATA_RELEASED_DEVICES = "released_devices" - VM_METADATA_CPU_NUMA_PIN = "__cpu_numa_pin" - + VM_METADATA_CPU_NUMA_PIN = "__cpu_numa_pin" 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/models/guest_actions.go b/pkg/compute/models/guest_actions.go index 44bd119248..9dd3d70b1c 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}) { @@ -3564,7 +3597,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 } @@ -3660,6 +3693,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: @@ -3750,6 +3788,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 { @@ -3808,7 +3852,7 @@ func (self *SGuest) PerformStatus(ctx context.Context, userCred mcclient.TokenCr func (self *SGuest) PerformStop(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.ServerStopInput) (jsonutils.JSONObject, error) { // XXX if is force, force stop guest - if input.IsForce || utils.IsInStringArray(self.Status, []string{api.VM_RUNNING, api.VM_STOP_FAILED, api.POD_STATUS_CRASH_LOOP_BACK_OFF, api.POD_STATUS_CONTAINER_EXITED}) { + if input.IsForce || utils.IsInStringArray(self.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}) { if err := self.ValidateEncryption(ctx, userCred); err != nil { return nil, errors.Wrap(httperrors.ErrForbidden, "encryption key not accessible") } @@ -3842,7 +3886,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 @@ -6934,3 +6978,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 4e2455de19..9af5978d65 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" @@ -2239,6 +2242,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 @@ -2339,6 +2349,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 httperrors.NewMissingParameterError("os_type") + } + + if !utils.IsInStringArray(config.OSType, api.KICKSTART_VALID_OS_TYPES) { + return httperrors.NewInputParameterError("unsupported os_type: %s, supported types: %v", config.OSType, api.KICKSTART_VALID_OS_TYPES) + } + + // 验证配置内容和URL二选一 + if config.Config == "" && config.ConfigURL == "" { + return httperrors.NewInputParameterError("either config or config_url must be provided") + } + + if config.Config != "" && config.ConfigURL != "" { + return httperrors.NewInputParameterError("config and config_url cannot be both provided, choose one") + } + + if config.Config != "" { + const maxConfigSize = 64 * 1024 + if len(config.Config) > maxConfigSize { + return httperrors.NewInputParameterError("config content too large: %d bytes, maximum allowed: %d bytes", len(config.Config), maxConfigSize) + } + if len(strings.TrimSpace(config.Config)) == 0 { + return httperrors.NewInputParameterError("config content cannot be empty") + } + } + + if config.ConfigURL != "" { + if len(config.ConfigURL) > 2048 { + return httperrors.NewInputParameterError("config URL too long: %d characters, maximum allowed: 2048", len(config.ConfigURL)) + } + if strings.TrimSpace(config.ConfigURL) == "" { + return httperrors.NewInputParameterError("config URL cannot be empty") + } + + parsedURL, err := url.Parse(config.ConfigURL) + if err != nil { + return httperrors.NewInputParameterError("invalid URL format: %v", err) + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return httperrors.NewInputParameterError("invalid URL scheme: %s, only http and https are allowed", parsedURL.Scheme) + } + if parsedURL.Host == "" { + return httperrors.NewInputParameterError("URL must specify a host") + } + + if err := checkKickstartURLContentSize(config.ConfigURL); err != nil { + return httperrors.NewInputParameterError("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) @@ -2590,6 +2719,35 @@ func (guest *SGuest) PostCreate(ctx context.Context, userCred mcclient.TokenCred guest.setUserData(ctx, userCred, userData) } + // 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 PreferRegion string @@ -7074,6 +7232,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..09c06e1aaf --- /dev/null +++ b/pkg/hostman/guestman/kickstart_helper.go @@ -0,0 +1,706 @@ +// 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" + "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" + "yunion.io/x/onecloud/pkg/util/procutils" +) + +const ( + KICKSTART_MONITOR_TIMEOUT = 60 * time.Minute + SERIAL_FILE_CHECK_INTERVAL = 5 * time.Second + 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 + kickstartTmpDir string + + ctx context.Context + cancel context.CancelFunc +} + +// NewKickstartSerialMonitor creates a new kickstart serial monitor +func NewKickstartSerialMonitor(s *SKVMGuestInstance) *SKickstartSerialMonitor { + ctx, cancel := context.WithCancel(context.Background()) + + // Get the server instance to access its homeDir + serialFilePath := path.Join(s.HomeDir(), "kickstart-serial.log") + kickstartTmpDir := s.getKickstartTmpDir() + + return &SKickstartSerialMonitor{ + serverId: s.GetId(), + serialFilePath: serialFilePath, + kickstartTmpDir: kickstartTmpDir, + 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, m.kickstartTmpDir) + + 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, kickStartBaseDir string) (string, error) { + log.Debugf("Creating kickstart ISO to %s with OS type %s", kickStartBaseDir, 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(kickStartBaseDir, 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(kickStartBaseDir, KICKSTART_ISO_FILENAME) + args := []string{ + "-o", isoPath, + "-V", volumeLabel, + "-r", + "-J", + } + args = append(args, filePaths...) + + cmd := procutils.NewCommand("mkisofs", args...) + if output, err := cmd.Output(); 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, kickstartDir string) { + if serverId == "" { + log.Warningf("Empty serverId provided for kickstart cleanup") + return + } + + 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 b5b0bce6c0..fbf9f9f13d 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 } @@ -489,6 +490,10 @@ func (s *SKVMGuestInstance) GetStateFilePath(version string) string { return p } +func (s *SKVMGuestInstance) getKickstartTmpDir() string { + return path.Join(s.HomeDir(), "kickstart") +} + func (s *SKVMGuestInstance) getQemuLogPath() string { return path.Join(s.HomeDir(), "qemu.log") } @@ -1274,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) { @@ -2085,6 +2092,7 @@ func (s *SKVMGuestInstance) ForceStop() bool { } func (s *SKVMGuestInstance) ExitCleanup(clear bool) { + s.cleanupKickstartMonitor() if clear { pid := s.GetPid() if pid > 0 { @@ -2185,6 +2193,8 @@ func (s *SKVMGuestInstance) delFlatFiles(ctx context.Context) error { } func (s *SKVMGuestInstance) Delete(ctx context.Context, migrated, recycle bool) error { + CleanupKickstartFiles(s.Id, s.getKickstartTmpDir()) + if err := s.delTmpDisks(ctx, migrated); err != nil { return errors.Wrap(err, "delTmpDisks") } @@ -3742,3 +3752,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 b921a9fa8c..be7579e89d 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) @@ -574,6 +579,11 @@ function nic_mtu() { 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") @@ -586,6 +596,216 @@ function nic_mtu() { 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 + } + + 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 + } + + // 检查是否存在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 + } + } + + 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 := s.getKickstartTmpDir() + 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) + + isoPath, err = CreateKickstartConfigISO(kickstartConfig, s.getKickstartTmpDir()) + 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) + 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) } @@ -1191,3 +1411,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 48db07a595..525f545b07 100644 --- a/pkg/hostman/guestman/qemu/generate.go +++ b/pkg/hostman/guestman/qemu/generate.go @@ -262,6 +262,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 { @@ -631,6 +643,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) } @@ -687,6 +718,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 { @@ -817,6 +859,11 @@ func GenerateStartOptions( input.RescueInitrdPath, input.RescueKernelPath, )...) + } else if input.KickstartBoot != nil { + opts = append(opts, generateKickstartBootOptions( + drvOpt, + input.KickstartBoot, + )...) } // generate disk options @@ -879,6 +926,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 7819459896..6da46d773a 100644 --- a/pkg/mcclient/options/compute/servers.go +++ b/pkg/mcclient/options/compute/servers.go @@ -31,6 +31,7 @@ import ( "yunion.io/x/onecloud/pkg/cloudcommon/cmdline" "yunion.io/x/onecloud/pkg/mcclient/options" "yunion.io/x/onecloud/pkg/util/cgrouputils/cpuset" + "yunion.io/x/onecloud/pkg/util/fileutils2" ) var ErrEmtptyUpdate = errors.Error("No valid update data") @@ -488,6 +489,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:"-"` + KickstartConfigFile string `help:"Kickstart configuration content file" 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"` @@ -657,6 +666,53 @@ 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.KickstartConfigFile == "" && opts.KickstartConfigURL == "" { + return nil, fmt.Errorf("either --kickstart-config or --kickstart-config-url must be provided when --kickstart-os-type is specified") + } + if opts.KickstartConfigFile != "" && 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.KickstartConfigFile != "" { + ksconf, err := fileutils2.FileGetContents(opts.KickstartConfigFile) + if err != nil { + return nil, errors.Wrapf(err, "FileGetContents %s", opts.KickstartConfigFile) + } + kickstartConfig.Config = ksconf + } + 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 } @@ -876,10 +932,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