diff --git a/pkg/microservice/aslan/core/environment/handler/environment.go b/pkg/microservice/aslan/core/environment/handler/environment.go index e9db96bd8c..0612b5f2e1 100644 --- a/pkg/microservice/aslan/core/environment/handler/environment.go +++ b/pkg/microservice/aslan/core/environment/handler/environment.go @@ -49,7 +49,8 @@ import ( ) type DeleteProductServicesRequest struct { - ServiceNames []string `json:"service_names"` + ServiceNames []string `json:"service_names"` + DeleteResources map[string][]*service.K8sServiceResource `json:"delete_resources,omitempty"` } type DeleteProductHelmReleaseRequest struct { @@ -1743,7 +1744,7 @@ func DeleteProductServices(c *gin.Context) { detail := fmt.Sprintf("%s:[%s]", envName, strings.Join(args.ServiceNames, ",")) internalhandler.InsertDetailedOperationLog(c, ctx.UserName, projectKey, setting.OperationSceneEnv, "删除", "环境的服务", detail, detail, "", types.RequestBodyTypeJSON, ctx.Logger, envName) - ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, envName, projectKey, args.ServiceNames, production, isDelete, ctx.Logger) + ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, envName, projectKey, args.ServiceNames, production, isDelete, args.DeleteResources, ctx.Logger) } // @Summary Delete helm release from envrionment diff --git a/pkg/microservice/aslan/core/environment/handler/openapi.go b/pkg/microservice/aslan/core/environment/handler/openapi.go index f75fc6e52a..3809b76dda 100644 --- a/pkg/microservice/aslan/core/environment/handler/openapi.go +++ b/pkg/microservice/aslan/core/environment/handler/openapi.go @@ -494,7 +494,7 @@ func OpenAPIDeleteYamlServiceFromEnv(c *gin.Context) { } } - ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, req.EnvName, projectKey, req.ServiceNames, false, !req.NotDeleteResource, ctx.Logger) + ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, req.EnvName, projectKey, req.ServiceNames, false, !req.NotDeleteResource, nil, ctx.Logger) } func OpenAPIDeleteProductionYamlServiceFromEnv(c *gin.Context) { @@ -550,7 +550,7 @@ func OpenAPIDeleteProductionYamlServiceFromEnv(c *gin.Context) { return } - ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, req.EnvName, projectKey, req.ServiceNames, true, !req.NotDeleteResource, ctx.Logger) + ctx.RespErr = service.DeleteProductServices(ctx.UserName, ctx.RequestID, req.EnvName, projectKey, req.ServiceNames, true, !req.NotDeleteResource, nil, ctx.Logger) } func OpenAPIApplyProductionYamlService(c *gin.Context) { diff --git a/pkg/microservice/aslan/core/environment/handler/router.go b/pkg/microservice/aslan/core/environment/handler/router.go index e6194c57e4..09d097ebbd 100644 --- a/pkg/microservice/aslan/core/environment/handler/router.go +++ b/pkg/microservice/aslan/core/environment/handler/router.go @@ -173,6 +173,7 @@ func (*Router) Inject(router *gin.RouterGroup) { environments.GET("/:name/services/:serviceName", GetService) environments.PUT("/:name/services/:serviceName", UpdateService) environments.GET("/:name/services/:serviceName/yaml", FetchServiceYaml) + environments.GET("/:name/services/:serviceName/resources", ListServiceResources) environments.POST("/:name/services/:serviceName/preview", PreviewService) environments.POST("/:name/services/preview/batch", BatchPreviewServices) environments.POST("/:name/services/:serviceName/restart", RestartService) diff --git a/pkg/microservice/aslan/core/environment/handler/service.go b/pkg/microservice/aslan/core/environment/handler/service.go index 924a808573..db3b0bd401 100644 --- a/pkg/microservice/aslan/core/environment/handler/service.go +++ b/pkg/microservice/aslan/core/environment/handler/service.go @@ -222,6 +222,10 @@ type FetchServiceYamlResponse struct { Yaml string `json:"yaml"` } +type ListServiceResourcesResponse struct { + Resources []*service.K8sServiceResource `json:"resources"` +} + // @Summary Fetch Service Yaml // @Description Fetch Service Yaml // @Tags environment @@ -284,6 +288,68 @@ func FetchServiceYaml(c *gin.Context) { ctx.Resp = resp } +// @Summary List service resources +// @Description List k8s yaml service resources +// @Tags environment +// @Accept json +// @Produce json +// @Param projectName query string true "project name" +// @Param name path string true "env name" +// @Param serviceName path string true "service name" +// @Success 200 {object} ListServiceResourcesResponse +// @Router /api/aslan/environment/environments/{name}/services/{serviceName}/resources [get] +func ListServiceResources(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + serviceName := c.Param("serviceName") + envName := c.Param("name") + projectKey := c.Query("projectName") + production := c.Query("production") == "true" + + // authorization checks + if !ctx.Resources.IsSystemAdmin { + if _, ok := ctx.Resources.ProjectAuthInfo[projectKey]; !ok { + ctx.UnAuthorized = true + return + } + if production { + if !ctx.Resources.ProjectAuthInfo[projectKey].IsProjectAdmin && + !ctx.Resources.ProjectAuthInfo[projectKey].ProductionEnv.View { + permitted, err := internalhandler.GetCollaborationModePermission(ctx.UserID, projectKey, types.ResourceTypeEnvironment, envName, types.ProductionEnvActionView) + if err != nil || !permitted { + ctx.UnAuthorized = true + return + } + } + + err = commonutil.CheckZadigProfessionalLicense() + if err != nil { + ctx.RespErr = err + return + } + } else { + if !ctx.Resources.ProjectAuthInfo[projectKey].IsProjectAdmin && + !ctx.Resources.ProjectAuthInfo[projectKey].Env.View { + permitted, err := internalhandler.GetCollaborationModePermission(ctx.UserID, projectKey, types.ResourceTypeEnvironment, envName, types.EnvActionView) + if err != nil || !permitted { + ctx.UnAuthorized = true + return + } + } + } + } + + resp := new(ListServiceResourcesResponse) + resp.Resources, ctx.RespErr = service.ListK8sServiceResources(projectKey, envName, serviceName, production, ctx.Logger) + ctx.Resp = resp +} + // @Summary Preview service // @Description Preview service // @Tags environment diff --git a/pkg/microservice/aslan/core/environment/service/environment.go b/pkg/microservice/aslan/core/environment/service/environment.go index 2b9fab9efb..353a14f329 100644 --- a/pkg/microservice/aslan/core/environment/service/environment.go +++ b/pkg/microservice/aslan/core/environment/service/environment.go @@ -2574,7 +2574,7 @@ func DeleteProduct(username, envName, productName, requestID string, isDelete bo } // @todo fix env already deleted issue, may cause service not really deleted in k8s - err = DeleteProductServices("", requestID, envName, productName, svcNames, false, isDelete, log) + err = DeleteProductServices("", requestID, envName, productName, svcNames, false, isDelete, nil, log) if err != nil { log.Warnf("DeleteProductServices error: %v", err) } @@ -2617,7 +2617,7 @@ func DeleteProduct(username, envName, productName, requestID string, isDelete bo return nil } -func DeleteProductServices(userName, requestID, envName, productName string, serviceNames []string, production, isDelete bool, log *zap.SugaredLogger) (err error) { +func DeleteProductServices(userName, requestID, envName, productName string, serviceNames []string, production, isDelete bool, deleteResources map[string][]*K8sServiceResource, log *zap.SugaredLogger) (err error) { productInfo, err := commonrepo.NewProductColl().Find(&commonrepo.ProductFindOptions{Name: productName, EnvName: envName, Production: util.GetBoolPointer(production)}) if err != nil { err = fmt.Errorf("failed to find product, productName: %s, envName: %s, production: %v, error: %v", productName, envName, production, err) @@ -2627,7 +2627,7 @@ func DeleteProductServices(userName, requestID, envName, productName string, ser if getProjectType(productName) == setting.HelmDeployType { return deleteHelmProductServices(userName, requestID, productInfo, serviceNames, isDelete, log) } - return deleteK8sProductServices(productInfo, serviceNames, isDelete, log) + return deleteK8sProductServices(productInfo, serviceNames, isDelete, deleteResources, log) } func DeleteProductHelmReleases(userName, requestID, envName, productName string, releases []string, production, isDelete bool, log *zap.SugaredLogger) (err error) { @@ -2643,7 +2643,7 @@ func deleteHelmProductServices(userName, requestID string, productInfo *commonmo return kube.DeleteHelmServiceFromEnv(userName, requestID, productInfo, serviceNames, isDelete, log) } -func deleteK8sProductServices(productInfo *commonmodels.Product, serviceNames []string, isDelete bool, log *zap.SugaredLogger) error { +func deleteK8sProductServices(productInfo *commonmodels.Product, serviceNames []string, isDelete bool, deleteResources map[string][]*K8sServiceResource, log *zap.SugaredLogger) error { serviceRelatedYaml := make(map[string]string) for _, service := range productInfo.GetServiceMap() { if !commonutil.ServiceIsDeployed(service.ServiceName, productInfo.ServiceDeployStrategy) || !isDelete { @@ -2660,6 +2660,20 @@ func deleteK8sProductServices(productInfo *commonmodels.Product, serviceNames [] log.Errorf("failed to remove k8s resources when rendering yaml for service : %s, err: %s", service.ServiceName, err) return fmt.Errorf("failed to remove k8s resources when rendering yaml for service : %s, err: %s", service.ServiceName, err) } + + if deleteResources != nil { + selectedResources, ok := deleteResources[service.ServiceName] + if ok { + yaml, err = filterSelectedServiceResourceYaml(yaml, selectedResources) + if err != nil { + log.Errorf("failed to filter selected k8s resources for service : %s, err: %s", service.ServiceName, err) + return fmt.Errorf("failed to filter selected k8s resources for service : %s, err: %s", service.ServiceName, err) + } + if len(selectedResources) == 0 { + log.Infof("all k8s resources are retained when deleting service %s in env %s/%s", service.ServiceName, productInfo.ProductName, productInfo.EnvName) + } + } + } serviceRelatedYaml[service.ServiceName] = yaml } } diff --git a/pkg/microservice/aslan/core/environment/service/service.go b/pkg/microservice/aslan/core/environment/service/service.go index 8e40402b14..101a30a8e7 100644 --- a/pkg/microservice/aslan/core/environment/service/service.go +++ b/pkg/microservice/aslan/core/environment/service/service.go @@ -20,6 +20,8 @@ import ( "context" "fmt" "slices" + "sort" + "strings" "github.com/koderover/zadig/v2/pkg/tool/clientmanager" "github.com/pkg/errors" @@ -313,6 +315,99 @@ func FetchServiceYaml(productName, envName, serviceName string, _ *zap.SugaredLo return curYaml, nil } +func ListK8sServiceResources(productName, envName, serviceName string, production bool, log *zap.SugaredLogger) ([]*K8sServiceResource, error) { + if getProjectType(productName) != setting.K8SDeployType { + return nil, e.ErrInvalidParam.AddDesc("only k8s yaml service resources are supported") + } + + productInfo, err := commonrepo.NewProductColl().Find(&commonrepo.ProductFindOptions{ + Name: productName, + EnvName: envName, + Production: util.GetBoolPointer(production), + }) + if err != nil { + return nil, e.ErrGetService.AddErr(err) + } + if productInfo.GetServiceMap()[serviceName] == nil { + return nil, e.ErrGetService.AddDesc(fmt.Sprintf("service %s not found in environment", serviceName)) + } + + serviceYaml, err := FetchServiceYaml(productName, envName, serviceName, log) + if err != nil { + return nil, err + } + + return parseK8sServiceResources(serviceYaml) +} + +func parseK8sServiceResources(serviceYaml string) ([]*K8sServiceResource, error) { + resources, _, err := kube.ManifestToUnstructured(serviceYaml) + if err != nil { + return nil, err + } + + ret := make([]*K8sServiceResource, 0, len(resources)) + for _, resource := range resources { + ret = append(ret, &K8sServiceResource{ + APIVersion: resource.GetAPIVersion(), + Kind: resource.GetKind(), + Name: resource.GetName(), + }) + } + return ret, nil +} + +func filterSelectedServiceResourceYaml(serviceYaml string, selectedResources []*K8sServiceResource) (string, error) { + if len(selectedResources) == 0 { + return "", nil + } + + selectedResourceMap := make(map[string]struct{}, len(selectedResources)) + for _, resource := range selectedResources { + if resource == nil || resource.APIVersion == "" || resource.Kind == "" || resource.Name == "" { + return "", fmt.Errorf("selected resource api_version, kind and name cannot be empty") + } + selectedResourceMap[k8sServiceResourceKey(resource.APIVersion, resource.Kind, resource.Name)] = struct{}{} + } + + resources, resourceMap, err := kube.ManifestToUnstructured(serviceYaml) + if err != nil { + return "", err + } + + filteredManifests := make([]string, 0, len(selectedResourceMap)) + foundResourceMap := make(map[string]struct{}, len(selectedResourceMap)) + for _, resource := range resources { + key := k8sServiceResourceKey(resource.GetAPIVersion(), resource.GetKind(), resource.GetName()) + if _, ok := selectedResourceMap[key]; !ok { + continue + } + + gvkn := fmt.Sprintf("%s-%s", resource.GetObjectKind().GroupVersionKind(), resource.GetName()) + if resourceInfo, ok := resourceMap[gvkn]; ok { + filteredManifests = append(filteredManifests, resourceInfo.Manifest) + foundResourceMap[key] = struct{}{} + } + } + + if len(foundResourceMap) != len(selectedResourceMap) { + missingResources := make([]string, 0, len(selectedResourceMap)-len(foundResourceMap)) + for key := range selectedResourceMap { + if _, ok := foundResourceMap[key]; !ok { + missingResources = append(missingResources, key) + } + } + sort.Strings(missingResources) + return "", fmt.Errorf("selected resources not found in service yaml: %s", strings.Join(missingResources, ", ")) + } + + return util.JoinYamls(filteredManifests), nil +} + +func k8sServiceResourceKey(apiVersion, kind, name string) string { + return fmt.Sprintf("%s/%s/%s", apiVersion, kind, name) +} + func PreviewService(args *PreviewServiceArgs, _ *zap.SugaredLogger) (*SvcDiffResult, error) { envInfo, err := commonrepo.NewProductColl().Find(&commonrepo.ProductFindOptions{ Name: args.ProductName, diff --git a/pkg/microservice/aslan/core/environment/service/service_resource_test.go b/pkg/microservice/aslan/core/environment/service/service_resource_test.go new file mode 100644 index 0000000000..8772f5bb1f --- /dev/null +++ b/pkg/microservice/aslan/core/environment/service/service_resource_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "strings" + "testing" +) + +const testServiceResourceYaml = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo +--- +apiVersion: v1 +kind: Service +metadata: + name: demo +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: demo +` + +func TestParseK8sServiceResources(t *testing.T) { + resources, err := parseK8sServiceResources(testServiceResourceYaml) + if err != nil { + t.Fatalf("parseK8sServiceResources() error = %v", err) + } + + if len(resources) != 3 { + t.Fatalf("expected 3 resources, got %d", len(resources)) + } + if resources[0].APIVersion != "apps/v1" || resources[0].Kind != "Deployment" || resources[0].Name != "demo" { + t.Fatalf("unexpected first resource: %+v", resources[0]) + } +} + +func TestFilterSelectedServiceResourceYaml(t *testing.T) { + filtered, err := filterSelectedServiceResourceYaml(testServiceResourceYaml, []*K8sServiceResource{ + {APIVersion: "v1", Kind: "Service", Name: "demo"}, + {APIVersion: "networking.k8s.io/v1", Kind: "Ingress", Name: "demo"}, + }) + if err != nil { + t.Fatalf("filterSelectedServiceResourceYaml() error = %v", err) + } + + resources, err := parseK8sServiceResources(filtered) + if err != nil { + t.Fatalf("parse filtered yaml error = %v", err) + } + if len(resources) != 2 { + t.Fatalf("expected 2 resources, got %d", len(resources)) + } + if resources[0].Kind != "Service" || resources[1].Kind != "Ingress" { + t.Fatalf("unexpected filtered resources: %+v", resources) + } + if strings.Contains(filtered, "kind: Deployment") { + t.Fatalf("filtered yaml should not contain Deployment: %s", filtered) + } +} + +func TestFilterSelectedServiceResourceYamlEmptySelection(t *testing.T) { + filtered, err := filterSelectedServiceResourceYaml(testServiceResourceYaml, nil) + if err != nil { + t.Fatalf("filterSelectedServiceResourceYaml() error = %v", err) + } + if filtered != "" { + t.Fatalf("expected empty yaml, got %q", filtered) + } +} + +func TestFilterSelectedServiceResourceYamlMissingResource(t *testing.T) { + _, err := filterSelectedServiceResourceYaml(testServiceResourceYaml, []*K8sServiceResource{ + {APIVersion: "v1", Kind: "ConfigMap", Name: "missing"}, + }) + if err == nil { + t.Fatal("expected missing resource error") + } +} diff --git a/pkg/microservice/aslan/core/environment/service/types.go b/pkg/microservice/aslan/core/environment/service/types.go index e0e4b685ee..2bb4c4e8ac 100644 --- a/pkg/microservice/aslan/core/environment/service/types.go +++ b/pkg/microservice/aslan/core/environment/service/types.go @@ -86,6 +86,12 @@ type PreviewServiceArgs struct { DeployContents []config.DeployContent `json:"deploy_contents"` } +type K8sServiceResource struct { + APIVersion string `json:"api_version"` + Kind string `json:"kind"` + Name string `json:"name"` +} + type RestartScaleArgs struct { Type string `json:"type"` ProductName string `json:"product_name"`