diff --git a/modules/k8s/application/application.go b/modules/k8s/application/application.go index 5ebff8e684..ac0e67ef77 100644 --- a/modules/k8s/application/application.go +++ b/modules/k8s/application/application.go @@ -212,6 +212,8 @@ func UpdateApplication(ctx context.Context, client dynamic_client.Interface, opt // 更新资源 result, err := client.Resource(applicationGroupVersionResource). Namespace(opts.Namespace).Update(ctx, unstructuredObj, opts.UpdateOptions) + log.Info("Updated Application: %v", result) + log.Info("Error: %v", err) if err != nil { return nil, application_errors.ErrOperateApplication{ Action: "update Application via Dynamic Client", diff --git a/routers/web/user/setting/appstore.go b/routers/web/user/setting/appstore.go index 3dc0844dac..7c9f688456 100644 --- a/routers/web/user/setting/appstore.go +++ b/routers/web/user/setting/appstore.go @@ -52,7 +52,7 @@ func handleGetApps(ctx *context.Context) { category := ctx.FormString("category") tag := ctx.FormString("tag") search := ctx.FormString("search") - deployment := ctx.FormString("deployment") // docker, kubernetes, all + deployment := ctx.FormString("deployment") // docker, kubernetes source := ctx.FormString("source") // local | devstar manager := appstore.NewManager(ctx) @@ -82,7 +82,7 @@ func handleGetApps(ctx *context.Context) { continue } } - if deployment != "" && deployment != "all" { + if deployment != "" { // 处理部署类型过滤,包括 'both' 类型 appDeployment := a.DeploymentType if appDeployment == "" { @@ -139,8 +139,8 @@ func handleGetApps(ctx *context.Context) { } // 处理部署类型过滤,包括 'both' 类型 - matchDeployment := deployment == "" || deployment == "all" - if deployment != "" && deployment != "all" { + matchDeployment := deployment == "" + if deployment != "" { appDeployment := app.DeploymentType if appDeployment == "" { // 如果没有 deployment_type 字段,尝试从其他字段获取 @@ -194,7 +194,7 @@ func handleGetCategories(ctx *context.Context) { func handleGetTags(ctx *context.Context) { tags, err := appstore_model.GetTags(ctx) if err != nil { - ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + ctx.JSON(http.StatusBadRequest, map[string]interface{}{ "error": err.Error(), }) return @@ -205,8 +205,42 @@ func handleGetTags(ctx *context.Context) { }) } +// AppStoreStatus handles app instance status requests +func AppStoreStatus(ctx *context.Context) { + appID := ctx.PathParam("app_id") + if appID == "" { + ctx.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": "应用ID不能为空", + }) + return + } + + manager := appstore.NewManager(ctx) + status, err := manager.GetAppStatus(appID) + if err != nil { + if appErr, ok := err.(*appstore.AppStoreError); ok { + ctx.JSON(http.StatusNotFound, map[string]interface{}{ + "error": appErr.Message, + }) + } else { + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "获取应用状态失败", + }) + } + return + } + + ctx.JSON(http.StatusOK, status) +} + // AppStoreInstall handles app installation func AppStoreInstall(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + if ctx.Req.Method != "POST" { ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) return @@ -266,8 +300,81 @@ func AppStoreInstall(ctx *context.Context) { } } +// AppStoreUpdate handles app update +func AppStoreUpdate(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + + if ctx.Req.Method != "POST" { + ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) + return + } + + // 解析表单数据 + appID := ctx.FormString("app_id") + configJSON := ctx.FormString("config") + installTarget := ctx.FormString("install_target") + kubeconfig := ctx.FormString("kubeconfig") + kubeconfigContext := ctx.FormString("kubeconfig_context") + + if appID == "" { + ctx.JSON(400, map[string]string{"error": "应用ID不能为空"}) + return + } + + // 权限校验:仅管理员可选择默认位置 + if installTarget == "local" && (ctx.Doer == nil || !ctx.Doer.IsAdmin) { + ctx.JSON(403, map[string]string{"error": "仅管理员可选择默认位置"}) + return + } + + // 创建 manager 并执行更新 + manager := appstore.NewManager(ctx) + if err := manager.UpdateInstalledApp(appID, configJSON, installTarget, kubeconfig, kubeconfigContext); err != nil { + // 根据错误类型返回相应的状态码和消息 + if appErr, ok := err.(*appstore.AppStoreError); ok { + switch appErr.Code { + case "APP_NOT_FOUND", "CONFIG_MERGE_ERROR": + ctx.JSON(400, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "KUBERNETES_UPDATE_ERROR": + ctx.JSON(500, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "NOT_IMPLEMENTED": + ctx.JSON(501, map[string]string{"error": appErr.Message}) + default: + ctx.JSON(500, map[string]string{"error": appErr.Message}) + } + } else { + ctx.JSON(500, map[string]string{"error": "更新失败: " + err.Error()}) + } + return + } + + // 更新成功 + if installTarget == "kubeconfig" && kubeconfig != "" { + ctx.Flash.Success("应用已成功更新到自定义位置") + } else { + ctx.Flash.Success("应用已成功更新到默认位置") + } + + // 优先根据表单的 redirect_to 回跳(前端会带上当前路径) + if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" { + ctx.RedirectToCurrentSite(redirectTo) + } else { + ctx.Redirect(setting.AppSubURL + "/user/settings/appstore") + } +} + // AppStoreUninstall handles app uninstallation func AppStoreUninstall(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + if ctx.Req.Method != "POST" { ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) return @@ -321,6 +428,67 @@ func AppStoreUninstall(ctx *context.Context) { } } +// AppStoreStop handles app stopping (setting replicas to 0) +func AppStoreStop(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + + if ctx.Req.Method != "POST" { + ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) + return + } + + // 解析表单数据 + appID := ctx.FormString("app_id") + installTarget := ctx.FormString("install_target") + kubeconfig := ctx.FormString("kubeconfig") + kubeconfigContext := ctx.FormString("kubeconfig_context") + + if appID == "" { + ctx.JSON(400, map[string]string{"error": "应用ID不能为空"}) + return + } + + // 创建 manager 并执行暂停 + manager := appstore.NewManager(ctx) + if err := manager.StopApp(appID, installTarget, []byte(kubeconfig), kubeconfigContext); err != nil { + // 根据错误类型返回相应的状态码和消息 + if appErr, ok := err.(*appstore.AppStoreError); ok { + switch appErr.Code { + case "APP_NOT_FOUND": + ctx.JSON(400, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "KUBERNETES_STOP_ERROR": + ctx.JSON(500, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "UNSUPPORTED_DEPLOYMENT_TYPE": + ctx.JSON(400, map[string]string{"error": appErr.Message}) + default: + ctx.JSON(500, map[string]string{"error": appErr.Message}) + } + } else { + ctx.JSON(500, map[string]string{"error": "暂停失败: " + err.Error()}) + } + return + } + + // 暂停成功 + if installTarget == "kubeconfig" && kubeconfig != "" { + ctx.Flash.Success("应用已成功在指定位置暂停") + } else { + ctx.Flash.Success("应用已成功暂停") + } + + // 根据来源页面决定重定向位置 + referer := ctx.Req.Header.Get("Referer") + if strings.Contains(referer, "/admin/appstore") { + ctx.Redirect(setting.AppSubURL + "/admin/appstore") + } else { + ctx.Redirect(setting.AppSubURL + "/user/settings/appstore") + } +} + // AppStoreConfigure handles app configuration func AppStoreConfigure(ctx *context.Context) { // TODO: Load app configuration form @@ -332,6 +500,12 @@ func AppStoreConfigure(ctx *context.Context) { // AppStoreConfigurePost handles app configuration form submission func AppStoreConfigurePost(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + // TODO: Handle configuration form submission ctx.Flash.Success("App configuration feature coming soon") if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" { @@ -343,6 +517,12 @@ func AppStoreConfigurePost(ctx *context.Context) { // 添加应用API func AddAppAPI(ctx *context.Context) { + // 检查 CSRF token + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + manager := appstore.NewManager(ctx) if ctx.Req.Method != "POST" { ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) diff --git a/routers/web/web.go b/routers/web/web.go index 11c21416d7..bde9c3f687 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -706,8 +706,11 @@ func registerWebRoutes(m *web.Router) { m.Group("/appstore", func() { m.Get("", user_setting.AppStore) m.Get("/api/{action}", user_setting.AppStoreAPI) + m.Get("/api/status/{app_id}", user_setting.AppStoreStatus) m.Post("/api/add", user_setting.AddAppAPI) m.Post("/install/{app_id}", user_setting.AppStoreInstall) + m.Post("/update/{app_id}", user_setting.AppStoreUpdate) + m.Post("/stop/{app_id}", user_setting.AppStoreStop) m.Post("/uninstall/{app_id}", user_setting.AppStoreUninstall) m.Get("/configure/{app_id}", user_setting.AppStoreConfigure) m.Post("/configure/{app_id}", user_setting.AppStoreConfigurePost) diff --git a/services/appstore/examples/nginx.json b/services/appstore/examples/nginx.json index d6158f7ef1..65e1c59a4a 100644 --- a/services/appstore/examples/nginx.json +++ b/services/appstore/examples/nginx.json @@ -1,6 +1,6 @@ { - "id": "nginx", - "name": "Nginx Web Server", + "id": "nginx-9", + "name": "Nginx Web Server-9", "description": "High-performance HTTP server and reverse proxy", "category": "web-server", "tags": ["web", "proxy", "http", "server"], @@ -56,7 +56,7 @@ "arch": "x86_64" }, "deploy": { - "type": "docker", + "type": "", "docker": { "image": "nginx", "tag": "1.24.0", diff --git a/services/appstore/k8s_application_mapper.go b/services/appstore/k8s_application_mapper.go index 8583ef4bf1..d360dbd671 100644 --- a/services/appstore/k8s_application_mapper.go +++ b/services/appstore/k8s_application_mapper.go @@ -139,9 +139,6 @@ func BuildK8sGetOptions(app *App, namespaceOverride string, wait bool) (*applica if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } @@ -160,9 +157,6 @@ func BuildK8sDeleteOptions(app *App, namespaceOverride string) (*application.Del if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } @@ -192,9 +186,6 @@ func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applica if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } diff --git a/services/appstore/k8s_manager.go b/services/appstore/k8s_manager.go index 9cc6dd2675..8eca751448 100644 --- a/services/appstore/k8s_manager.go +++ b/services/appstore/k8s_manager.go @@ -5,6 +5,7 @@ package appstore import ( "context" + "fmt" k8s "code.gitea.io/gitea/modules/k8s" applicationv1 "code.gitea.io/gitea/modules/k8s/api/application/v1" @@ -27,7 +28,14 @@ func NewK8sManager(ctx context.Context) *K8sManager { // InstallAppToKubernetes installs an application to a Kubernetes cluster func (km *K8sManager) InstallAppToKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -70,7 +78,14 @@ func (km *K8sManager) InstallAppToKubernetes(app *App, kubeconfig []byte, contex // UninstallAppFromKubernetes uninstalls an application from a Kubernetes cluster func (km *K8sManager) UninstallAppFromKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -113,7 +128,14 @@ func (km *K8sManager) UninstallAppFromKubernetes(app *App, kubeconfig []byte, co // GetAppFromKubernetes gets an application from a Kubernetes cluster func (km *K8sManager) GetAppFromKubernetes(app *App, kubeconfig []byte, contextName string) (interface{}, error) { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return nil, &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return nil, &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -147,7 +169,14 @@ func (km *K8sManager) GetAppFromKubernetes(app *App, kubeconfig []byte, contextN // ListAppsFromKubernetes lists applications from a Kubernetes cluster func (km *K8sManager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contextName string) (*applicationv1.ApplicationList, error) { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return nil, &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return nil, &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -181,7 +210,14 @@ func (km *K8sManager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contex // UpdateAppInKubernetes updates an application in a Kubernetes cluster func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -198,8 +234,29 @@ func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, context } } - // Build Kubernetes update options - updateOptions, err := BuildK8sUpdateOptions(app, "", nil) + // 先获取现有的应用,以保留元数据(特别是 resourceVersion) + existingApp, err := km.GetAppFromKubernetes(app, kubeconfig, contextName) + if err != nil { + // 如果应用不存在,返回错误 + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "应用在 Kubernetes 中不存在,无法更新", + Details: err.Error(), + } + } + + // 确保获取到的是 Application 类型 + appData, ok := existingApp.(*applicationv1.Application) + if !ok { + return &AppStoreError{ + Code: "TYPE_ERROR", + Message: "无法获取现有应用数据", + Details: fmt.Sprintf("期望 *applicationv1.Application 类型,实际类型: %T", existingApp), + } + } + + // Build Kubernetes update options,传入现有应用以保留元数据 + updateOptions, err := BuildK8sUpdateOptions(app, "", appData) if err != nil { return &AppStoreError{ Code: "KUBERNETES_OPTIONS_ERROR", @@ -220,3 +277,8 @@ func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, context return nil } + +// IsApplicationReady 封装底层就绪判断,避免上层直接依赖 k8s/application 的实现 +func (km *K8sManager) IsApplicationReady(status *applicationv1.ApplicationStatus) bool { + return application.IsApplicationStatusReady(status) +} diff --git a/services/appstore/manager.go b/services/appstore/manager.go index f1d9e295dd..e57916c776 100644 --- a/services/appstore/manager.go +++ b/services/appstore/manager.go @@ -6,6 +6,7 @@ package appstore import ( "context" "encoding/json" + "fmt" "net/http" "strings" "time" @@ -252,34 +253,34 @@ func (m *Manager) GetTags() ([]string, error) { // PrepareInstallation prepares an application for installation by merging user config // Returns a complete App structure with merged configuration -func (m *Manager) PrepareInstallation(appID string, userConfig UserConfig) (*App, error) { - // Load the application - app, err := m.GetApp(appID) - if err != nil { - return nil, err - } +// func (m *Manager) PrepareInstallation(appID string, userConfig UserConfig) (*App, error) { +// // Load the application +// app, err := m.GetApp(appID) +// if err != nil { +// return nil, err +// } - // Set the app ID and version in user config if not provided - if userConfig.AppID == "" { - userConfig.AppID = appID - } - if userConfig.Version == "" { - userConfig.Version = app.Version - } +// // Set the app ID and version in user config if not provided +// if userConfig.AppID == "" { +// userConfig.AppID = appID +// } +// if userConfig.Version == "" { +// userConfig.Version = app.Version +// } - // Merge user configuration with app's default configuration - mergedApp, err := m.parser.MergeUserConfig(app, userConfig) - if err != nil { - return nil, err - } +// // Merge user configuration with app's default configuration +// mergedApp, err := m.parser.MergeUserConfig(app, userConfig) +// if err != nil { +// return nil, err +// } - return mergedApp, nil -} +// return mergedApp, nil +// } // AddApp adds a new application to the database func (m *Manager) AddApp(app *App) error { // Validate the app - if err := m.parser.validateApp(app); err != nil { + if err := m.parser.validateAppTemplate(app); err != nil { return err } @@ -303,7 +304,7 @@ func (m *Manager) AddApp(app *App) error { // AddAppFromJSON 通过 parser 解析原始 JSON 并添加应用 func (m *Manager) AddAppFromJSON(jsonBytes []byte) error { - app, err := m.parser.ParseApp(jsonBytes) + app, err := m.parser.ParseAppTemplate(jsonBytes) if err != nil { return err } @@ -469,6 +470,67 @@ func (m *Manager) InstallApp(appID string, configJSON string, installTarget stri return nil } +// UpdateInstalledApp updates an already installed application with new configuration +// The update flow mirrors InstallApp: merge config → choose target → call K8s/Docker updater +func (m *Manager) UpdateInstalledApp(appID string, configJSON string, installTarget string, kubeconfig string, kubeconfigContext string) error { + // 获取应用信息 + app, err := m.GetApp(appID) + if err != nil { + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "获取应用失败", + Details: err.Error(), + } + } + + // 使用 parser 解析和合并用户配置 + mergedApp, err := m.parser.ParseAndMergeUserConfig(app, configJSON) + if err != nil { + return &AppStoreError{ + Code: "CONFIG_MERGE_ERROR", + Message: "配置合并失败", + Details: err.Error(), + } + } + + // 根据安装目标和应用实际部署类型决定更新方式 + if installTarget == "kubeconfig" && kubeconfig != "" { + // 更新外部 Kubernetes 集群中的应用实例 + switch mergedApp.Deploy.Type { + case "kubernetes": + if err := m.UpdateAppInKubernetes(mergedApp, []byte(kubeconfig), kubeconfigContext); err != nil { + return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "Kubernetes 更新失败", Details: err.Error()} + } + return nil + case "docker": + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "外部环境 Docker 更新功能开发中"} + default: + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "外部环境更新功能开发中"} + } + } + + // 本地更新(依据应用部署类型) + switch mergedApp.Deploy.Type { + case "kubernetes": + if err := m.UpdateAppInKubernetes(mergedApp, nil, ""); err != nil { + return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "本地 Kubernetes 更新失败", Details: err.Error()} + } + return nil + case "docker": + // TODO: 实现 Docker 更新逻辑 + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地 Docker 更新功能开发中"} + default: + // 未知部署类型,优先尝试 K8s(若 deployment_type 支持) + if mergedApp.DeploymentType == "kubernetes" || mergedApp.DeploymentType == "both" { + if err := m.UpdateAppInKubernetes(mergedApp, nil, ""); err != nil { + return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "本地 Kubernetes 更新失败", Details: err.Error()} + } + return nil + } + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地更新功能开发中"} + } +} + // UninstallApp uninstalls an application based on the provided parameters func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig string, kubeconfigContext string) error { // 获取应用信息 @@ -492,10 +554,9 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st } } } else { - // 根据应用实际部署类型决定本地卸载方式 - switch app.Deploy.Type { - case "kubernetes": - // 应用部署在 Kubernetes,从本地 K8s 集群卸载 + // 优先检查应用实际部署类型 + if app.Deploy.Type == "kubernetes" { + // 应用实际部署在 Kubernetes,从本地 K8s 集群卸载 if err := m.UninstallAppFromKubernetes(app, nil, ""); err != nil { return &AppStoreError{ Code: "KUBERNETES_UNINSTALL_ERROR", @@ -503,21 +564,40 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st Details: err.Error(), } } - case "docker": - // 应用部署在 Docker,从本地 Docker 卸载 + return nil + } else if app.Deploy.Type == "docker" { + // 应用实际部署在 Docker,从本地 Docker 卸载 // TODO: 实现 Docker 卸载逻辑 return &AppStoreError{ Code: "NOT_IMPLEMENTED", Message: "本地 Docker 卸载功能开发中", } - default: - // 未知部署类型,默认尝试 Docker - // TODO: 实现 Docker 卸载逻辑 - return &AppStoreError{ - Code: "NOT_IMPLEMENTED", - Message: "本地卸载功能开发中", + } else if app.Deploy.Type == "" { + // 如果 Deploy.Type 为空,根据应用支持的部署类型尝试卸载 + if app.DeploymentType == "kubernetes" || app.DeploymentType == "both" { + // 尝试从 Kubernetes 卸载 + if err := m.UninstallAppFromKubernetes(app, nil, ""); err != nil { + return &AppStoreError{ + Code: "KUBERNETES_UNINSTALL_ERROR", + Message: "本地 Kubernetes 卸载失败", + Details: err.Error(), + } + } + return nil + } else if app.DeploymentType == "docker" { + // TODO: 实现 Docker 卸载逻辑 + return &AppStoreError{ + Code: "NOT_IMPLEMENTED", + Message: "本地 Docker 卸载功能开发中", + } } } + + // 如果以上都不匹配,返回未知部署类型错误 + return &AppStoreError{ + Code: "UNKNOWN_DEPLOYMENT_TYPE", + Message: "未知的部署类型,无法确定卸载方式", + } } return nil @@ -547,3 +627,109 @@ func (m *Manager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contextNam func (m *Manager) UpdateAppInKubernetes(app *App, kubeconfig []byte, contextName string) error { return m.k8s.UpdateAppInKubernetes(app, kubeconfig, contextName) } + +// IsInstalledAppReady 提供给上层的就绪判断入口,避免直接依赖 k8s 层实现 +func (m *Manager) IsInstalledAppReady(status *applicationv1.ApplicationStatus) bool { + return m.k8s.IsApplicationReady(status) +} + +// GetAppStatus 获取应用的安装和运行状态 +func (m *Manager) GetAppStatus(appID string) (map[string]interface{}, error) { + app, err := m.GetApp(appID) + if err != nil { + return nil, &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "应用不存在", + Details: err.Error(), + } + } + + // 检查应用是否已安装 + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + // 尝试从 Kubernetes 获取状态 + k8sApp, err := m.GetAppFromKubernetes(app, nil, "") + if err != nil { + // 应用未安装或获取状态失败 + return map[string]interface{}{ + "phase": "Not Installed", + "replicas": 0, + "readyReplicas": 0, + "ready": false, + }, nil + } + + // 应用已安装,返回状态信息 + if k8sApp != nil { + // 尝试解析 K8s Application 状态 + if appData, ok := k8sApp.(*applicationv1.Application); ok { + // 使用 Application CRD 的实际状态 + phase := string(appData.Status.Phase) + replicas := int(appData.Status.Replicas) + readyReplicas := int(appData.Status.ReadyReplicas) + + // 判断是否就绪 + ready := m.IsInstalledAppReady(&appData.Status) + + return map[string]interface{}{ + "phase": phase, + "replicas": replicas, + "readyReplicas": readyReplicas, + "ready": ready, + }, nil + } + // 如果无法解析,返回错误 + return nil, &AppStoreError{ + Code: "STATUS_PARSE_ERROR", + Message: "无法解析应用状态数据", + Details: fmt.Sprintf("期望 *applicationv1.Application 类型,实际类型: %T", k8sApp), + } + } + } + + // 默认返回未安装状态 + return map[string]interface{}{ + "phase": "Not Installed", + "replicas": 0, + "readyReplicas": 0, + "ready": false, + }, nil +} + +// StopApp stops an application by setting replicas to 0 +func (m *Manager) StopApp(appID string, installTarget string, kubeconfig []byte, kubeconfigContext string) error { + // 获取应用信息 + app, err := m.GetApp(appID) + if err != nil { + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "获取应用失败", + Details: err.Error(), + } + } + + // 检查应用是否支持 Kubernetes 部署 + if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + // 创建应用的副本并设置副本数为 0 + stoppedApp := *app + if stoppedApp.Deploy.Kubernetes != nil { + stoppedApp.Deploy.Kubernetes.Replicas = 0 + } + + // 调用 k8s 层的 UpdateAppInKubernetes 函数来停止应用 + if err := m.k8s.UpdateAppInKubernetes(&stoppedApp, kubeconfig, kubeconfigContext); err != nil { + return &AppStoreError{ + Code: "KUBERNETES_STOP_ERROR", + Message: "停止应用失败", + Details: err.Error(), + } + } + return nil + } + + // 如果应用不支持 Kubernetes 部署,返回错误 + return &AppStoreError{ + Code: "UNSUPPORTED_DEPLOYMENT_TYPE", + Message: "应用不支持 Kubernetes 部署,无法停止", + } +} diff --git a/services/appstore/parser.go b/services/appstore/parser.go index 65146a6049..d72911d24d 100644 --- a/services/appstore/parser.go +++ b/services/appstore/parser.go @@ -77,8 +77,24 @@ func (p *Parser) MergeUserConfig(app *App, userConfig UserConfig) (*App, error) return &mergedApp, nil } -// ParseApp 从原始 JSON 字节流解析 App -func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { +// // ParseApp 从原始 JSON 字节流解析 App +// func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { +// var app App +// if err := json.Unmarshal(jsonBytes, &app); err != nil { +// return nil, &AppStoreError{ +// Code: "INVALID_JSON", +// Message: "应用JSON格式错误", +// Details: err.Error(), +// } +// } +// if err := p.validateApp(&app); err != nil { +// return nil, err +// } +// return &app, nil +// } + +// ParseAppTemplate parses JSON bytes into an App struct for app templates (deploy.type is optional) +func (p *Parser) ParseAppTemplate(jsonBytes []byte) (*App, error) { var app App if err := json.Unmarshal(jsonBytes, &app); err != nil { return nil, &AppStoreError{ @@ -87,7 +103,7 @@ func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { Details: err.Error(), } } - if err := p.validateApp(&app); err != nil { + if err := p.validateAppTemplate(&app); err != nil { return nil, err } return &app, nil @@ -98,7 +114,7 @@ func (p *Parser) ParseAndMergeApp(jsonBytes []byte, baseApp *App) (*App, error) if baseApp == nil { return nil, &AppStoreError{Code: "INTERNAL", Message: "baseApp is nil"} } - parsed, err := p.ParseApp(jsonBytes) + parsed, err := p.ParseAppTemplate(jsonBytes) if err != nil { return nil, err } @@ -284,6 +300,68 @@ func (p *Parser) validateApp(app *App) error { return nil } +// validateAppTemplate validates application template (deploy.type is optional) +func (p *Parser) validateAppTemplate(app *App) error { + if app.ID == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: "App ID is required", + } + } + + if app.Name == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App name is required for app: %s", app.ID), + } + } + + if app.Category == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App category is required for app: %s", app.ID), + } + } + + if app.Version == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App version is required for app: %s", app.ID), + } + } + + if err := p.validateVersionFormat(app.Version); err != nil { + return err + } + + if err := p.validateAppConfig(&app.Config); err != nil { + return err + } + + // For app templates, deploy.type is optional, but if provided, it must be valid + if app.Deploy.Type != "" { + if err := p.validateAppDeploy(&app.Deploy); err != nil { + return err + } + } + + // Validate deployment_type value when provided + deploymentType := strings.ToLower(strings.TrimSpace(app.DeploymentType)) + if deploymentType != "" { + switch deploymentType { + case "docker", "kubernetes", "both": + // allowed + default: + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("Invalid deployment_type: %s (must be docker|kubernetes|both)", app.DeploymentType), + } + } + } + + return nil +} + // validateVersionFormat validates version string format func (p *Parser) validateVersionFormat(version string) error { // Simple version format validation (semantic versioning) diff --git a/templates/user/settings/appstore.tmpl b/templates/user/settings/appstore.tmpl index b52a4263c3..64c0fbc00d 100644 --- a/templates/user/settings/appstore.tmpl +++ b/templates/user/settings/appstore.tmpl @@ -200,18 +200,14 @@
@@ -292,6 +288,11 @@