@@ -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 拒绝)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 注入租户删除提交后的任务内存状态同步依赖。
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user