Merge pull request #63 from xmindlab-heicode/feat/aws-full-resource-discovery

feat(discovery): AWS 全量资源发现 — EC2/RDS/S3/Lambda List/Describe (#62)
This commit is contained in:
Fasthei
2026-06-10 21:35:03 +08:00
committed by GitHub
3 changed files with 541 additions and 28 deletions
+10 -16
View File
@@ -22,10 +22,11 @@ import (
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource.
//
// ⚠️ 覆盖范围(#5 复审 #2,据实声明):GetResources 仅返回**已打标签或曾打标签**的资源,
// **不覆盖完全未打标签的 EC2/RDS/S3 等**。因此本适配器是「**tagged resources only**」的发现,
// 不等于账号内全量资源;补全需后续加服务级 List/Describe(DescribeInstances / DescribeDBInstances /
// ListBuckets 等)。在此之前 #5 的「全量发现」诉求不应据本适配器关闭。
// ⚠️ 覆盖范围:GetResources 仅返回**已打标签或曾打标签**的资源,**不覆盖完全未打标签的
// EC2/RDS/S3 等**——本函数(discoverAWSTagged)是「**tagged resources only**」的一半。
// 全量发现由 discoverAWS(aws_resource_discovery_services.go,#62)把本结果与服务级
// List/Describe(DescribeInstances / DescribeDBInstances / ListBuckets / ListFunctions)
// 按 ARN 合并去重补齐。
//
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
// region, optional session_token.
@@ -185,18 +186,11 @@ type awsGetResourcesResponse struct {
PaginationToken string `json:"PaginationToken"`
}
// discoverAWS enumerates the account's tagged resources via the Resource Groups
// Tagging API (paginated). Real implementation (no mock).
func discoverAWS(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
cred := awsDiscoveryCredentials{
AccessKeyID: mapString(creds, "access_key_id"),
SecretAccessKey: mapString(creds, "secret_access_key"),
SessionToken: mapString(creds, "session_token"),
Region: mapString(creds, "region"),
}
if err := cred.validate(); err != nil {
return nil, err
}
// discoverAWSTagged enumerates the account's tagged resources via the Resource Groups
// Tagging API (paginated). Real implementation (no mock). Coverage is "tagged only"
// (see file header); discoverAWS (aws_resource_discovery_services.go) merges this with
// service-level List/Describe for full-account coverage (#62).
func discoverAWSTagged(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
endpoint := fmt.Sprintf("https://tagging.%s.amazonaws.com/", cred.Region)
const target = "ResourceGroupsTaggingAPI_20170126.GetResources"
@@ -0,0 +1,401 @@
package controller
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/heicode/manager/common"
)
// Service-level AWS discovery (#62): augments the tagged-only GetResources pass
// (aws_resource_discovery.go) with direct List/Describe so **untagged** EC2/RDS/S3/Lambda
// resources are found too — closing the "tagged only ≠ full account" gap (#5 follow-up).
//
// 设计:
// - discoverAWS 编排:先 tagged(最全的标签信息),再各服务级 List/Describe,按 ARN 合并去重
// (tagged 优先保留,信息更丰富)。
// - **失败降级**:任一服务调用失败(如缺该服务读权限 / AccessDenied)只记日志并跳过,绝不让
// 整次发现失败;仅当「tagged 报错且无任何服务返回结果」时,才把 tagged 的原始错误抛出,
// 好让账号凭据/区域问题对用户可见。
// - 复用 awsSigV4Authorization(已对 AWS get-vanilla 向量验证)签 GET;无 AWS SDK 依赖。
// - 区域:EC2/RDS/Lambda 用账号配置的 region;S3 ListBuckets 是全局接口(以 us-east-1 签名)。
// 跨 region 全量扫描不在本次范围(EC2/RDS 仅扫配置 region),由 #62 备注另行跟踪。
// - 解析逻辑(parseAWS* )为纯函数,便于用真实响应样本单测。
// discoverAWS enumerates the account's resources by merging the tagged-resources pass
// with service-level List/Describe (#62). Replaces the old tagged-only discoverAWS.
func discoverAWS(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
cred := awsDiscoveryCredentials{
AccessKeyID: mapString(creds, "access_key_id"),
SecretAccessKey: mapString(creds, "secret_access_key"),
SessionToken: mapString(creds, "session_token"),
Region: mapString(creds, "region"),
}
if err := cred.validate(); err != nil {
return nil, err
}
var lists [][]discoveredCloudResource
tagged, taggedErr := discoverAWSTagged(cred, now, httpClient)
if taggedErr != nil {
common.SysLog("discoverAWS tagged: " + taggedErr.Error())
} else {
lists = append(lists, tagged) // tagged 先入,合并时优先保留
}
services := []struct {
name string
fn func(awsDiscoveryCredentials, time.Time, *http.Client) ([]discoveredCloudResource, error)
}{
{"ec2", discoverAWSEC2},
{"rds", discoverAWSRDS},
{"s3", discoverAWSS3},
{"lambda", discoverAWSLambda},
}
for _, svc := range services {
list, err := svc.fn(cred, now, httpClient)
if err != nil {
common.SysLog("discoverAWS " + svc.name + ": " + err.Error())
continue
}
lists = append(lists, list)
}
merged := mergeDedupeByExternalID(lists...)
// 全部为空且 tagged 当初报错 → 抛出原始错误,让凭据/区域问题可见(纯权限缺失则降级返回已得结果)。
if len(merged) == 0 && taggedErr != nil {
return nil, taggedErr
}
return merged, nil
}
// mergeDedupeByExternalID 按 ExternalId(ARN)合并多组发现结果,先到先得(靠前的列表优先保留),
// 保持稳定顺序。纯函数,便于单测。
func mergeDedupeByExternalID(lists ...[]discoveredCloudResource) []discoveredCloudResource {
seen := map[string]bool{}
out := make([]discoveredCloudResource, 0)
for _, list := range lists {
for _, d := range list {
id := strings.TrimSpace(d.ExternalId)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, d)
}
}
return out
}
// awsSignedGet 发起一次 SigV4 签名的 GET(空 body),返回响应体。非 2xx 视为错误。
func awsSignedGet(cred awsDiscoveryCredentials, rawURL, service string, now time.Time, httpClient *http.Client) ([]byte, error) {
auth, amzDate, err := awsSigV4Authorization(http.MethodGet, rawURL, map[string]string{}, nil, cred, service, now)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Amz-Date", amzDate)
req.Header.Set("Authorization", auth)
if strings.TrimSpace(cred.SessionToken) != "" {
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return body, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ---- EC2 DescribeInstances (Query 协议, XML) ----
type ec2DescribeInstancesResponse struct {
XMLName xml.Name `xml:"DescribeInstancesResponse"`
Reservations []struct {
OwnerID string `xml:"ownerId"`
Instances []struct {
InstanceID string `xml:"instanceId"`
InstanceType string `xml:"instanceType"`
State struct {
Name string `xml:"name"`
} `xml:"instanceState"`
Placement struct {
AvailabilityZone string `xml:"availabilityZone"`
} `xml:"placement"`
Tags []struct {
Key string `xml:"key"`
Value string `xml:"value"`
} `xml:"tagSet>item"`
} `xml:"instancesSet>item"`
} `xml:"reservationSet>item"`
NextToken string `xml:"nextToken"`
}
// parseEC2Instances 解析 DescribeInstances XML → 归一化资源 + nextToken。纯函数。
func parseEC2Instances(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r ec2DescribeInstancesResponse
if err := xml.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, res := range r.Reservations {
for _, inst := range res.Instances {
if strings.TrimSpace(inst.InstanceID) == "" {
continue
}
arn := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", region, res.OwnerID, inst.InstanceID)
tags := map[string]any{}
name := inst.InstanceID
for _, t := range inst.Tags {
tags[t.Key] = t.Value
if strings.EqualFold(t.Key, "Name") && strings.TrimSpace(t.Value) != "" {
name = t.Value
}
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::EC2::Instance",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{
"region": region,
"instance_type": inst.InstanceType,
"state": inst.State.Name,
"tags": tags,
},
})
}
}
return out, strings.TrimSpace(r.NextToken), nil
}
func discoverAWSEC2(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
nextToken := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("Action", "DescribeInstances")
q.Set("Version", "2016-11-15")
q.Set("MaxResults", "100")
if nextToken != "" {
q.Set("NextToken", nextToken)
}
rawURL := fmt.Sprintf("https://ec2.%s.amazonaws.com/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "ec2", now, httpClient)
if err != nil {
return nil, err
}
page1, token, err := parseEC2Instances(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
nextToken = token
if nextToken == "" {
break
}
}
return out, nil
}
// ---- RDS DescribeDBInstances (Query 协议, XML) ----
type rdsDescribeDBInstancesResponse struct {
XMLName xml.Name `xml:"DescribeDBInstancesResponse"`
Result struct {
Marker string `xml:"Marker"`
DBInstances []struct {
Arn string `xml:"DBInstanceArn"`
Identifier string `xml:"DBInstanceIdentifier"`
Engine string `xml:"Engine"`
Status string `xml:"DBInstanceStatus"`
AvailabilityZone string `xml:"AvailabilityZone"`
} `xml:"DBInstances>DBInstance"`
} `xml:"DescribeDBInstancesResult"`
}
// parseRDSInstances 解析 DescribeDBInstances XML → 归一化资源 + marker。纯函数。
func parseRDSInstances(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r rdsDescribeDBInstancesResponse
if err := xml.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, db := range r.Result.DBInstances {
arn := strings.TrimSpace(db.Arn)
if arn == "" {
continue // 无 ARN 不入(稳定外部 id 缺失)
}
name := strings.TrimSpace(db.Identifier)
if name == "" {
name = arn
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::RDS::DBInstance",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{
"region": region,
"engine": db.Engine,
"status": db.Status,
"az": db.AvailabilityZone,
},
})
}
return out, strings.TrimSpace(r.Result.Marker), nil
}
func discoverAWSRDS(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
marker := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("Action", "DescribeDBInstances")
q.Set("Version", "2014-10-31")
q.Set("MaxRecords", "100")
if marker != "" {
q.Set("Marker", marker)
}
rawURL := fmt.Sprintf("https://rds.%s.amazonaws.com/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "rds", now, httpClient)
if err != nil {
return nil, err
}
page1, next, err := parseRDSInstances(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
marker = next
if marker == "" {
break
}
}
return out, nil
}
// ---- S3 ListBuckets (REST-XML, 全局) ----
type s3ListAllMyBucketsResult struct {
XMLName xml.Name `xml:"ListAllMyBucketsResult"`
Buckets []struct {
Name string `xml:"Name"`
} `xml:"Buckets>Bucket"`
}
// parseS3Buckets 解析 ListBuckets XML → 归一化资源。纯函数。S3 bucket ARN 无 region/account 段。
func parseS3Buckets(body []byte) ([]discoveredCloudResource, error) {
var r s3ListAllMyBucketsResult
if err := xml.Unmarshal(body, &r); err != nil {
return nil, err
}
out := []discoveredCloudResource{}
for _, b := range r.Buckets {
name := strings.TrimSpace(b.Name)
if name == "" {
continue
}
arn := "arn:aws:s3:::" + name
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::S3::Bucket",
Location: "", // 全局列举不含 region;按需 GetBucketLocation(未做,避免逐桶请求)
BindingScope: "aws:" + arn,
Metadata: map[string]any{"bucket": name},
})
}
return out, nil
}
func discoverAWSS3(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
// ListBuckets 是全局接口,固定以 us-east-1 / service=s3 签名。
s3Cred := cred
s3Cred.Region = "us-east-1"
body, err := awsSignedGet(s3Cred, "https://s3.amazonaws.com/", "s3", now, httpClient)
if err != nil {
return nil, err
}
return parseS3Buckets(body)
}
// ---- Lambda ListFunctions (REST-JSON) ----
type lambdaListFunctionsResponse struct {
Functions []struct {
FunctionArn string `json:"FunctionArn"`
FunctionName string `json:"FunctionName"`
Runtime string `json:"Runtime"`
} `json:"Functions"`
NextMarker string `json:"NextMarker"`
}
// parseLambdaFunctions 解析 ListFunctions JSON → 归一化资源 + nextMarker。纯函数。
func parseLambdaFunctions(body []byte, region string) ([]discoveredCloudResource, string, error) {
var r lambdaListFunctionsResponse
if err := common.Unmarshal(body, &r); err != nil {
return nil, "", err
}
out := []discoveredCloudResource{}
for _, fn := range r.Functions {
arn := strings.TrimSpace(fn.FunctionArn)
if arn == "" {
continue
}
name := strings.TrimSpace(fn.FunctionName)
if name == "" {
name = arn
}
out = append(out, discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: "AWS::Lambda::Function",
Location: region,
BindingScope: "aws:" + arn,
Metadata: map[string]any{"region": region, "runtime": fn.Runtime},
})
}
return out, strings.TrimSpace(r.NextMarker), nil
}
func discoverAWSLambda(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
out := []discoveredCloudResource{}
marker := ""
for page := 0; page < 50; page++ {
q := url.Values{}
q.Set("MaxItems", "50")
if marker != "" {
q.Set("Marker", marker)
}
rawURL := fmt.Sprintf("https://lambda.%s.amazonaws.com/2015-03-31/functions/?%s", cred.Region, q.Encode())
body, err := awsSignedGet(cred, rawURL, "lambda", now, httpClient)
if err != nil {
return nil, err
}
page1, next, err := parseLambdaFunctions(body, cred.Region)
if err != nil {
return nil, err
}
out = append(out, page1...)
marker = next
if marker == "" {
break
}
}
return out, nil
}
@@ -20,21 +20,21 @@ import (
func TestClassifyCloudResourceType(t *testing.T) {
cases := map[string]string{
// vm
"Microsoft.Compute/virtualMachines": "vm",
"AWS::EC2::Instance": "vm",
"compute.googleapis.com/Instance": "vm",
"Microsoft.Compute/virtualMachines": "vm",
"AWS::EC2::Instance": "vm",
"compute.googleapis.com/Instance": "vm",
// database
"Microsoft.Sql/servers/databases": "database",
"AWS::RDS::DBInstance": "database",
"sqladmin.googleapis.com/Instance": "database",
"AWS::DynamoDB::Table": "database",
"Microsoft.Sql/servers/databases": "database",
"AWS::RDS::DBInstance": "database",
"sqladmin.googleapis.com/Instance": "database",
"AWS::DynamoDB::Table": "database",
// blob / object storage
"Microsoft.Storage/storageAccounts": "blob",
"AWS::S3::Bucket": "blob",
"storage.googleapis.com/Bucket": "blob",
"Microsoft.Storage/storageAccounts": "blob",
"AWS::S3::Bucket": "blob",
"storage.googleapis.com/Bucket": "blob",
// fallback
"Microsoft.Network/virtualNetworks": "cloud_resource",
"AWS::IAM::Role": "cloud_resource",
"Microsoft.Network/virtualNetworks": "cloud_resource",
"AWS::IAM::Role": "cloud_resource",
}
for native, want := range cases {
require.Equal(t, want, classifyCloudResourceType(native), "classify %s", native)
@@ -148,3 +148,121 @@ func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
require.Contains(t, string(claims), "svc@proj.iam.gserviceaccount.com")
require.Contains(t, string(claims), "cloud-platform")
}
// #62: EC2 DescribeInstances XML 解析 —— 含 Name 标签 / ownerId / nextToken;ARN 由 region+owner+id 组装。
func TestParseEC2Instances(t *testing.T) {
body := []byte(`<?xml version="1.0"?>
<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<reservationSet>
<item>
<ownerId>123456789012</ownerId>
<instancesSet>
<item>
<instanceId>i-0abc123</instanceId>
<instanceType>t3.micro</instanceType>
<instanceState><name>running</name></instanceState>
<placement><availabilityZone>us-east-1a</availabilityZone></placement>
<tagSet>
<item><key>Name</key><value>web-1</value></item>
<item><key>env</key><value>prod</value></item>
</tagSet>
</item>
<item>
<instanceId>i-0def456</instanceId>
<instanceType>t3.small</instanceType>
</item>
</instancesSet>
</item>
</reservationSet>
<nextToken>NEXT==</nextToken>
</DescribeInstancesResponse>`)
out, next, err := parseEC2Instances(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "NEXT==", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123", out[0].ExternalId)
require.Equal(t, "web-1", out[0].Name) // Name 标签优先
require.Equal(t, "AWS::EC2::Instance", out[0].NativeType)
require.Equal(t, "vm", classifyCloudResourceType(out[0].NativeType))
require.Equal(t, "i-0def456", out[1].Name) // 无 Name 标签回退 instanceId
}
// #62: RDS DescribeDBInstances XML 解析 —— ARN 直接取 DBInstanceArn;无 ARN 跳过;Marker 续页。
func TestParseRDSInstances(t *testing.T) {
body := []byte(`<DescribeDBInstancesResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/">
<DescribeDBInstancesResult>
<Marker>m2</Marker>
<DBInstances>
<DBInstance>
<DBInstanceArn>arn:aws:rds:eu-west-1:123456789012:db:prod-pg</DBInstanceArn>
<DBInstanceIdentifier>prod-pg</DBInstanceIdentifier>
<Engine>postgres</Engine>
<DBInstanceStatus>available</DBInstanceStatus>
</DBInstance>
<DBInstance>
<DBInstanceIdentifier>no-arn</DBInstanceIdentifier>
<Engine>mysql</Engine>
</DBInstance>
</DBInstances>
</DescribeDBInstancesResult>
</DescribeDBInstancesResponse>`)
out, marker, err := parseRDSInstances(body, "eu-west-1")
require.NoError(t, err)
require.Equal(t, "m2", marker)
require.Len(t, out, 1) // 无 ARN 的被跳过
require.Equal(t, "arn:aws:rds:eu-west-1:123456789012:db:prod-pg", out[0].ExternalId)
require.Equal(t, "prod-pg", out[0].Name)
require.Equal(t, "database", classifyCloudResourceType(out[0].NativeType))
}
// #62: S3 ListBuckets XML 解析 —— ARN 为 arn:aws:s3:::<name>,全局无 region。
func TestParseS3Buckets(t *testing.T) {
body := []byte(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner><ID>x</ID></Owner>
<Buckets>
<Bucket><Name>my-bucket</Name><CreationDate>2024-01-01T00:00:00Z</CreationDate></Bucket>
<Bucket><Name>logs-bucket</Name></Bucket>
</Buckets>
</ListAllMyBucketsResult>`)
out, err := parseS3Buckets(body)
require.NoError(t, err)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:s3:::my-bucket", out[0].ExternalId)
require.Equal(t, "my-bucket", out[0].Name)
require.Equal(t, "blob", classifyCloudResourceType(out[0].NativeType))
require.Empty(t, out[0].Location)
}
// #62: Lambda ListFunctions JSON 解析 —— ARN 取 FunctionArn;NextMarker 续页。
func TestParseLambdaFunctions(t *testing.T) {
body := []byte(`{"Functions":[
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-a","FunctionName":"fn-a","Runtime":"go1.x"},
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-b","FunctionName":"fn-b","Runtime":"python3.12"}
],"NextMarker":"mk"}`)
out, next, err := parseLambdaFunctions(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "mk", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:lambda:us-east-1:123456789012:function:fn-a", out[0].ExternalId)
require.Equal(t, "fn-a", out[0].Name)
require.Equal(t, "AWS::Lambda::Function", out[0].NativeType)
}
// #62: 合并去重 —— 先到先得(tagged 优先保留),空 ExternalId 丢弃,顺序稳定。
func TestMergeDedupeByExternalID(t *testing.T) {
tagged := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "tagged-a", Metadata: map[string]any{"tags": map[string]any{"k": "v"}}},
{ExternalId: "arn:b", Name: "tagged-b"},
}
ec2 := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "ec2-a"}, // 与 tagged 重复 → 保留 tagged
{ExternalId: "arn:c", Name: "ec2-c"}, // 未打标签的新资源
{ExternalId: "", Name: "empty"}, // 空 id 丢弃
}
merged := mergeDedupeByExternalID(tagged, ec2)
require.Len(t, merged, 3)
require.Equal(t, "tagged-a", merged[0].Name) // tagged 优先
require.NotNil(t, merged[0].Metadata["tags"]) // 保留更丰富的 tagged 元数据
require.Equal(t, "tagged-b", merged[1].Name)
require.Equal(t, "ec2-c", merged[2].Name) // 补齐未打标签资源
}