新增对象存储/账单/保留IP模块,删桶并发提速与成本增强
CI / test (push) Successful in 33s

This commit is contained in:
2026-07-21 12:11:05 +08:00
parent 9cfde8b702
commit f40f2a20e8
44 changed files with 7788 additions and 54 deletions
+23 -2
View File
@@ -7,6 +7,18 @@ import (
"oci-portal/internal/oci"
)
// ChangeVnicPublicIP 更换指定 VNIC 的临时公网 IP,返回新地址(旧保留 IP 自动解绑)。
func (s *OciConfigService) ChangeVnicPublicIP(ctx context.Context, id uint, region, vnicID string) (string, error) {
if vnicID == "" {
return "", fmt.Errorf("change vnic public ip: vnicId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.ChangeVnicPublicIP(ctx, cred, region, vnicID)
}
// ChangeInstancePublicIP 更换实例主 VNIC 的临时公网 IP,返回新地址。
func (s *OciConfigService) ChangeInstancePublicIP(ctx context.Context, id uint, region, instanceID string) (string, error) {
cred, err := s.credentialsByID(ctx, id)
@@ -46,7 +58,7 @@ func (s *OciConfigService) InstanceVnics(ctx context.Context, id uint, region, i
return s.client.ListInstanceVnics(ctx, cred, region, instanceID)
}
// AttachVnic 为实例附加次要 VNIC。
// AttachVnic 为实例附加次要 VNIC;选了保留 IP 时由后台等网卡就绪后绑定
func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
if instanceID == "" || in.SubnetID == "" {
return oci.Vnic{}, fmt.Errorf("attach vnic: instanceId and subnetId are required")
@@ -55,7 +67,16 @@ func (s *OciConfigService) AttachVnic(ctx context.Context, id uint, region, inst
if err != nil {
return oci.Vnic{}, err
}
return s.client.AttachVnic(ctx, cred, region, instanceID, in)
vnic, err := s.client.AttachVnic(ctx, cred, region, instanceID, in)
if err != nil {
return vnic, err
}
if in.ReservedPublicIPID != "" {
s.goBind(func() {
s.bindReservedIPToVnicWhenReady(cred, region, instanceID, vnic.AttachmentID, in.ReservedPublicIPID)
})
}
return vnic, nil
}
// DetachVnic 分离 VNIC 附加关系(主 VNIC 由 OCI 拒绝)。
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"context"
"fmt"
"strings"
"oci-portal/internal/oci"
)
// maxInvoicePdfBytes 是发票 PDF 中转上限;正常发票远小于此,超限视为异常。
const maxInvoicePdfBytes = 20 << 20
// Invoices 列出租户发票;year>0 只取该自然年(前端按年懒加载),0 为全量。
func (s *OciConfigService) Invoices(ctx context.Context, id uint, year int) ([]oci.Invoice, error) {
if year != 0 && (year < 2000 || year > 2100) {
return nil, fmt.Errorf("invoices: invalid year %d", year)
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListInvoices(ctx, cred, year)
}
// InvoiceLines 列出一张发票的费用明细。
func (s *OciConfigService) InvoiceLines(ctx context.Context, id uint, internalID string) ([]oci.InvoiceLine, error) {
if internalID == "" {
return nil, fmt.Errorf("invoice lines: internal invoice id is empty")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListInvoiceLines(ctx, cred, internalID)
}
// InvoicePdf 拉取一张发票的 PDF 原文。
func (s *OciConfigService) InvoicePdf(ctx context.Context, id uint, internalID string) ([]byte, error) {
if internalID == "" {
return nil, fmt.Errorf("invoice pdf: internal invoice id is empty")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.DownloadInvoicePdf(ctx, cred, internalID, maxInvoicePdfBytes)
}
// PayInvoice 按订阅默认付款方式支付发票;email 接收付款回执,必填。
func (s *OciConfigService) PayInvoice(ctx context.Context, id uint, internalID, email string) error {
if internalID == "" {
return fmt.Errorf("pay invoice: internal invoice id is empty")
}
if !strings.Contains(email, "@") {
return fmt.Errorf("pay invoice: invalid receipt email")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.PayInvoice(ctx, cred, internalID, email)
}
// PaymentMethods 列出订阅上登记的全部付款方式。
func (s *OciConfigService) PaymentMethods(ctx context.Context, id uint) ([]oci.PaymentMethod, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListPaymentMethods(ctx, cred)
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"strings"
"testing"
"oci-portal/internal/oci"
)
// billingStubClient 只桩账单方法,其余经内嵌 fakeClient 兜底。
type billingStubClient struct {
*fakeClient
invoices []oci.Invoice
lines []oci.InvoiceLine
methods []oci.PaymentMethod
pdf []byte
listYear int
paidID string
paidEmail string
}
func (f *billingStubClient) ListInvoices(ctx context.Context, cred oci.Credentials, year int) ([]oci.Invoice, error) {
f.listYear = year
return f.invoices, nil
}
func (f *billingStubClient) ListInvoiceLines(ctx context.Context, cred oci.Credentials, internalInvoiceID string) ([]oci.InvoiceLine, error) {
return f.lines, nil
}
func (f *billingStubClient) DownloadInvoicePdf(ctx context.Context, cred oci.Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
return f.pdf, nil
}
func (f *billingStubClient) PayInvoice(ctx context.Context, cred oci.Credentials, internalInvoiceID, email string) error {
f.paidID, f.paidEmail = internalInvoiceID, email
return nil
}
func (f *billingStubClient) ListPaymentMethods(ctx context.Context, cred oci.Credentials) ([]oci.PaymentMethod, error) {
return f.methods, nil
}
func TestInvoicesAndPaymentMethods(t *testing.T) {
client := &billingStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
invoices: []oci.Invoice{{ID: "inv1", Number: "100001", Status: "OPEN"}},
lines: []oci.InvoiceLine{{Product: "Compute", Total: 12.5}},
methods: []oci.PaymentMethod{{Method: "CREDIT_CARD", LastDigits: "4242"}},
pdf: []byte("%PDF-1.4 fake"),
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
if list, err := svc.Invoices(ctx, cfg.ID, 0); err != nil || len(list) != 1 || list[0].Number != "100001" {
t.Fatalf("Invoices = %v, %v", list, err)
}
if _, err := svc.Invoices(ctx, cfg.ID, 2026); err != nil || client.listYear != 2026 {
t.Fatalf("Invoices(year) 透传 = %d, %v, want 2026", client.listYear, err)
}
if _, err := svc.Invoices(ctx, cfg.ID, 26); err == nil {
t.Fatal("Invoices(26) 应拒绝非法年份")
}
if lines, err := svc.InvoiceLines(ctx, cfg.ID, "6100"); err != nil || len(lines) != 1 {
t.Fatalf("InvoiceLines = %v, %v", lines, err)
}
if data, err := svc.InvoicePdf(ctx, cfg.ID, "6100"); err != nil || len(data) == 0 {
t.Fatalf("InvoicePdf = %d bytes, %v", len(data), err)
}
if ms, err := svc.PaymentMethods(ctx, cfg.ID); err != nil || len(ms) != 1 || ms[0].LastDigits != "4242" {
t.Fatalf("PaymentMethods = %v, %v", ms, err)
}
}
func TestPayInvoiceValidation(t *testing.T) {
client := &billingStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
tests := []struct {
name, internalID, email, wantErr string
}{
{"内部 ID 为空", "", "a@b.c", "internal invoice id is empty"},
{"邮箱非法", "6100", "not-an-email", "invalid receipt email"},
{"合法请求", "6100", "billing@example.com", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := svc.PayInvoice(ctx, cfg.ID, tt.internalID, tt.email)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if client.paidID != tt.internalID || client.paidEmail != tt.email {
t.Fatalf("透传 = (%s, %s), want (%s, %s)", client.paidID, client.paidEmail, tt.internalID, tt.email)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
+7
View File
@@ -37,6 +37,9 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
if count < 1 || count > maxBatchCreate {
return nil, nil, fmt.Errorf("create instances: count must be between 1 and %d", maxBatchCreate)
}
if in.ReservedPublicIPID != "" && count > 1 {
return nil, nil, fmt.Errorf("create instances: reserved public ip only supports single instance")
}
if err := validateCreateInstance(in); err != nil {
return nil, nil, err
}
@@ -59,6 +62,10 @@ func (s *OciConfigService) CreateInstances(ctx context.Context, id uint, in oci.
failures = append(failures, fmt.Sprintf("%s: %s", one.DisplayName, oci.CompactError(err)))
continue
}
if one.ReservedPublicIPID != "" {
region, instanceID, ipID := one.Region, instance.ID, one.ReservedPublicIPID
s.goBind(func() { s.bindReservedIPWhenReady(cred, region, instanceID, ipID) })
}
instances = append(instances, instance)
}
return instances, failures, nil
+349
View File
@@ -0,0 +1,349 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"oci-portal/internal/oci"
)
// ObjectStorageNamespace 查询租户 namespace。
func (s *OciConfigService) ObjectStorageNamespace(ctx context.Context, id uint, region string) (string, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.GetObjectStorageNamespace(ctx, cred, region)
}
// Buckets 列出指定区间的存储桶(compartmentID 空为生效 compartment)。
func (s *OciConfigService) Buckets(ctx context.Context, id uint, region, compartmentID string) ([]oci.Bucket, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListBuckets(ctx, cred, region, compartmentID)
}
// CreateBucket 创建存储桶,名称做基本合法性校验。
func (s *OciConfigService) CreateBucket(ctx context.Context, id uint, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
if err := validateBucketName(in.Name); err != nil {
return oci.Bucket{}, err
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Bucket{}, err
}
return s.client.CreateBucket(ctx, cred, region, in)
}
// validateBucketName 桶名规则:1-256 位字母数字连字符下划线句点。
func validateBucketName(name string) error {
if name == "" || len(name) > 256 {
return fmt.Errorf("create bucket: name length must be 1-256")
}
for _, r := range name {
ok := r == '-' || r == '_' || r == '.' ||
(r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
if !ok {
return fmt.Errorf("create bucket: invalid character %q in name", r)
}
}
return nil
}
// UpdateBucket 更新桶可见性 / 版本控制。
func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, name string, in oci.UpdateBucketInput) (oci.Bucket, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.Bucket{}, err
}
return s.client.UpdateBucket(ctx, cred, region, name, in)
}
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除,
// 返回 queued=true 表示已排队。
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return false, err
}
err = s.client.DeleteBucket(ctx, cred, region, name)
if err == nil {
return false, nil
}
if !errors.Is(err, oci.ErrBucketNotEmpty) {
return false, err
}
go s.purgeAndDeleteBucket(cred, region, name)
return true, nil
}
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
const purgeBucketTimeout = 30 * time.Minute
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
defer cancel()
start := time.Now()
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s purge objects failed: %v", name, err)
return
}
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
}
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
return
}
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
}
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
start, total := time.Now(), 0
for {
versions, next, err := s.client.ListObjectVersions(ctx, cred, region, name, "")
if err != nil {
return err
}
if len(versions) == 0 {
break
}
err = forEachConcurrently(bulkDeleteWorkers, versions, func(v oci.ObjectVersion) error {
return s.client.DeleteObjectVersion(ctx, cred, region, name, v.Name, v.VersionID)
})
if err != nil {
return err
}
total += len(versions)
// 每轮都从首页重新列:删除后游标失效,直到列表为空
if next == "" && len(versions) < 1000 {
break
}
}
if total > 0 {
log.Printf("[bucket-purge] %s purged %d object versions in %s", name, total, time.Since(start).Round(time.Second))
}
return nil
}
// purgeBucketPARs 并发删除桶内全部预签名请求(与手动「全部删除」同一路径);
// 记录成功数/总数与耗时,速率长期顶在 ~10/s 即为 OCI 服务端限流。
func (s *OciConfigService) purgeBucketPARs(ctx context.Context, cred oci.Credentials, region, name string) error {
start := time.Now()
pars, err := s.client.ListPARs(ctx, cred, region, name)
if err != nil {
return err
}
if len(pars) == 0 {
return nil
}
deleted, err := s.deletePARsConcurrently(ctx, cred, region, name, pars)
log.Printf("[bucket-purge] %s purged %d/%d pars in %s", name, deleted, len(pars), time.Since(start).Round(time.Second))
return err
}
// Objects 分页列出对象(delimiter 前缀模式)。
func (s *OciConfigService) Objects(ctx context.Context, id uint, region, bucket, prefix, startWith string, limit int) (oci.ListObjectsResult, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.ListObjectsResult{}, err
}
return s.client.ListObjects(ctx, cred, region, bucket, prefix, startWith, limit)
}
// DeleteObject 删除对象。
func (s *OciConfigService) DeleteObject(ctx context.Context, id uint, region, bucket, object string) error {
if object == "" {
return fmt.Errorf("delete object: object name is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteObject(ctx, cred, region, bucket, object)
}
// RenameObject 重命名对象。
func (s *OciConfigService) RenameObject(ctx context.Context, id uint, region, bucket, src, dst string) error {
if src == "" || dst == "" || src == dst {
return fmt.Errorf("rename object: source and distinct new name are required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.RenameObject(ctx, cred, region, bucket, src, dst)
}
// RestoreObject 取回 Archive 对象。
func (s *OciConfigService) RestoreObject(ctx context.Context, id uint, region, bucket, object string, hours int) error {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.RestoreObject(ctx, cred, region, bucket, object, hours)
}
// ObjectDetail 查询对象元数据。
func (s *OciConfigService) ObjectDetail(ctx context.Context, id uint, region, bucket, object string) (oci.ObjectDetail, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.ObjectDetail{}, err
}
return s.client.HeadObject(ctx, cred, region, bucket, object)
}
// 对象内容中转上限:GET 服务预览(office 文档可达数 MB),PUT 服务文本编辑保存。
const (
maxObjectGetBytes = 20 << 20
maxObjectPutBytes = 5 << 20
)
// ObjectContent 读取对象内容(面板中转,预览/编辑用,不签发 PAR)。
func (s *OciConfigService) ObjectContent(ctx context.Context, id uint, region, bucket, object string) (oci.ObjectContent, error) {
if object == "" {
return oci.ObjectContent{}, fmt.Errorf("get object content: object name is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.ObjectContent{}, err
}
return s.client.GetObject(ctx, cred, region, bucket, object, maxObjectGetBytes)
}
// PutObjectContent 保存对象内容(面板中转;ifMatch 冲突时 OCI 返回 412 透出)。
func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
if object == "" {
return "", fmt.Errorf("put object content: object name is required")
}
if int64(len(data)) > maxObjectPutBytes {
return "", fmt.Errorf("put object content %s: %w", object, oci.ErrObjectTooLarge)
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return "", err
}
return s.client.PutObject(ctx, cred, region, bucket, object, data, contentType, ifMatch)
}
// parAccessTypes 是允许签发的 PAR 访问类型。
var parAccessTypes = map[string]bool{
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true,
}
// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天。
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
if !parAccessTypes[in.AccessType] {
return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType)
}
if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 {
return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720")
}
if strings.TrimSpace(in.Name) == "" {
in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.PAR{}, err
}
return s.client.CreatePAR(ctx, cred, region, bucket, in)
}
// PARsPage 分页列出桶内预签名请求;limit 归一到 1-1000,默认 100。
func (s *OciConfigService) PARsPage(ctx context.Context, id uint, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, "", err
}
return s.client.ListPARsPage(ctx, cred, region, bucket, page, limit)
}
// DeletePAR 撤销预签名请求。
func (s *OciConfigService) DeletePAR(ctx context.Context, id uint, region, bucket, parID string) error {
if parID == "" {
return fmt.Errorf("delete par: parId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeletePAR(ctx, cred, region, bucket, parID)
}
// DeleteAllPARs 删除桶的全部预签名请求,返回成功删除数量;
// 个别失败不中断其余删除,返回首个错误供上层提示。
func (s *OciConfigService) DeleteAllPARs(ctx context.Context, id uint, region, bucket string) (int, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return 0, err
}
pars, err := s.client.ListPARs(ctx, cred, region, bucket)
if err != nil {
return 0, err
}
return s.deletePARsConcurrently(ctx, cred, region, bucket, pars)
}
// bulkDeleteWorkers 批量删除并发度;OCI 无批量接口,串行逐条会到分钟级。
// 16 配合请求级 429/5xx 退避重试(internal/oci)在限流与吞吐间取平衡,不再往上提。
const bulkDeleteWorkers = 16
// forEachConcurrently 以固定并发度对 items 逐个执行 fn;
// 个别失败不中断其余执行,返回首个错误。
func forEachConcurrently[T any](workers int, items []T, fn func(T) error) error {
// 信号量按并发度限流,容量=workers(规范允许的注释说明场景)
sem := make(chan struct{}, workers)
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
for _, it := range items {
wg.Add(1)
sem <- struct{}{}
go func(it T) {
defer wg.Done()
defer func() { <-sem }()
if err := fn(it); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
}(it)
}
wg.Wait()
return firstErr
}
// deletePARsConcurrently 并发删除给定 PAR,返回成功删除数量与首个错误。
func (s *OciConfigService) deletePARsConcurrently(ctx context.Context, cred oci.Credentials, region, bucket string, pars []oci.PAR) (int, error) {
var (
mu sync.Mutex
deleted int
)
err := forEachConcurrently(bulkDeleteWorkers, pars, func(p oci.PAR) error {
if err := s.client.DeletePAR(ctx, cred, region, bucket, p.ID); err != nil {
return fmt.Errorf("delete par %s: %w", p.Name, err)
}
mu.Lock()
deleted++
mu.Unlock()
return nil
})
return deleted, err
}
+405
View File
@@ -0,0 +1,405 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/oci"
)
type osStubClient struct {
*fakeClient
buckets []oci.Bucket
createdPAR oci.CreatePARInput
renamed [2]string
// 对象内容中转 stub 状态
objectData []byte
objectEtag string
putContentType string
putIfMatch string
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
mu sync.Mutex
versions []oci.ObjectVersion
pars []oci.PAR
bucketDeleted bool
}
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
return f.buckets, nil
}
func (f *osStubClient) CreateBucket(ctx context.Context, cred oci.Credentials, region string, in oci.CreateBucketInput) (oci.Bucket, error) {
b := oci.Bucket{Name: in.Name, Namespace: "ns", VersioningOn: in.VersioningOn}
f.buckets = append(f.buckets, b)
return b, nil
}
func (f *osStubClient) RenameObject(ctx context.Context, cred oci.Credentials, region, bucket, src, dst string) error {
f.renamed = [2]string{src, dst}
return nil
}
func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, region, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
// 模拟 OCI 真实行为:残留对象版本或活跃 PAR 都会拒绝删桶
if len(f.versions) > 0 || len(f.pars) > 0 {
return fmt.Errorf("delete bucket %s: %w", name, oci.ErrBucketNotEmpty)
}
f.bucketDeleted = true
return nil
}
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
}
func (f *osStubClient) DeleteObjectVersion(ctx context.Context, cred oci.Credentials, region, bucket, object, versionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.versions[:0]
for _, v := range f.versions {
if !(v.Name == object && v.VersionID == versionID) {
kept = append(kept, v)
}
}
f.versions = kept
return nil
}
func (f *osStubClient) ListPARs(ctx context.Context, cred oci.Credentials, region, bucket string) ([]oci.PAR, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]oci.PAR(nil), f.pars...), nil
}
// ListPARsPage 以数组下标模拟游标:page 为起始下标,next 为下一段起始下标。
func (f *osStubClient) ListPARsPage(ctx context.Context, cred oci.Credentials, region, bucket, page string, limit int) ([]oci.PAR, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
start, _ := strconv.Atoi(page)
if start >= len(f.pars) || limit <= 0 {
return nil, "", nil
}
end := start + limit
next := ""
if end < len(f.pars) {
next = strconv.Itoa(end)
} else {
end = len(f.pars)
}
return append([]oci.PAR(nil), f.pars[start:end]...), next, nil
}
func (f *osStubClient) DeletePAR(ctx context.Context, cred oci.Credentials, region, bucket, parID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.pars[:0]
for _, p := range f.pars {
if p.ID != parID {
kept = append(kept, p)
}
}
f.pars = kept
return nil
}
func (f *osStubClient) purgeState() (deleted bool, versions, pars int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.bucketDeleted, len(f.versions), len(f.pars)
}
func (f *osStubClient) GetObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, maxBytes int64) (oci.ObjectContent, error) {
if int64(len(f.objectData)) > maxBytes {
return oci.ObjectContent{}, fmt.Errorf("get object %s: %w", object, oci.ErrObjectTooLarge)
}
return oci.ObjectContent{Data: f.objectData, ContentType: "text/plain", Etag: f.objectEtag}, nil
}
func (f *osStubClient) PutObject(ctx context.Context, cred oci.Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
f.objectData = data
f.putContentType = contentType
f.putIfMatch = ifMatch
return "etag-new", nil
}
func (f *osStubClient) CreatePAR(ctx context.Context, cred oci.Credentials, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
f.createdPAR = in
return oci.PAR{ID: "par-1", Name: in.Name, AccessType: in.AccessType, FullURL: "https://os.example/p/x/n/ns/b/o"}, nil
}
func TestObjectStorageValidation(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
tests := []struct {
name string
run func() error
wantErr string
}{
{name: "桶名非法字符", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "有中文"})
return err
}, wantErr: "invalid character"},
{name: "桶名空", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: ""})
return err
}, wantErr: "length"},
{name: "合法桶名", run: func() error {
_, err := svc.CreateBucket(ctx, cfg.ID, "r", oci.CreateBucketInput{Name: "my-bucket_01"})
return err
}},
{name: "重命名缺参", run: func() error {
return svc.RenameObject(ctx, cfg.ID, "r", "b", "a.txt", "a.txt")
}, wantErr: "distinct"},
{name: "PAR 类型不支持", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
return err
}, wantErr: "unsupported access type"},
{name: "PAR 时长越界", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
return err
}, wantErr: "1-720"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.run()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestDeleteBucketEmptySync(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || queued {
t.Fatalf("DeleteBucket 空桶 = queued %v, %v, want 同步删除", queued, err)
}
if deleted, _, _ := client.purgeState(); !deleted {
t.Error("空桶应同步删除")
}
}
func TestDeleteBucketPurgeQueued(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{
{Name: "a.txt", VersionID: "v1"},
{Name: "a.txt", VersionID: ""},
{Name: "dir/b.bin", VersionID: "v9"},
},
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("DeleteBucket 非空桶 = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if deleted, nv, np := client.purgeState(); deleted && nv == 0 && np == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
deleted, nv, np := client.purgeState()
t.Fatalf("purge 未完成: deleted=%v versions=%d pars=%d", deleted, nv, np)
}
// TestDeleteBucketPAROnlyQueued 覆盖真实环境踩到的场景:桶内无对象,
// 仅剩活跃 PAR 时 OCI 也拒绝删桶,应同样走后台清空后重删。
func TestDeleteBucketPAROnlyQueued(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
pars: []oci.PAR{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("DeleteBucket 仅 PAR 桶 = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if deleted, _, np := client.purgeState(); deleted && np == 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
deleted, _, np := client.purgeState()
t.Fatalf("purge 未完成: deleted=%v pars=%d", deleted, np)
}
func TestDeleteAllPARsConcurrent(t *testing.T) {
pars := make([]oci.PAR, 40)
for i := range pars {
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i), Name: fmt.Sprintf("par-%d", i)}
}
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
n, err := svc.DeleteAllPARs(context.Background(), cfg.ID, "r", "b")
if err != nil || n != 40 {
t.Fatalf("DeleteAllPARs = %d, %v, want 40 deleted", n, err)
}
if _, _, np := client.purgeState(); np != 0 {
t.Errorf("remaining pars = %d, want 0", np)
}
}
func TestPARsPage(t *testing.T) {
pars := make([]oci.PAR, 5)
for i := range pars {
pars[i] = oci.PAR{ID: fmt.Sprintf("p-%d", i)}
}
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, pars: pars}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
tests := []struct {
name string
page string
limit int
wantIDs int
wantNext string
}{
{name: "首页带游标", page: "", limit: 2, wantIDs: 2, wantNext: "2"},
{name: "中间页", page: "2", limit: 2, wantIDs: 2, wantNext: "4"},
{name: "末页无游标", page: "4", limit: 2, wantIDs: 1, wantNext: ""},
{name: "limit 0 归一为 100", page: "", limit: 0, wantIDs: 5, wantNext: ""},
{name: "limit 越界归一为 100", page: "", limit: 5000, wantIDs: 5, wantNext: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items, next, err := svc.PARsPage(context.Background(), cfg.ID, "r", "b", tt.page, tt.limit)
if err != nil {
t.Fatalf("PARsPage: %v", err)
}
if len(items) != tt.wantIDs || next != tt.wantNext {
t.Fatalf("PARsPage = %d 条, next=%q; want %d 条, next=%q", len(items), next, tt.wantIDs, tt.wantNext)
}
})
}
}
func TestForEachConcurrently(t *testing.T) {
items := make([]int, 20)
for i := range items {
items[i] = i
}
tests := []struct {
name string
items []int
failOn int // 值为 -1 表示不注入失败
wantErr bool
wantDone int
}{
{name: "全部成功", items: items, failOn: -1, wantDone: 20},
{name: "个别失败不中断其余", items: items, failOn: 3, wantErr: true, wantDone: 19},
{name: "空列表", items: nil, failOn: -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
mu sync.Mutex
done int
)
err := forEachConcurrently(4, tt.items, func(i int) error {
if i == tt.failOn {
return errors.New("boom")
}
mu.Lock()
done++
mu.Unlock()
return nil
})
if (err != nil) != tt.wantErr || done != tt.wantDone {
t.Fatalf("forEachConcurrently err=%v done=%d, want err=%v done=%d", err, done, tt.wantErr, tt.wantDone)
}
})
}
}
func TestObjectContentRoundTrip(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
objectData: []byte("hello"), objectEtag: "e1",
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
got, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", "a.txt")
if err != nil || string(got.Data) != "hello" || got.Etag != "e1" {
t.Fatalf("ObjectContent = %q etag=%q, %v; want hello/e1", got.Data, got.Etag, err)
}
etag, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a.txt", []byte("world"), "text/plain", "e1")
if err != nil || etag != "etag-new" {
t.Fatalf("PutObjectContent = %q, %v; want etag-new", etag, err)
}
if client.putIfMatch != "e1" || client.putContentType != "text/plain" {
t.Errorf("ifMatch=%q contentType=%q, want e1/text-plain 透传", client.putIfMatch, client.putContentType)
}
}
func TestObjectContentLimits(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
if _, err := svc.ObjectContent(ctx, cfg.ID, "r", "b", ""); err == nil {
t.Error("空对象名应报错")
}
big := make([]byte, maxObjectPutBytes+1)
if _, err := svc.PutObjectContent(ctx, cfg.ID, "r", "b", "a", big, "", ""); !errors.Is(err, oci.ErrObjectTooLarge) {
t.Errorf("超限 PUT err = %v, want ErrObjectTooLarge", err)
}
}
func TestCreatePARDefaultsName(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
par, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
oci.CreatePARInput{ObjectName: "db/app.sql.gz", AccessType: "ObjectRead", ExpiresHours: 24})
if err != nil {
t.Fatalf("CreatePAR: %v", err)
}
if client.createdPAR.Name != "par-db-app.sql.gz" {
t.Errorf("default name = %q, want par-db-app.sql.gz", client.createdPAR.Name)
}
if par.FullURL == "" {
t.Errorf("FullURL empty, want populated")
}
}
+8 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"sync"
"time"
"gorm.io/gorm"
@@ -23,11 +24,17 @@ type OciConfigService struct {
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache
// bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。
bindWG sync.WaitGroup
bindStop chan struct{}
}
// NewOciConfigService 组装依赖。
func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) *OciConfigService {
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
return &OciConfigService{
db: db, cipher: cipher, client: client,
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
}
}
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
+137
View File
@@ -0,0 +1,137 @@
package service
import (
"context"
"fmt"
"log"
"time"
"oci-portal/internal/oci"
)
// ReservedIPs 列出指定区间的保留公网 IP(compartmentID 空为生效 compartment)。
func (s *OciConfigService) ReservedIPs(ctx context.Context, id uint, region, compartmentID string) ([]oci.ReservedIP, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return nil, err
}
return s.client.ListReservedIPs(ctx, cred, region, compartmentID)
}
// CreateReservedIP 在指定区间创建保留公网 IP。
func (s *OciConfigService) CreateReservedIP(ctx context.Context, id uint, region, compartmentID, displayName string) (oci.ReservedIP, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return oci.ReservedIP{}, err
}
return s.client.CreateReservedIP(ctx, cred, region, compartmentID, displayName)
}
// AssignReservedIP 绑定保留 IP:vnicID 非空绑到该网卡主私有 IP,
// 否则绑实例主网卡(instanceID 为空表示解绑)。
func (s *OciConfigService) AssignReservedIP(ctx context.Context, id uint, region, publicIPID, instanceID, vnicID string) error {
if publicIPID == "" {
return fmt.Errorf("assign reserved ip: publicIpId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
if vnicID != "" {
return s.client.AssignReservedIPToVnic(ctx, cred, region, publicIPID, vnicID)
}
return s.client.AssignReservedIP(ctx, cred, region, publicIPID, instanceID)
}
// DeleteReservedIP 删除保留公网 IP。
func (s *OciConfigService) DeleteReservedIP(ctx context.Context, id uint, region, publicIPID string) error {
if publicIPID == "" {
return fmt.Errorf("delete reserved ip: publicIpId is required")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
return err
}
return s.client.DeleteReservedIP(ctx, cred, region, publicIPID)
}
// 保留 IP 后台绑定的轮询节奏与超时上限。
const (
reservedBindInterval = 10 * time.Second
reservedBindTimeout = 8 * time.Minute
)
// goBind 启动保留 IP 后台绑定 goroutine 并纳入 WaitGroup,供 Stop 等待收尾。
func (s *OciConfigService) goBind(fn func()) {
s.bindWG.Add(1)
go func() {
defer s.bindWG.Done()
fn()
}()
}
// Stop 通知在途的保留 IP 绑定 goroutine 提前退出并等待全部收尾;仅关服时调用一次。
func (s *OciConfigService) Stop() {
close(s.bindStop)
s.bindWG.Wait()
}
// bindReservedIPToVnicWhenReady 等次要 VNIC 就绪后绑定保留 IP;附加网卡的后台编排,
// 失败只记日志(附加请求本身已成功返回)。
func (s *OciConfigService) bindReservedIPToVnicWhenReady(cred oci.Credentials, region, instanceID, attachmentID, publicIPID string) {
ctx, cancel := context.WithTimeout(context.Background(), reservedBindTimeout)
defer cancel()
for {
vnics, err := s.client.ListInstanceVnics(ctx, cred, region, instanceID)
if err == nil {
if vnicID := readyVnicID(vnics, attachmentID); vnicID != "" {
if err := s.client.AssignReservedIPToVnic(ctx, cred, region, publicIPID, vnicID); err != nil {
log.Printf("[reserved-ip] bind %s to vnic %s failed: %v", publicIPID, vnicID, err)
}
return
}
}
select {
case <-ctx.Done():
log.Printf("[reserved-ip] bind %s timed out waiting for attachment %s", publicIPID, attachmentID)
return
case <-s.bindStop:
return
case <-time.After(reservedBindInterval):
}
}
}
// readyVnicID 从网卡列表找出指定附加关系中已就绪的 VNIC。
func readyVnicID(vnics []oci.Vnic, attachmentID string) string {
for _, v := range vnics {
if v.AttachmentID == attachmentID && v.VnicID != "" && v.LifecycleState == "ATTACHED" {
return v.VnicID
}
}
return ""
}
// bindReservedIPWhenReady 等实例 RUNNING 后绑定保留 IP;创建实例的后台编排,
// 失败只记日志(创建请求本身已成功返回)。
func (s *OciConfigService) bindReservedIPWhenReady(cred oci.Credentials, region, instanceID, publicIPID string) {
ctx, cancel := context.WithTimeout(context.Background(), reservedBindTimeout)
defer cancel()
for {
inst, err := s.client.GetInstance(ctx, cred, region, instanceID)
if err == nil && inst.LifecycleState == "RUNNING" {
if err := s.client.AssignReservedIP(ctx, cred, region, publicIPID, instanceID); err != nil {
log.Printf("[reserved-ip] bind %s to instance %s failed: %v", publicIPID, instanceID, err)
}
return
}
select {
case <-ctx.Done():
log.Printf("[reserved-ip] bind %s timed out waiting for instance %s", publicIPID, instanceID)
return
case <-s.bindStop:
return
case <-time.After(reservedBindInterval):
}
}
}
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/oci"
)
type reservedIPStubClient struct {
*fakeClient
mu sync.Mutex
ips []oci.ReservedIP
assigned map[string]string // publicIpId -> instanceId
vnicBound map[string]string // publicIpId -> vnicId
deleted []string
instState string // GetInstance 返回的状态
launched oci.CreateInstanceInput
}
func (f *reservedIPStubClient) ListReservedIPs(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.ReservedIP, error) {
return f.ips, nil
}
func (f *reservedIPStubClient) CreateReservedIP(ctx context.Context, cred oci.Credentials, region, compartmentID, displayName string) (oci.ReservedIP, error) {
ip := oci.ReservedIP{ID: "ocid1.publicip..new", DisplayName: displayName, IPAddress: "155.248.0.10", LifecycleState: "AVAILABLE"}
f.ips = append(f.ips, ip)
return ip, nil
}
func (f *reservedIPStubClient) AssignReservedIP(ctx context.Context, cred oci.Credentials, region, publicIPID, instanceID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.assigned == nil {
f.assigned = map[string]string{}
}
f.assigned[publicIPID] = instanceID
return nil
}
func (f *reservedIPStubClient) AssignReservedIPToVnic(ctx context.Context, cred oci.Credentials, region, publicIPID, vnicID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.vnicBound == nil {
f.vnicBound = map[string]string{}
}
f.vnicBound[publicIPID] = vnicID
return nil
}
func (f *reservedIPStubClient) DeleteReservedIP(ctx context.Context, cred oci.Credentials, region, publicIPID string) error {
f.deleted = append(f.deleted, publicIPID)
return nil
}
func (f *reservedIPStubClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) {
f.launched = in
return oci.Instance{ID: "ocid1.instance..new", LifecycleState: "PROVISIONING"}, nil
}
func (f *reservedIPStubClient) GetInstance(ctx context.Context, cred oci.Credentials, region, instanceID string) (oci.Instance, error) {
return oci.Instance{ID: instanceID, LifecycleState: f.instState}, nil
}
func (f *reservedIPStubClient) assignedTo(publicIPID string) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.assigned[publicIPID]
}
func TestReservedIPCrud(t *testing.T) {
client := &reservedIPStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
if _, err := svc.CreateReservedIP(ctx, cfg.ID, "ap-tokyo-1", "", "ip-1"); err != nil {
t.Fatalf("CreateReservedIP: %v", err)
}
got, err := svc.ReservedIPs(ctx, cfg.ID, "ap-tokyo-1", "")
if err != nil || len(got) != 1 || got[0].DisplayName != "ip-1" {
t.Fatalf("ReservedIPs = %+v, %v", got, err)
}
// publicIpId 缺失被拒
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "", "inst", ""); err == nil ||
!strings.Contains(err.Error(), "publicIpId") {
t.Errorf("AssignReservedIP 空 id err = %v, want publicIpId required", err)
}
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new", "ocid1.instance..a", ""); err != nil {
t.Fatalf("AssignReservedIP: %v", err)
}
if got := client.assignedTo("ocid1.publicip..new"); got != "ocid1.instance..a" {
t.Errorf("assigned = %q, want instance a", got)
}
// vnicId 非空时路由到按网卡绑定
if err := svc.AssignReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new", "", "ocid1.vnic..v1"); err != nil {
t.Fatalf("AssignReservedIP vnic: %v", err)
}
if got := client.vnicBound["ocid1.publicip..new"]; got != "ocid1.vnic..v1" {
t.Errorf("vnicBound = %q, want vnic v1", got)
}
if err := svc.DeleteReservedIP(ctx, cfg.ID, "ap-tokyo-1", "ocid1.publicip..new"); err != nil {
t.Fatalf("DeleteReservedIP: %v", err)
}
if len(client.deleted) != 1 {
t.Errorf("deleted = %v, want 1 entry", client.deleted)
}
}
func TestCreateInstanceWithReservedIP(t *testing.T) {
client := &reservedIPStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
instState: "RUNNING",
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
ctx := context.Background()
in := oci.CreateInstanceInput{
AvailabilityDomain: "AD-1",
DisplayName: "vm1",
Shape: "VM.Standard.A1.Flex",
ImageID: "ocid1.image..a",
SubnetID: "ocid1.subnet..s",
ReservedPublicIPID: "ocid1.publicip..r",
}
// 批量创建 + 保留 IP 被拒
if _, _, err := svc.CreateInstances(ctx, cfg.ID, in, 2); err == nil ||
!strings.Contains(err.Error(), "single instance") {
t.Fatalf("批量+保留IP err = %v, want single instance", err)
}
instances, failures, err := svc.CreateInstances(ctx, cfg.ID, in, 1)
if err != nil || len(failures) != 0 || len(instances) != 1 {
t.Fatalf("CreateInstances = %+v, %v, %v", instances, failures, err)
}
// 后台 goroutine 轮询到 RUNNING 后完成绑定
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if client.assignedTo("ocid1.publicip..r") == "ocid1.instance..new" {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("assigned = %q, want bind to new instance", client.assignedTo("ocid1.publicip..r"))
}
+17 -3
View File
@@ -57,7 +57,8 @@ func (s *OciConfigService) saveCompartmentCache(ctx context.Context, cfgID uint,
rows := make([]model.CompartmentCache, 0, len(comps))
for _, c := range comps {
rows = append(rows, model.CompartmentCache{
OciConfigID: cfgID, OCID: c.ID, Name: c.Name, State: c.LifecycleState,
OciConfigID: cfgID, OCID: c.ID, Name: c.Name,
ParentOCID: c.ParentID, State: c.LifecycleState,
})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -191,16 +192,29 @@ func (s *OciConfigService) CachedCompartments(ctx context.Context, id uint) ([]o
Order("name").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("load compartment cache: %w", err)
}
if len(rows) == 0 {
if len(rows) == 0 || legacyCompartmentCache(rows) {
return s.refreshCompartmentCache(ctx, cfg)
}
comps := make([]oci.Compartment, 0, len(rows))
for _, r := range rows {
comps = append(comps, oci.Compartment{ID: r.OCID, Name: r.Name, LifecycleState: r.State})
comps = append(comps, oci.Compartment{
ID: r.OCID, Name: r.Name, ParentID: r.ParentOCID, LifecycleState: r.State,
})
}
return comps, nil
}
// legacyCompartmentCache 识别未存 parentId 的旧格式缓存:每个 compartment
// 必有父(至少是租户根),整组 parent 全空即旧数据,需实时刷新一次自愈。
func legacyCompartmentCache(rows []model.CompartmentCache) bool {
for _, r := range rows {
if r.ParentOCID != "" {
return false
}
}
return len(rows) > 0
}
// refreshCompartmentCache 实时拉取 compartment 并覆盖缓存。
func (s *OciConfigService) refreshCompartmentCache(ctx context.Context, cfg *model.OciConfig) ([]oci.Compartment, error) {
cred, err := s.credentialsOf(cfg)
+30 -1
View File
@@ -16,7 +16,7 @@ func multiScopeClient() *fakeClient {
{Key: "AMS", Name: "eu-amsterdam-1", Status: "READY"},
},
compartments: []oci.Compartment{
{ID: "ocid1.compartment..a", Name: "instances", LifecycleState: "ACTIVE"},
{ID: "ocid1.compartment..a", Name: "instances", ParentID: "ocid1.tenancy..root", LifecycleState: "ACTIVE"},
},
}
}
@@ -154,6 +154,9 @@ func TestCachedCompartments(t *testing.T) {
if len(comps) != 1 || comps[0].ID != "ocid1.compartment..a" {
t.Errorf("comps = %+v, want 缓存中的 1 项", comps)
}
if comps[0].ParentID != "ocid1.tenancy..root" {
t.Errorf("ParentID = %q, want 缓存透传 parentId", comps[0].ParentID)
}
in := trialImportInput()
in.Alias = "未开启"
@@ -170,6 +173,32 @@ func TestCachedCompartments(t *testing.T) {
}
}
// TestCachedCompartmentsHealsLegacyRows 旧版缓存(未存 parentId)应实时刷新自愈并回写。
func TestCachedCompartmentsHealsLegacyRows(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
cfg := importMultiConfig(t, svc)
// 模拟旧版残留:抹掉缓存行的 parentId
if err := svc.db.Model(&model.CompartmentCache{}).Where("oci_config_id = ?", cfg.ID).
Update("ParentOCID", "").Error; err != nil {
t.Fatalf("清空 parentId: %v", err)
}
comps, err := svc.CachedCompartments(context.Background(), cfg.ID)
if err != nil {
t.Fatalf("CachedCompartments: %v", err)
}
if len(comps) != 1 || comps[0].ParentID != "ocid1.tenancy..root" {
t.Errorf("comps = %+v, want 自愈刷新后带 parentId", comps)
}
var rows []model.CompartmentCache
svc.db.Where("oci_config_id = ?", cfg.ID).Find(&rows)
if len(rows) != 1 || rows[0].ParentOCID != "ocid1.tenancy..root" {
t.Errorf("cache rows = %+v, want 缓存已回写 parentId", rows)
}
}
func TestRegionSubscriptionsReconcilesCache(t *testing.T) {
client := multiScopeClient()
svc := newTestService(t, client)
+8 -2
View File
@@ -73,8 +73,11 @@ func applyCostDefaults(q *oci.CostQuery) {
func alignDown(t time.Time, granularity string) time.Time {
t = t.UTC()
if granularity == "MONTHLY" {
switch granularity {
case "MONTHLY":
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
case "HOURLY":
return t.Truncate(time.Hour)
}
return t.Truncate(24 * time.Hour)
}
@@ -84,8 +87,11 @@ func alignUp(t time.Time, granularity string) time.Time {
if down.Equal(t.UTC()) {
return down
}
if granularity == "MONTHLY" {
switch granularity {
case "MONTHLY":
return down.AddDate(0, 1, 0)
case "HOURLY":
return down.Add(time.Hour)
}
return down.AddDate(0, 0, 1)
}
+44
View File
@@ -3,7 +3,9 @@ package service
import (
"context"
"strings"
"sync"
"testing"
"time"
"oci-portal/internal/oci"
)
@@ -12,17 +14,35 @@ import (
type vnicStubClient struct {
*fakeClient
mu sync.Mutex
vnics []oci.Vnic
attached oci.AttachVnicInput
attachInst string
detachedID string
ipv6Vnic string
boundVnic string // AssignReservedIPToVnic 记录
boundIP string
}
func (f *vnicStubClient) ListInstanceVnics(ctx context.Context, cred oci.Credentials, region, instanceID string) ([]oci.Vnic, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.vnics, nil
}
func (f *vnicStubClient) AssignReservedIPToVnic(ctx context.Context, cred oci.Credentials, region, publicIPID, vnicID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.boundIP, f.boundVnic = publicIPID, vnicID
return nil
}
func (f *vnicStubClient) bound() (string, string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.boundIP, f.boundVnic
}
func (f *vnicStubClient) AttachVnic(ctx context.Context, cred oci.Credentials, region, instanceID string, in oci.AttachVnicInput) (oci.Vnic, error) {
f.attachInst, f.attached = instanceID, in
return oci.Vnic{AttachmentID: "att-1", SubnetID: in.SubnetID, LifecycleState: "ATTACHING"}, nil
@@ -73,3 +93,27 @@ func TestVnicManagement(t *testing.T) {
t.Fatalf("AddVnicIpv6 = %q, %v (vnic %q)", addr, err, client.ipv6Vnic)
}
}
func TestAttachVnicWithReservedIP(t *testing.T) {
client := &vnicStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
// 附加返回 att-1;列表里它已就绪,后台编排应绑定到对应 VNIC
vnics: []oci.Vnic{{AttachmentID: "att-1", VnicID: "ocid1.vnic.oc1..new", LifecycleState: "ATTACHED"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
in := oci.AttachVnicInput{SubnetID: "ocid1.subnet.oc1..s", ReservedPublicIPID: "ocid1.publicip..r"}
if _, err := svc.AttachVnic(context.Background(), cfg.ID, "ap-tokyo-1", "ocid1.instance.oc1..x", in); err != nil {
t.Fatalf("AttachVnic: %v", err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ip, vnic := client.bound(); ip == "ocid1.publicip..r" && vnic == "ocid1.vnic.oc1..new" {
return
}
time.Sleep(20 * time.Millisecond)
}
ip, vnic := client.bound()
t.Fatalf("bound = (%q, %q), want reserved ip bound to new vnic", ip, vnic)
}