package controller import ( "errors" "fmt" "strings" "github.com/gin-gonic/gin" "github.com/heicode/manager/common" "github.com/heicode/manager/model" "gorm.io/gorm" ) // Cloud resource discovery — provider-agnostic layer (#5). // // 历史上只有 Azure 发现(controller/azure_resource_discovery.go,硬要求 provider==azure)。 // 本文件抽象出统一的 provider 接口,让 AWS / GCP 走同一条「发现 → 归一化 → 落库」路径, // 并对 vm / database / blob 做跨云一致的分类(DoD #3)。Azure 适配器复用既有 ARM 客户端, // 故三家行为一致;具体云的签名/鉴权在 aws_resource_discovery.go / gcp_resource_discovery.go。 // discoveredCloudResource 是各 provider 适配器输出的**归一化**资源视图。upsert 时由统一 // 路径补 source_account_id 与 classified_type,并写入 ResourceBinding(resource_type=cloud_resource)。 type discoveredCloudResource struct { ExternalId string // ARN / Azure resource id / GCP asset name —— 跨 provider 的稳定外部 id Name string // 展示名 NativeType string // provider 原生类型字符串(如 Microsoft.Compute/virtualMachines、AWS::EC2::Instance、compute.googleapis.com/Instance) Location string // region / location BindingScope string // 可选;为空时默认 ":" Metadata map[string]any // provider 特定附加字段 } // cloudDiscoveryProvider 是「连接云账号 → 列资源」的统一抽象(DoD #3)。 type cloudDiscoveryProvider interface { name() string // discover 用 creds(已合并 metadata + secret JSON)列出 account 下的资源。 discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) } func cloudDiscoveryProviderByName(provider string) (cloudDiscoveryProvider, bool) { switch strings.ToLower(strings.TrimSpace(provider)) { case "azure": return azureCloudDiscoveryProvider{}, true case "aws", "amazon": return awsCloudDiscoveryProvider{}, true case "gcp", "google", "gce": return gcpCloudDiscoveryProvider{}, true } return nil, false } // classifyCloudResourceType 把 provider 原生类型映射到跨云一致的逻辑类别(DoD #3): // vm / database / blob / cloud_resource(兜底)。基于不区分大小写的子串匹配,覆盖三家常见服务。 func classifyCloudResourceType(nativeType string) string { t := strings.ToLower(nativeType) containsAny := func(s string, subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false } switch { // 数据库(放在 vm 之前:避免 "sqlvirtual..." 之类被误判为 vm;数据库特征更强) case containsAny(t, "sql", "postgres", "mysql", "mariadb", "rds", "database", "cosmos", "dynamodb", "spanner", "cloudsql", "bigtable", "redis", "documentdb", "memcache"): return "database" case containsAny(t, "virtualmachine", "ec2::instance", "ec2/instance", "compute/instance", "compute.googleapis.com/instance", "virtualmachinescaleset", "instance"): return "vm" case containsAny(t, "storageaccount", "::s3::", "s3:::", "/buckets/", "storage/bucket", "storage.googleapis.com/bucket", "blob", "bucket"): return "blob" default: return "cloud_resource" } } // upsertDiscoveredCloudResources 把归一化资源幂等写入 ResourceBinding(按 user+type+provider+external_id // 去重)。统一所有 provider 的落库格式:resource_type 固定为 cloud_resource(与既有 Azure 行为一致, // 不让发现的资源直接变成可绑定的 vm/db/blob),跨云类别放在 metadata.classified_type。 func upsertDiscoveredCloudResources(account model.ResourceBinding, provider string, discovered []discoveredCloudResource) ([]resourceResponse, error) { items := make([]resourceResponse, 0, len(discovered)) for _, d := range discovered { if strings.TrimSpace(d.ExternalId) == "" { continue } var resource model.ResourceBinding err := model.DB.Where( "user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?", account.UserId, "cloud_resource", provider, d.ExternalId, ).First(&resource).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil { return nil, err } if resource.Id == 0 { if err := model.DB.Create(&resource).Error; err != nil { return nil, err } } else if err := model.DB.Save(&resource).Error; err != nil { return nil, err } items = append(items, resourceToResponse(resource)) } return items, nil } // applyDiscoveredResourceFields 把一条归一化发现结果写入 ResourceBinding 字段(DB 无关,可单测)。 // 关键安全约束(#5 复审 #1):**SecretRef 恒为空**——发现出的子资源绝不继承云账号 secret_ref, // 否则会把账号级凭据引用扩散到每个 VM/S3/DB 并经 resourceToResponse / grant manifest 下发给 agent。 // 发现是只读清单;如需 agent 读取某资源,应另行 grant/审批/最小权限 secret。 func applyDiscoveredResourceFields(resource *model.ResourceBinding, account model.ResourceBinding, provider string, d discoveredCloudResource) error { metadata := map[string]any{} for k, v := range d.Metadata { metadata[k] = v } metadata["source_account_id"] = account.Id metadata["classified_type"] = classifyCloudResourceType(d.NativeType) if strings.TrimSpace(d.NativeType) != "" { metadata["native_type"] = d.NativeType } if strings.TrimSpace(d.Location) != "" { metadata["location"] = d.Location } metadataJSON, err := marshalResourceJSON(metadata) if err != nil { return err } permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}}) if err != nil { return err } constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints)) if err != nil { return err } name := strings.TrimSpace(d.Name) if name == "" { name = d.ExternalId } bindingScope := strings.TrimSpace(d.BindingScope) if bindingScope == "" { bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId) } resource.UserId = account.UserId resource.TenantId = account.TenantId resource.ProjectId = account.ProjectId resource.BindingScope = bindingScope resource.Name = name resource.ResourceType = "cloud_resource" resource.Provider = provider resource.ExternalId = d.ExternalId resource.SecretRef = "" // 见上:绝不继承账号 secret_ref resource.Metadata = metadataJSON resource.PermissionScope = permissionScopeJSON resource.Constraints = constraintsJSON resource.Status = "active" return nil } // DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic // discovery dispatcher (#5). Routes by the account's provider to the matching // adapter (azure/aws/gcp), then upserts via the unified path. func DiscoverCloudResources(c *gin.Context) { userId := c.GetInt("id") var account model.ResourceBinding if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&account).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { common.ApiErrorMsg(c, "resource not found") return } common.ApiError(c, err) return } if account.ResourceType != "cloud_account" || account.Status != "active" { common.ApiErrorMsg(c, "resource must be an active cloud_account") return } impl, ok := cloudDiscoveryProviderByName(account.Provider) if !ok { common.ApiErrorMsg(c, "unsupported cloud provider: "+account.Provider+" (supported: azure, aws, gcp)") return } // creds = metadata(非密)合并 secret JSON(密)。secret 优先级在适配器内部按需处理。 creds := unmarshalResourceJSON(account.Metadata) if creds == nil { creds = map[string]any{} } if strings.TrimSpace(account.SecretRef) != "" { secretClient, err := newSecretStoreClientFromEnv() if err != nil { common.ApiError(c, err) return } secretData, err := secretClient.getJSONSecret(account.SecretRef) if err != nil { common.ApiError(c, err) return } for k, v := range secretData { creds[k] = v // secret 覆盖同名非密字段 } } discovered, err := impl.discover(account, creds) if err != nil { common.ApiError(c, err) return } items, err := upsertDiscoveredCloudResources(account, impl.name(), discovered) if err != nil { common.ApiError(c, err) return } common.ApiSuccess(c, gin.H{ "account": resourceToResponse(account), "provider": impl.name(), "items": items, "discovered": len(items), }) } // azureCloudDiscoveryProvider 让既有 Azure ARM 发现实现统一接口(复用 azure_resource_discovery.go // 的 client/token/list),保证三家走同一抽象。 type azureCloudDiscoveryProvider struct{} func (azureCloudDiscoveryProvider) name() string { return "azure" } func (azureCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) { cred := azureDiscoveryCredentials{ SubscriptionID: firstString(creds, nil, account.ExternalId, "subscription_id"), TenantID: firstString(creds, nil, account.TenantId, "tenant_id"), ClientID: mapString(creds, "client_id"), ClientSecret: mapString(creds, "client_secret"), } if err := cred.validate(); err != nil { return nil, err } client := newAzureResourceDiscoveryClientFromEnv() token, err := client.clientCredentialsToken(cred) if err != nil { return nil, err } arm, err := client.listSubscriptionResources(cred.SubscriptionID, token) if err != nil { return nil, err } out := make([]discoveredCloudResource, 0, len(arm)) for _, r := range arm { out = append(out, discoveredCloudResource{ ExternalId: r.ID, Name: r.Name, NativeType: r.Type, Location: r.Location, BindingScope: fmt.Sprintf("azure:%s:%s", cred.SubscriptionID, r.ID), Metadata: map[string]any{ "subscription_id": cred.SubscriptionID, "tenant_id": cred.TenantID, "resource_group": azureResourceGroupFromID(r.ID), "tags": r.Tags, }, }) } return out, nil }