@@ -0,0 +1,320 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/ospgateway"
|
||||
)
|
||||
|
||||
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
|
||||
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
|
||||
|
||||
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
InternalID string `json:"internalId"`
|
||||
Number string `json:"number"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
IsPaid bool `json:"isPaid"`
|
||||
IsPayable bool `json:"isPayable"`
|
||||
IsPaymentFailed bool `json:"isPaymentFailed"`
|
||||
Amount float64 `json:"amount"`
|
||||
AmountDue float64 `json:"amountDue"`
|
||||
Currency string `json:"currency"`
|
||||
TimeInvoice *time.Time `json:"timeInvoice"`
|
||||
TimeDue *time.Time `json:"timeDue"`
|
||||
}
|
||||
|
||||
// InvoiceLine 是发票内一行费用明细。
|
||||
type InvoiceLine struct {
|
||||
Product string `json:"product"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
Total float64 `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
TimeStart *time.Time `json:"timeStart"`
|
||||
TimeEnd *time.Time `json:"timeEnd"`
|
||||
}
|
||||
|
||||
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
|
||||
type PaymentMethod struct {
|
||||
Method string `json:"method"` // CREDIT_CARD / PAYPAL
|
||||
CardType string `json:"cardType,omitempty"`
|
||||
LastDigits string `json:"lastDigits,omitempty"`
|
||||
NameOnCard string `json:"nameOnCard,omitempty"`
|
||||
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PayerName string `json:"payerName,omitempty"`
|
||||
BillingAgreement string `json:"billingAgreement,omitempty"`
|
||||
}
|
||||
|
||||
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
|
||||
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
|
||||
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
|
||||
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
info, err := c.ValidateKey(ctx, cred)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve osp home region: %w", err)
|
||||
}
|
||||
r, ok := RegionByKey(info.HomeRegionKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
|
||||
}
|
||||
if cred.TenancyOCID != "" {
|
||||
c.homeRegions.Store(cred.TenancyOCID, r.Name)
|
||||
}
|
||||
return r.Name, nil
|
||||
}
|
||||
|
||||
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
|
||||
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
|
||||
region, err := c.ospHomeRegion(ctx, cred)
|
||||
if err != nil {
|
||||
return ospgateway.InvoiceServiceClient{}, "", err
|
||||
}
|
||||
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
ic.SetRegion(region)
|
||||
return ic, region, nil
|
||||
}
|
||||
|
||||
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
|
||||
// (按 timeInvoice 窗口过滤),为 0 拉全量。
|
||||
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
|
||||
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := ospgateway.ListInvoicesRequest{
|
||||
OspHomeRegion: ®ion,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Limit: common.Int(ospPageLimit),
|
||||
}
|
||||
if year > 0 {
|
||||
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
var out []Invoice
|
||||
for {
|
||||
resp, err := ic.ListInvoices(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list invoices: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
out = append(out, toInvoice(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(out), nil
|
||||
}
|
||||
req.Page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
|
||||
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
|
||||
const ospPageLimit = 100
|
||||
|
||||
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
|
||||
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
|
||||
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []InvoiceLine
|
||||
var page *string
|
||||
for {
|
||||
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
|
||||
OspHomeRegion: ®ion,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
InternalInvoiceId: &internalInvoiceID,
|
||||
Page: page,
|
||||
Limit: common.Int(ospPageLimit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list invoice lines: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
out = append(out, toInvoiceLine(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(out), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
|
||||
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
|
||||
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
|
||||
OspHomeRegion: ®ion,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
InternalInvoiceId: &internalInvoiceID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download invoice pdf: %w", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download invoice pdf: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
|
||||
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
|
||||
ic, region, err := c.ospInvoiceClient(ctx, cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
|
||||
OspHomeRegion: ®ion,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
InternalInvoiceId: &internalInvoiceID,
|
||||
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pay invoice: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
|
||||
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
|
||||
region, err := c.ospHomeRegion(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new osp subscription client: %w", err)
|
||||
}
|
||||
applyProxy(&sc.BaseClient, cred)
|
||||
sc.SetRegion(region)
|
||||
var out []PaymentMethod
|
||||
var page *string
|
||||
for {
|
||||
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
|
||||
OspHomeRegion: ®ion,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list payment methods: %w", err)
|
||||
}
|
||||
for _, sub := range resp.Items {
|
||||
out = appendPaymentMethods(out, sub.PaymentOptions)
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(out), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
|
||||
inv := Invoice{
|
||||
ID: deref(v.InvoiceId),
|
||||
InternalID: deref(v.InternalInvoiceId),
|
||||
Number: deref(v.InvoiceNumber),
|
||||
Type: string(v.InvoiceType),
|
||||
Status: string(v.InvoiceStatus),
|
||||
Amount: money(v.InvoiceAmount),
|
||||
AmountDue: money(v.InvoiceAmountDue),
|
||||
TimeInvoice: sdkTime(v.TimeInvoice),
|
||||
TimeDue: sdkTime(v.TimeInvoiceDue),
|
||||
}
|
||||
if v.IsPaid != nil {
|
||||
inv.IsPaid = *v.IsPaid
|
||||
}
|
||||
if v.IsPayable != nil {
|
||||
inv.IsPayable = *v.IsPayable
|
||||
}
|
||||
if v.IsPaymentFailed != nil {
|
||||
inv.IsPaymentFailed = *v.IsPaymentFailed
|
||||
}
|
||||
if v.Currency != nil {
|
||||
inv.Currency = deref(v.Currency.CurrencyCode)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
|
||||
line := InvoiceLine{
|
||||
Product: deref(v.Product),
|
||||
OrderNo: deref(v.OrderNo),
|
||||
Quantity: money(v.Quantity),
|
||||
UnitPrice: money(v.NetUnitPrice),
|
||||
Total: money(v.TotalPrice),
|
||||
TimeStart: sdkTime(v.TimeStart),
|
||||
TimeEnd: sdkTime(v.TimeEnd),
|
||||
}
|
||||
if v.Currency != nil {
|
||||
line.Currency = deref(v.Currency.CurrencyCode)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
|
||||
for _, opt := range opts {
|
||||
switch v := opt.(type) {
|
||||
case ospgateway.CreditCardPaymentOption:
|
||||
out = append(out, creditCardMethod(v))
|
||||
case *ospgateway.CreditCardPaymentOption:
|
||||
out = append(out, creditCardMethod(*v))
|
||||
case ospgateway.PaypalPaymentOption:
|
||||
out = append(out, paypalMethod(v))
|
||||
case *ospgateway.PaypalPaymentOption:
|
||||
out = append(out, paypalMethod(*v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
|
||||
return PaymentMethod{
|
||||
Method: "CREDIT_CARD",
|
||||
CardType: string(v.CreditCardType),
|
||||
LastDigits: deref(v.LastDigits),
|
||||
NameOnCard: deref(v.NameOnCard),
|
||||
TimeExpiration: sdkTime(v.TimeExpiration),
|
||||
}
|
||||
}
|
||||
|
||||
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
|
||||
return PaymentMethod{
|
||||
Method: "PAYPAL",
|
||||
Email: deref(v.EmailAddress),
|
||||
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
|
||||
BillingAgreement: deref(v.ExtBillingAgreementId),
|
||||
}
|
||||
}
|
||||
|
||||
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
|
||||
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
|
||||
func money(p *float32) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return math.Round(float64(*p)*10000) / 10000
|
||||
}
|
||||
+39
-2
@@ -50,6 +50,13 @@ type Client interface {
|
||||
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
|
||||
// GetSubscription 查询单个订阅的完整信息。
|
||||
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
|
||||
// 账单(OSP Gateway,仅主区域):发票列表/明细/PDF/付款与订阅付款方式。
|
||||
// ListInvoices 的 year>0 时按自然年过滤(timeInvoice 窗口),0 为全量。
|
||||
ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error)
|
||||
ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error)
|
||||
DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error)
|
||||
PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error
|
||||
ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error)
|
||||
// ListShapes 列出租户在目标区域可用的实例规格(带缓存)。
|
||||
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
|
||||
// ListImages 列出可用的实例镜像。
|
||||
@@ -91,6 +98,34 @@ type Client interface {
|
||||
DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error
|
||||
// 实例 IP:更换主 VNIC 临时公网 IP、添加与取消分配 IPv6 地址。
|
||||
ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error)
|
||||
ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error)
|
||||
// 对象存储:namespace、桶 CRUD、对象列表/删除/重命名/取回/元数据、预签名请求(PAR)。
|
||||
GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error)
|
||||
ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error)
|
||||
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
|
||||
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
|
||||
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
|
||||
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
|
||||
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
|
||||
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
|
||||
DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error
|
||||
RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error
|
||||
RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error
|
||||
HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error)
|
||||
// 小文件中转:预览/编辑经面板直读直写,不签发 PAR;PutObject ifMatch 做并发保护。
|
||||
GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error)
|
||||
PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error)
|
||||
CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error)
|
||||
ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error)
|
||||
// ListPARsPage 单页列出 PAR:page 为上页游标(空取首页),返回下一页游标(空为末页)。
|
||||
ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error)
|
||||
DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error
|
||||
// 保留公网 IP:列出 / 创建 / 绑定(instanceID 空为解绑) / 删除。
|
||||
ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error)
|
||||
CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error)
|
||||
AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error
|
||||
AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error
|
||||
DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error
|
||||
AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error)
|
||||
DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error
|
||||
// VNIC 管理:列出实例网卡、附加次要网卡、分离(主网卡由 OCI 拒绝)、按网卡加 IPv6。
|
||||
@@ -184,8 +219,10 @@ type Client interface {
|
||||
|
||||
// RealClient 基于官方 SDK 实现 Client。
|
||||
type RealClient struct {
|
||||
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||
namespaces sync.Map // tenancy → 对象存储 namespace,租户常量不过期
|
||||
homeRegions sync.Map // tenancy → 主区域公共名,租户常量不过期(OSP 账单用)
|
||||
}
|
||||
|
||||
// NewClient 返回生产使用的真实客户端。
|
||||
|
||||
+38
-13
@@ -14,15 +14,16 @@ import (
|
||||
type CostQuery struct {
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Granularity string // DAILY / MONTHLY
|
||||
Granularity string // HOURLY / DAILY / MONTHLY
|
||||
QueryType string // COST / USAGE
|
||||
GroupBy string // service / skuName / region 等维度
|
||||
GroupBy string // 单维度(service / skuName / region 等),或逗号分隔的复合维度(如 "service,skuName",主+子)
|
||||
}
|
||||
|
||||
// CostItem 是一个时间桶内某分组维度的成本或用量。
|
||||
type CostItem struct {
|
||||
TimeStart *time.Time `json:"timeStart"`
|
||||
GroupValue string `json:"groupValue"`
|
||||
SubValue string `json:"subValue,omitempty"` // 复合分组的第二维取值(如 service 下的 skuName)
|
||||
ComputedAmount float32 `json:"computedAmount"`
|
||||
ComputedQuantity float32 `json:"computedQuantity"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -75,11 +76,37 @@ func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummari
|
||||
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
|
||||
Granularity: granularity,
|
||||
QueryType: queryType,
|
||||
GroupBy: []string{q.GroupBy},
|
||||
GroupBy: splitGroupBy(q.GroupBy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段。
|
||||
// splitGroupBy 把 "service,skuName" 复合维度拆成 Usage API 的 groupBy 列表。
|
||||
func splitGroupBy(groupBy string) []string {
|
||||
parts := strings.Split(groupBy, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dimValue 按维度名取响应条目上的分组值。
|
||||
func dimValue(item usageapi.UsageSummary, dim string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(dim)) {
|
||||
case "skuname":
|
||||
return deref(item.SkuName)
|
||||
case "region":
|
||||
return deref(item.Region)
|
||||
case "compartmentname":
|
||||
return deref(item.CompartmentName)
|
||||
default:
|
||||
return deref(item.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// toCostItem 把响应条目转为本地 DTO;首维进 GroupValue,复合分组的次维进 SubValue。
|
||||
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
||||
out := CostItem{
|
||||
Currency: deref(item.Currency),
|
||||
@@ -94,15 +121,13 @@ func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
||||
if item.ComputedQuantity != nil {
|
||||
out.ComputedQuantity = *item.ComputedQuantity
|
||||
}
|
||||
switch strings.ToLower(groupBy) {
|
||||
case "skuname":
|
||||
out.GroupValue = deref(item.SkuName)
|
||||
case "region":
|
||||
out.GroupValue = deref(item.Region)
|
||||
case "compartmentname":
|
||||
out.GroupValue = deref(item.CompartmentName)
|
||||
default:
|
||||
out.GroupValue = deref(item.Service)
|
||||
dims := splitGroupBy(groupBy)
|
||||
if len(dims) == 0 {
|
||||
dims = []string{"service"}
|
||||
}
|
||||
out.GroupValue = dimValue(item, dims[0])
|
||||
if len(dims) > 1 {
|
||||
out.SubValue = dimValue(item, dims[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type CreateInstanceInput struct {
|
||||
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
||||
SubnetID string // 为空时自动创建 VCN 与子网
|
||||
AssignPublicIP bool
|
||||
ReservedPublicIPID string // 非空时不分配临时公网 IP,实例就绪后由 service 层绑定该保留 IP
|
||||
AssignIpv6 bool
|
||||
SSHPublicKey string // 与 RootPassword 互斥
|
||||
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
|
||||
@@ -189,6 +190,10 @@ func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in Cr
|
||||
}
|
||||
|
||||
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
|
||||
// 保留 IP 不能在 launch 时直接绑定:先不分配临时公网 IP,就绪后由 service 层换绑
|
||||
if in.ReservedPublicIPID != "" {
|
||||
in.AssignPublicIP = false
|
||||
}
|
||||
details := core.LaunchInstanceDetails{
|
||||
CompartmentId: &compartmentID,
|
||||
AvailabilityDomain: &in.AvailabilityDomain,
|
||||
|
||||
@@ -345,8 +345,8 @@ func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vc
|
||||
return toVCN(resp.Vcn), nil
|
||||
}
|
||||
|
||||
// DeleteVCN 实现 Client:级联清理子网、网关与非默认路由表 / 安全列表 /
|
||||
// DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
|
||||
// DeleteVCN 实现 Client:级联清理子网、网关、网络安全组与非默认路由表 /
|
||||
// 安全列表 / DHCP 选项后删除 VCN(见 vcndelete.go);子网被实例占用时 OCI 返回 409。
|
||||
func (c *RealClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
// 对象存储:namespace / 桶 / 对象 / 预签名请求(PAR)。
|
||||
// 上传下载数据面走 PAR 直连 OCI,面板只做控制面;
|
||||
// 例外:预览/编辑的小文件经面板中转(GetObject/PutObject),不签发 PAR。
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/objectstorage"
|
||||
)
|
||||
|
||||
// ErrObjectTooLarge 表示对象超出面板中转大小上限。
|
||||
var ErrObjectTooLarge = errors.New("object too large for inline transfer")
|
||||
|
||||
// ObjectContent 是对象内容与写入所需元数据(面板中转小文件用)。
|
||||
type ObjectContent struct {
|
||||
Data []byte
|
||||
ContentType string
|
||||
Etag string
|
||||
}
|
||||
|
||||
// Bucket 是存储桶摘要(含用量估算,来自 GetBucket 的 approximate 字段)。
|
||||
type Bucket struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Visibility string `json:"visibility"` // NoPublicAccess / ObjectRead
|
||||
StorageTier string `json:"storageTier"`
|
||||
VersioningOn bool `json:"versioningOn"`
|
||||
ApproximateCount int64 `json:"approximateCount"`
|
||||
ApproximateSize int64 `json:"approximateSize"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// ObjectSummary 是对象列表行;文件夹以 prefixes 单独返回。
|
||||
type ObjectSummary struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
StorageTier string `json:"storageTier"`
|
||||
ArchivalState string `json:"archivalState"`
|
||||
TimeModified *time.Time `json:"timeModified"`
|
||||
}
|
||||
|
||||
// ListObjectsResult 是分页对象列表:delimiter="/" 模式,子层级归入 Prefixes。
|
||||
type ListObjectsResult struct {
|
||||
Objects []ObjectSummary `json:"objects"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
NextStartWith string `json:"nextStartWith"`
|
||||
}
|
||||
|
||||
// ObjectDetail 是对象元数据(HEAD)。
|
||||
type ObjectDetail struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
Etag string `json:"etag"`
|
||||
ContentMd5 string `json:"contentMd5"`
|
||||
StorageTier string `json:"storageTier"`
|
||||
ArchivalState string `json:"archivalState"`
|
||||
TimeModified *time.Time `json:"timeModified"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// PAR 是预签名请求摘要;FullURL 仅创建响应携带(OCI 事后不可再取回)。
|
||||
type PAR struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ObjectName string `json:"objectName"`
|
||||
AccessType string `json:"accessType"`
|
||||
FullURL string `json:"fullUrl,omitempty"`
|
||||
TimeExpires *time.Time `json:"timeExpires"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// CreateBucketInput 创建桶参数;CompartmentID 空为生效 compartment。
|
||||
type CreateBucketInput struct {
|
||||
Name string
|
||||
CompartmentID string
|
||||
PublicRead bool
|
||||
StorageTier string // Standard / Archive
|
||||
VersioningOn bool
|
||||
}
|
||||
|
||||
// UpdateBucketInput 更新桶参数;nil 字段不改。
|
||||
type UpdateBucketInput struct {
|
||||
PublicRead *bool
|
||||
VersioningOn *bool
|
||||
}
|
||||
|
||||
// CreatePARInput 签发预签名请求参数。
|
||||
type CreatePARInput struct {
|
||||
Name string
|
||||
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
|
||||
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite
|
||||
ExpiresHours int
|
||||
}
|
||||
|
||||
// bulkRetryPolicy 批量删除(清空桶)的限流退避:16 并发下 OCI 可能回 429。
|
||||
// 不用 DefaultRetryPolicy:其 8 次尝试、退避上限 30s,且带最终一致性模式
|
||||
// (9 次、上限 45s)——大批量删除里 worker 一旦触发就长睡,吞吐塌方;
|
||||
// 这里收紧为 5 次、上限 3s、关掉最终一致性,单条最坏 ~13s 后放行不拖全场。
|
||||
var bulkRetryPolicy = common.NewRetryPolicyWithOptions(
|
||||
common.ReplaceWithValuesFromRetryPolicy(common.DefaultRetryPolicyWithoutEventualConsistency()),
|
||||
common.WithMaximumNumberAttempts(5),
|
||||
common.WithExponentialBackoff(3*time.Second, 2.0),
|
||||
)
|
||||
|
||||
func (c *RealClient) osClient(cred Credentials, region string) (objectstorage.ObjectStorageClient, error) {
|
||||
oc, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return oc, fmt.Errorf("new object storage client: %w", err)
|
||||
}
|
||||
applyProxy(&oc.BaseClient, cred)
|
||||
if region != "" {
|
||||
oc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return oc, nil
|
||||
}
|
||||
|
||||
// GetObjectStorageNamespace 实现 Client:租户 namespace(全租户唯一,常量语义),
|
||||
// 首次回源后按 tenancy 进程内缓存;此前每个对象存储操作都远程取一次,
|
||||
// 经代理链路时白付一整次冷连接往返。并发首次可能重复回源,结果相同无害。
|
||||
func (c *RealClient) GetObjectStorageNamespace(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
if v, ok := c.namespaces.Load(cred.TenancyOCID); ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := oc.GetNamespace(ctx, objectstorage.GetNamespaceRequest{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get namespace: %w", err)
|
||||
}
|
||||
if cred.TenancyOCID != "" {
|
||||
c.namespaces.Store(cred.TenancyOCID, deref(resp.Value))
|
||||
}
|
||||
return deref(resp.Value), nil
|
||||
}
|
||||
|
||||
// ListBuckets 实现 Client:列出指定 compartment 下的桶并逐个补全用量(空为生效 compartment)。
|
||||
func (c *RealClient) ListBuckets(ctx context.Context, cred Credentials, region, compartmentID string) ([]Bucket, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := oc.ListBuckets(ctx, objectstorage.ListBucketsRequest{
|
||||
NamespaceName: &ns,
|
||||
CompartmentId: hostCompartment(cred, compartmentID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list buckets: %w", err)
|
||||
}
|
||||
out := make([]Bucket, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, c.bucketDetail(ctx, oc, ns, deref(it.Name)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bucketDetail 取单桶详情;失败时退化为只有名字的摘要,不拖垮列表。
|
||||
func (c *RealClient) bucketDetail(ctx context.Context, oc objectstorage.ObjectStorageClient, ns, name string) Bucket {
|
||||
resp, err := oc.GetBucket(ctx, objectstorage.GetBucketRequest{
|
||||
NamespaceName: &ns,
|
||||
BucketName: &name,
|
||||
Fields: []objectstorage.GetBucketFieldsEnum{"approximateCount", "approximateSize"},
|
||||
})
|
||||
if err != nil {
|
||||
return Bucket{Name: name, Namespace: ns}
|
||||
}
|
||||
b := Bucket{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Visibility: string(resp.PublicAccessType),
|
||||
StorageTier: string(resp.StorageTier),
|
||||
VersioningOn: resp.Versioning == objectstorage.BucketVersioningEnabled,
|
||||
ApproximateCount: derefI64(resp.ApproximateCount),
|
||||
ApproximateSize: derefI64(resp.ApproximateSize),
|
||||
}
|
||||
if resp.TimeCreated != nil {
|
||||
b.TimeCreated = &resp.TimeCreated.Time
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func derefI64(p *int64) int64 {
|
||||
if p != nil {
|
||||
return *p
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CreateBucket 实现 Client。
|
||||
func (c *RealClient) CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return Bucket{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return Bucket{}, err
|
||||
}
|
||||
details := objectstorage.CreateBucketDetails{
|
||||
Name: &in.Name,
|
||||
CompartmentId: hostCompartment(cred, in.CompartmentID),
|
||||
PublicAccessType: objectstorage.CreateBucketDetailsPublicAccessTypeNopublicaccess,
|
||||
StorageTier: objectstorage.CreateBucketDetailsStorageTierStandard,
|
||||
}
|
||||
if in.PublicRead {
|
||||
details.PublicAccessType = objectstorage.CreateBucketDetailsPublicAccessTypeObjectread
|
||||
}
|
||||
if strings.EqualFold(in.StorageTier, "Archive") {
|
||||
details.StorageTier = objectstorage.CreateBucketDetailsStorageTierArchive
|
||||
}
|
||||
if in.VersioningOn {
|
||||
details.Versioning = objectstorage.CreateBucketDetailsVersioningEnabled
|
||||
}
|
||||
if _, err := oc.CreateBucket(ctx, objectstorage.CreateBucketRequest{
|
||||
NamespaceName: &ns, CreateBucketDetails: details,
|
||||
}); err != nil {
|
||||
return Bucket{}, fmt.Errorf("create bucket: %w", err)
|
||||
}
|
||||
return c.bucketDetail(ctx, oc, ns, in.Name), nil
|
||||
}
|
||||
|
||||
// UpdateBucket 实现 Client:改可见性 / 版本控制。
|
||||
func (c *RealClient) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return Bucket{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return Bucket{}, err
|
||||
}
|
||||
details := objectstorage.UpdateBucketDetails{}
|
||||
if in.PublicRead != nil {
|
||||
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeNopublicaccess
|
||||
if *in.PublicRead {
|
||||
details.PublicAccessType = objectstorage.UpdateBucketDetailsPublicAccessTypeObjectread
|
||||
}
|
||||
}
|
||||
if in.VersioningOn != nil {
|
||||
details.Versioning = objectstorage.UpdateBucketDetailsVersioningSuspended
|
||||
if *in.VersioningOn {
|
||||
details.Versioning = objectstorage.UpdateBucketDetailsVersioningEnabled
|
||||
}
|
||||
}
|
||||
if _, err := oc.UpdateBucket(ctx, objectstorage.UpdateBucketRequest{
|
||||
NamespaceName: &ns, BucketName: &name, UpdateBucketDetails: details,
|
||||
}); err != nil {
|
||||
return Bucket{}, fmt.Errorf("update bucket: %w", err)
|
||||
}
|
||||
return c.bucketDetail(ctx, oc, ns, name), nil
|
||||
}
|
||||
|
||||
// DeleteBucket 实现 Client:仅空桶可删,OCI 拒绝非空桶。
|
||||
// ErrBucketNotEmpty 表示桶内仍有对象 / 历史版本 / 未完成分片上传,OCI 拒绝删除。
|
||||
var ErrBucketNotEmpty = errors.New("bucket not empty")
|
||||
|
||||
func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := oc.DeleteBucket(ctx, objectstorage.DeleteBucketRequest{
|
||||
NamespaceName: &ns, BucketName: &name,
|
||||
}); err != nil {
|
||||
if isBucketNotEmptyErr(err) {
|
||||
return fmt.Errorf("delete bucket %s: %w", name, ErrBucketNotEmpty)
|
||||
}
|
||||
return fmt.Errorf("delete bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
|
||||
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
|
||||
// 两者都可经后台清空(对象版本+PAR)后重删。
|
||||
func isBucketNotEmptyErr(err error) bool {
|
||||
var se common.ServiceError
|
||||
if !errors.As(err, &se) {
|
||||
return false
|
||||
}
|
||||
if se.GetCode() == "BucketNotEmpty" {
|
||||
return true
|
||||
}
|
||||
return se.GetHTTPStatusCode() == 409 &&
|
||||
strings.Contains(se.GetMessage(), "Preauthenticated Request")
|
||||
}
|
||||
|
||||
// ObjectVersion 是对象版本摘要,清空桶时逐版本删除用。
|
||||
type ObjectVersion struct {
|
||||
Name string `json:"name"`
|
||||
VersionID string `json:"versionId"`
|
||||
}
|
||||
|
||||
// ListObjectVersions 实现 Client:分页列出全部对象版本(未开版本控制的桶返回当前版本)。
|
||||
func (c *RealClient) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req := objectstorage.ListObjectVersionsRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, Limit: common.Int(1000),
|
||||
}
|
||||
if page != "" {
|
||||
req.Page = &page
|
||||
}
|
||||
resp, err := oc.ListObjectVersions(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list object versions: %w", err)
|
||||
}
|
||||
out := make([]ObjectVersion, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, ObjectVersion{Name: deref(it.Name), VersionID: deref(it.VersionId)})
|
||||
}
|
||||
return out, deref(resp.OpcNextPage), nil
|
||||
}
|
||||
|
||||
// DeleteObjectVersion 实现 Client:删除指定版本(versionID 空为当前版本)。
|
||||
func (c *RealClient) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := objectstorage.DeleteObjectRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
|
||||
}
|
||||
if versionID != "" {
|
||||
req.VersionId = &versionID
|
||||
}
|
||||
if _, err := oc.DeleteObject(ctx, req); err != nil {
|
||||
return fmt.Errorf("delete object version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListObjects 实现 Client:delimiter="/" 前缀模式;startWith 为上页游标。
|
||||
func (c *RealClient) ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return ListObjectsResult{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return ListObjectsResult{}, err
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
req := objectstorage.ListObjectsRequest{
|
||||
NamespaceName: &ns,
|
||||
BucketName: &bucket,
|
||||
Delimiter: common.String("/"),
|
||||
Limit: &limit,
|
||||
Fields: common.String("name,size,timeModified,storageTier,archivalState"),
|
||||
}
|
||||
if prefix != "" {
|
||||
req.Prefix = &prefix
|
||||
}
|
||||
if startWith != "" {
|
||||
req.Start = &startWith
|
||||
}
|
||||
resp, err := oc.ListObjects(ctx, req)
|
||||
if err != nil {
|
||||
return ListObjectsResult{}, fmt.Errorf("list objects: %w", err)
|
||||
}
|
||||
return toListObjectsResult(resp), nil
|
||||
}
|
||||
|
||||
func toListObjectsResult(resp objectstorage.ListObjectsResponse) ListObjectsResult {
|
||||
out := ListObjectsResult{
|
||||
Objects: make([]ObjectSummary, 0, len(resp.Objects)),
|
||||
Prefixes: orEmpty(resp.Prefixes),
|
||||
NextStartWith: deref(resp.NextStartWith),
|
||||
}
|
||||
for _, o := range resp.Objects {
|
||||
item := ObjectSummary{
|
||||
Name: deref(o.Name),
|
||||
Size: derefI64(o.Size),
|
||||
StorageTier: string(o.StorageTier),
|
||||
ArchivalState: string(o.ArchivalState),
|
||||
}
|
||||
if o.TimeModified != nil {
|
||||
item.TimeModified = &o.TimeModified.Time
|
||||
}
|
||||
out.Objects = append(out.Objects, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeleteObject 实现 Client。
|
||||
func (c *RealClient) DeleteObject(ctx context.Context, cred Credentials, region, bucket, object string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := oc.DeleteObject(ctx, objectstorage.DeleteObjectRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameObject 实现 Client:OCI 原生重命名(同桶内)。
|
||||
func (c *RealClient) RenameObject(ctx context.Context, cred Credentials, region, bucket, src, dst string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := oc.RenameObject(ctx, objectstorage.RenameObjectRequest{
|
||||
NamespaceName: &ns,
|
||||
BucketName: &bucket,
|
||||
RenameObjectDetails: objectstorage.RenameObjectDetails{
|
||||
SourceName: &src,
|
||||
NewName: &dst,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("rename object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreObject 实现 Client:Archive 对象取回,hours 为可下载时长(默认 24)。
|
||||
func (c *RealClient) RestoreObject(ctx context.Context, cred Credentials, region, bucket, object string, hours int) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := objectstorage.RestoreObjectsRequest{
|
||||
NamespaceName: &ns,
|
||||
BucketName: &bucket,
|
||||
RestoreObjectsDetails: objectstorage.RestoreObjectsDetails{
|
||||
ObjectName: &object,
|
||||
},
|
||||
}
|
||||
if hours > 0 {
|
||||
req.RestoreObjectsDetails.Hours = &hours
|
||||
}
|
||||
if _, err := oc.RestoreObjects(ctx, req); err != nil {
|
||||
return fmt.Errorf("restore object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HeadObject 实现 Client:对象元数据。
|
||||
func (c *RealClient) HeadObject(ctx context.Context, cred Credentials, region, bucket, object string) (ObjectDetail, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return ObjectDetail{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return ObjectDetail{}, err
|
||||
}
|
||||
resp, err := oc.HeadObject(ctx, objectstorage.HeadObjectRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||
})
|
||||
if err != nil {
|
||||
return ObjectDetail{}, fmt.Errorf("head object: %w", err)
|
||||
}
|
||||
d := ObjectDetail{
|
||||
Name: object,
|
||||
Size: derefI64(resp.ContentLength),
|
||||
ContentType: deref(resp.ContentType),
|
||||
Etag: deref(resp.ETag),
|
||||
ContentMd5: deref(resp.ContentMd5),
|
||||
StorageTier: string(resp.StorageTier),
|
||||
ArchivalState: string(resp.ArchivalState),
|
||||
Metadata: resp.OpcMeta,
|
||||
}
|
||||
if resp.LastModified != nil {
|
||||
d.TimeModified = &resp.LastModified.Time
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetObject 实现 Client:整体读入对象内容;超过 maxBytes 返回 ErrObjectTooLarge。
|
||||
func (c *RealClient) GetObject(ctx context.Context, cred Credentials, region, bucket, object string, maxBytes int64) (ObjectContent, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return ObjectContent{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return ObjectContent{}, err
|
||||
}
|
||||
resp, err := oc.GetObject(ctx, objectstorage.GetObjectRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||
})
|
||||
if err != nil {
|
||||
return ObjectContent{}, fmt.Errorf("get object: %w", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
if derefI64(resp.ContentLength) > maxBytes {
|
||||
return ObjectContent{}, fmt.Errorf("get object %s (%d bytes): %w", object, derefI64(resp.ContentLength), ErrObjectTooLarge)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
|
||||
if err != nil {
|
||||
return ObjectContent{}, fmt.Errorf("read object body: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return ObjectContent{}, fmt.Errorf("get object %s: %w", object, ErrObjectTooLarge)
|
||||
}
|
||||
return ObjectContent{Data: data, ContentType: deref(resp.ContentType), Etag: deref(resp.ETag)}, nil
|
||||
}
|
||||
|
||||
// PutObject 实现 Client:整体写入对象;ifMatch 非空时作为 If-Match 条件,
|
||||
// 不匹配由 OCI 返回 412(经 ServiceError 透出)。返回新 ETag。
|
||||
func (c *RealClient) PutObject(ctx context.Context, cred Credentials, region, bucket, object string, data []byte, contentType, ifMatch string) (string, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
length := int64(len(data))
|
||||
req := objectstorage.PutObjectRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: &object,
|
||||
ContentLength: &length,
|
||||
PutObjectBody: io.NopCloser(bytes.NewReader(data)),
|
||||
}
|
||||
if contentType != "" {
|
||||
req.ContentType = &contentType
|
||||
}
|
||||
if ifMatch != "" {
|
||||
req.IfMatch = &ifMatch
|
||||
}
|
||||
resp, err := oc.PutObject(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object: %w", err)
|
||||
}
|
||||
return deref(resp.ETag), nil
|
||||
}
|
||||
|
||||
// CreatePAR 实现 Client:签发预签名请求并拼出完整 URL(仅此时可得)。
|
||||
func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bucket string, in CreatePARInput) (PAR, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return PAR{}, err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return PAR{}, err
|
||||
}
|
||||
if in.ExpiresHours <= 0 {
|
||||
in.ExpiresHours = 24
|
||||
}
|
||||
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
||||
Name: &in.Name,
|
||||
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
||||
TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)},
|
||||
}
|
||||
if in.ObjectName != "" {
|
||||
details.ObjectName = &in.ObjectName
|
||||
}
|
||||
if in.AccessType == "AnyObjectReadWrite" || in.AccessType == "AnyObjectWrite" {
|
||||
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
||||
}
|
||||
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
|
||||
})
|
||||
if err != nil {
|
||||
return PAR{}, fmt.Errorf("create par: %w", err)
|
||||
}
|
||||
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
|
||||
}
|
||||
|
||||
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
|
||||
out := PAR{
|
||||
ID: deref(p.Id),
|
||||
Name: deref(p.Name),
|
||||
ObjectName: deref(p.ObjectName),
|
||||
AccessType: string(p.AccessType),
|
||||
}
|
||||
if p.AccessUri != nil {
|
||||
out.FullURL = strings.TrimSuffix(endpoint, "/") + *p.AccessUri
|
||||
}
|
||||
if p.TimeExpires != nil {
|
||||
out.TimeExpires = &p.TimeExpires.Time
|
||||
}
|
||||
if p.TimeCreated != nil {
|
||||
out.TimeCreated = &p.TimeCreated.Time
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListPARsPage 实现 Client:单页列出桶内预签名请求(摘要不含 URL),
|
||||
// 返回下一页游标,空串表示已到末页;limit ≤0 时用 OCI 默认页大小。
|
||||
func (c *RealClient) ListPARsPage(ctx context.Context, cred Credentials, region, bucket, page string, limit int) ([]PAR, string, error) {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req := objectstorage.ListPreauthenticatedRequestsRequest{NamespaceName: &ns, BucketName: &bucket}
|
||||
if limit > 0 {
|
||||
req.Limit = common.Int(limit)
|
||||
}
|
||||
if page != "" {
|
||||
req.Page = &page
|
||||
}
|
||||
resp, err := oc.ListPreauthenticatedRequests(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list pars: %w", err)
|
||||
}
|
||||
out := make([]PAR, 0, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
out = append(out, summaryPAR(it))
|
||||
}
|
||||
return out, deref(resp.OpcNextPage), nil
|
||||
}
|
||||
|
||||
// ListPARs 实现 Client:翻页列出桶内全部有效预签名请求(清空/全部删除用)。
|
||||
func (c *RealClient) ListPARs(ctx context.Context, cred Credentials, region, bucket string) ([]PAR, error) {
|
||||
out := make([]PAR, 0, 16)
|
||||
page := ""
|
||||
for {
|
||||
items, next, err := c.ListPARsPage(ctx, cred, region, bucket, page, 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, items...)
|
||||
if next == "" {
|
||||
return out, nil
|
||||
}
|
||||
page = next
|
||||
}
|
||||
}
|
||||
|
||||
func summaryPAR(p objectstorage.PreauthenticatedRequestSummary) PAR {
|
||||
out := PAR{
|
||||
ID: deref(p.Id),
|
||||
Name: deref(p.Name),
|
||||
ObjectName: deref(p.ObjectName),
|
||||
AccessType: string(p.AccessType),
|
||||
}
|
||||
if p.TimeExpires != nil {
|
||||
out.TimeExpires = &p.TimeExpires.Time
|
||||
}
|
||||
if p.TimeCreated != nil {
|
||||
out.TimeCreated = &p.TimeCreated.Time
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeletePAR 实现 Client:撤销预签名请求,链接立即失效。
|
||||
func (c *RealClient) DeletePAR(ctx context.Context, cred Credentials, region, bucket, parID string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := oc.DeletePreauthenticatedRequest(ctx, objectstorage.DeletePreauthenticatedRequestRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ParId: &parID,
|
||||
RequestMetadata: common.RequestMetadata{RetryPolicy: &bulkRetryPolicy},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete par: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
|
||||
// (凭据为空也能取到,证明未走回源路径)。
|
||||
func TestGetObjectStorageNamespaceCached(t *testing.T) {
|
||||
c := NewClient()
|
||||
tests := []struct {
|
||||
name string
|
||||
tenancy string
|
||||
seed string
|
||||
}{
|
||||
{name: "缓存命中直接返回", tenancy: "ocid1.tenancy.oc1..aaaa", seed: "ns-a"},
|
||||
{name: "不同租户各取各的", tenancy: "ocid1.tenancy.oc1..bbbb", seed: "ns-b"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c.namespaces.Store(tt.tenancy, tt.seed)
|
||||
got, err := c.GetObjectStorageNamespace(context.Background(), Credentials{TenancyOCID: tt.tenancy}, "us-ashburn-1")
|
||||
if err != nil || got != tt.seed {
|
||||
t.Fatalf("GetObjectStorageNamespace = %q, %v; want %q", got, err, tt.seed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+39
-11
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
@@ -31,6 +32,20 @@ const (
|
||||
proxyTLSHandshakeTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// 连接池参数:PerHost 须 > 批量删除并发数(service 层 16)——并发拨号竞速时
|
||||
// Transport 会投机新建连接,池上限过紧会让这些连接用一次即被丢弃(churn);
|
||||
// IdleConnTimeout 显式关闭空闲连接(零值=永不关,只能等远端 ~65s 断开,连接会堆积)。
|
||||
const (
|
||||
proxyMaxIdleConns = 128
|
||||
proxyMaxIdleConnsPerHost = 32
|
||||
proxyIdleConnTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// proxyClients 按代理配置复用 http.Client。SDK client 每次操作新建,
|
||||
// 若 Transport 也随之新建则连接零复用:每个请求都要付完整的
|
||||
// TCP+SOCKS/CONNECT+TLS 握手(经代理 3+ 个 RTT),批量操作被拖到分钟级。
|
||||
var proxyClients sync.Map // ProxySpec(值) → *http.Client
|
||||
|
||||
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
||||
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
||||
func applyProxy(base *common.BaseClient, cred Credentials) {
|
||||
@@ -39,16 +54,21 @@ func applyProxy(base *common.BaseClient, cred Credentials) {
|
||||
}
|
||||
}
|
||||
|
||||
// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。
|
||||
// proxyHTTPClient 按代理配置取共享 http.Client;nil 或非法配置返回 nil(走直连)。
|
||||
// 同配置复用同一实例(连接池随之复用);调用方只可包装、不得改写其字段。
|
||||
func proxyHTTPClient(p *ProxySpec) *http.Client {
|
||||
if p == nil || p.Host == "" || p.Port <= 0 {
|
||||
return nil
|
||||
}
|
||||
if v, ok := proxyClients.Load(*p); ok {
|
||||
return v.(*http.Client)
|
||||
}
|
||||
tr := transportFor(p)
|
||||
if tr == nil {
|
||||
return nil
|
||||
}
|
||||
return &http.Client{Transport: tr, Timeout: proxyClientTimeout}
|
||||
v, _ := proxyClients.LoadOrStore(*p, &http.Client{Transport: tr, Timeout: proxyClientTimeout})
|
||||
return v.(*http.Client)
|
||||
}
|
||||
|
||||
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
|
||||
@@ -57,6 +77,16 @@ func HTTPClientFor(p *ProxySpec) *http.Client {
|
||||
return proxyHTTPClient(p)
|
||||
}
|
||||
|
||||
// pooledTransport 连接池参数统一的 Transport 骨架;阶段超时见常量注释。
|
||||
func pooledTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||
MaxIdleConns: proxyMaxIdleConns,
|
||||
MaxIdleConnsPerHost: proxyMaxIdleConnsPerHost,
|
||||
IdleConnTimeout: proxyIdleConnTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
||||
func transportFor(p *ProxySpec) *http.Transport {
|
||||
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||
@@ -66,11 +96,10 @@ func transportFor(p *ProxySpec) *http.Transport {
|
||||
if p.Username != "" {
|
||||
u.User = url.UserPassword(p.Username, p.Password)
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyURL(u),
|
||||
DialContext: dialer.DialContext,
|
||||
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||
}
|
||||
tr := pooledTransport()
|
||||
tr.Proxy = http.ProxyURL(u)
|
||||
tr.DialContext = dialer.DialContext
|
||||
return tr
|
||||
}
|
||||
var auth *proxy.Auth
|
||||
if p.Username != "" {
|
||||
@@ -84,8 +113,7 @@ func transportFor(p *ProxySpec) *http.Transport {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &http.Transport{
|
||||
DialContext: cd.DialContext,
|
||||
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
|
||||
}
|
||||
tr := pooledTransport()
|
||||
tr.DialContext = cd.DialContext
|
||||
return tr
|
||||
}
|
||||
|
||||
@@ -29,6 +29,63 @@ func TestTransportForStageTimeouts(t *testing.T) {
|
||||
if tr.TLSHandshakeTimeout != proxyTLSHandshakeTimeout {
|
||||
t.Fatalf("TLSHandshakeTimeout = %v, 期望 %v", tr.TLSHandshakeTimeout, proxyTLSHandshakeTimeout)
|
||||
}
|
||||
if tr.MaxIdleConnsPerHost != proxyMaxIdleConnsPerHost || tr.IdleConnTimeout != proxyIdleConnTimeout {
|
||||
t.Fatalf("连接池参数未设置: PerHost=%d IdleTimeout=%v", tr.MaxIdleConnsPerHost, tr.IdleConnTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHTTPClientReuse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b *ProxySpec
|
||||
wantSame bool
|
||||
}{
|
||||
{
|
||||
name: "同配置复用同一实例",
|
||||
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
|
||||
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
|
||||
wantSame: true,
|
||||
},
|
||||
{
|
||||
name: "不同配置各自实例",
|
||||
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080},
|
||||
b: &ProxySpec{Type: "socks5", Host: "10.0.0.2", Port: 1080},
|
||||
},
|
||||
{
|
||||
name: "同地址不同凭据不复用",
|
||||
a: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u1", Password: "p1"},
|
||||
b: &ProxySpec{Type: "socks5", Host: "10.0.0.1", Port: 1080, Username: "u2", Password: "p2"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ca, cb := proxyHTTPClient(tt.a), proxyHTTPClient(tt.b)
|
||||
if ca == nil || cb == nil {
|
||||
t.Fatal("proxyHTTPClient 返回 nil")
|
||||
}
|
||||
if (ca == cb) != tt.wantSame {
|
||||
t.Fatalf("实例复用 = %v, 期望 %v", ca == cb, tt.wantSame)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHTTPClientInvalidSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spec *ProxySpec
|
||||
}{
|
||||
{name: "nil 配置", spec: nil},
|
||||
{name: "缺 host", spec: &ProxySpec{Type: "socks5", Port: 1080}},
|
||||
{name: "非法端口", spec: &ProxySpec{Type: "socks5", Host: "10.0.0.1"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if c := proxyHTTPClient(tt.spec); c != nil {
|
||||
t.Fatalf("非法配置应返回 nil,得到 %v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package oci
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
@@ -62,7 +64,7 @@ func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIP
|
||||
}
|
||||
|
||||
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
|
||||
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作。
|
||||
// 旧临时 IP 删除、旧保留 IP 自动解绑(资源保留在账户),随后分配新的临时 IP。
|
||||
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
@@ -72,13 +74,26 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing := lookupPublicIP(ctx, vn, priv.Id); existing != nil {
|
||||
if existing.Lifetime == core.PublicIpLifetimeReserved {
|
||||
return "", fmt.Errorf("change public ip: private ip has a reserved public ip, release it manually")
|
||||
}
|
||||
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
|
||||
return "", fmt.Errorf("delete old public ip: %w", err)
|
||||
}
|
||||
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
|
||||
}
|
||||
|
||||
// ChangeVnicPublicIP 实现 Client:为指定 VNIC 的主私有 IP 更换临时公网 IP(次要网卡场景)。
|
||||
func (c *RealClient) ChangeVnicPublicIP(ctx context.Context, cred Credentials, region, vnicID string) (string, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return replaceEphemeralPublicIP(ctx, vn, cred, priv)
|
||||
}
|
||||
|
||||
// replaceEphemeralPublicIP 释放私有 IP 上现有公网 IP(临时删除、保留解绑)并分配新临时 IP。
|
||||
func replaceEphemeralPublicIP(ctx context.Context, vn core.VirtualNetworkClient, cred Credentials, priv core.PrivateIp) (string, error) {
|
||||
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
|
||||
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
|
||||
@@ -94,6 +109,45 @@ func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credential
|
||||
return deref(resp.IpAddress), nil
|
||||
}
|
||||
|
||||
// detachExistingPublicIP 释放私有 IP 上已绑定的公网 IP:
|
||||
// 临时 IP 直接删除;保留 IP 仅解绑(回到未分配状态,资源不删除)。
|
||||
func detachExistingPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) error {
|
||||
existing := lookupPublicIP(ctx, vn, privateIPID)
|
||||
if existing == nil {
|
||||
return nil
|
||||
}
|
||||
if existing.Lifetime != core.PublicIpLifetimeReserved {
|
||||
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: existing.Id}); err != nil {
|
||||
return fmt.Errorf("delete old public ip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
|
||||
PublicIpId: existing.Id,
|
||||
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: common.String("")},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unassign reserved public ip: %w", err)
|
||||
}
|
||||
waitPublicIPDetached(ctx, vn, privateIPID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitPublicIPDetached 短轮询等私有 IP 上的旧绑定释放;保留 IP 解绑为异步操作,
|
||||
// 立即创建新临时 IP 可能撞上未释放的旧绑定。超时不报错,交由后续创建操作反馈。
|
||||
func waitPublicIPDetached(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if lookupPublicIP(ctx, vn, privateIPID) == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// primaryVnic 返回实例的主 VNIC。
|
||||
func (c *RealClient) primaryVnic(ctx context.Context, cred Credentials, region, instanceID string) (core.Vnic, error) {
|
||||
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// ReservedIP 是保留公网 IP 摘要;绑定目标尽力反查,失败时对应字段为空。
|
||||
type ReservedIP struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
AssignedInstanceID string `json:"assignedInstanceId"`
|
||||
AssignedInstanceName string `json:"assignedInstanceName"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// ListReservedIPs 实现 Client:列出区间内全部保留公网 IP(REGION 作用域);
|
||||
// compartmentID 为空时用生效 compartment。
|
||||
func (c *RealClient) ListReservedIPs(ctx context.Context, cred Credentials, region, compartmentID string) ([]ReservedIP, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ReservedIP, 0, 4)
|
||||
var page *string
|
||||
for {
|
||||
resp, err := vn.ListPublicIps(ctx, core.ListPublicIpsRequest{
|
||||
Scope: core.ListPublicIpsScopeRegion,
|
||||
Lifetime: core.ListPublicIpsLifetimeReserved,
|
||||
CompartmentId: hostCompartment(cred, compartmentID),
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list reserved ips: %w", err)
|
||||
}
|
||||
for i := range resp.Items {
|
||||
out = append(out, c.toReservedIP(ctx, vn, cc, resp.Items[i]))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
break
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toReservedIP 转换 SDK 对象并尽力反查绑定的实例。
|
||||
func (c *RealClient) toReservedIP(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, p core.PublicIp) ReservedIP {
|
||||
var created *time.Time
|
||||
if p.TimeCreated != nil {
|
||||
created = &p.TimeCreated.Time
|
||||
}
|
||||
r := ReservedIP{
|
||||
ID: deref(p.Id),
|
||||
DisplayName: deref(p.DisplayName),
|
||||
IPAddress: deref(p.IpAddress),
|
||||
LifecycleState: string(p.LifecycleState),
|
||||
TimeCreated: created,
|
||||
}
|
||||
if p.AssignedEntityType == core.PublicIpAssignedEntityTypePrivateIp && p.AssignedEntityId != nil {
|
||||
r.AssignedInstanceID, r.AssignedInstanceName = resolveIPAssignee(ctx, vn, cc, *p.AssignedEntityId)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// resolveIPAssignee 由私有 IP 反查所属实例;任一步失败即放弃,不影响列表主体。
|
||||
func resolveIPAssignee(ctx context.Context, vn core.VirtualNetworkClient, cc core.ComputeClient, privateIPID string) (string, string) {
|
||||
priv, err := vn.GetPrivateIp(ctx, core.GetPrivateIpRequest{PrivateIpId: &privateIPID})
|
||||
if err != nil || priv.VnicId == nil {
|
||||
return "", ""
|
||||
}
|
||||
atts, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
|
||||
CompartmentId: priv.CompartmentId,
|
||||
VnicId: priv.VnicId,
|
||||
})
|
||||
if err != nil || len(atts.Items) == 0 || atts.Items[0].InstanceId == nil {
|
||||
return "", ""
|
||||
}
|
||||
instanceID := *atts.Items[0].InstanceId
|
||||
inst, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return instanceID, ""
|
||||
}
|
||||
return instanceID, deref(inst.DisplayName)
|
||||
}
|
||||
|
||||
// CreateReservedIP 实现 Client:在指定 compartment 创建保留公网 IP(空为生效 compartment)。
|
||||
func (c *RealClient) CreateReservedIP(ctx context.Context, cred Credentials, region, compartmentID, displayName string) (ReservedIP, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return ReservedIP{}, err
|
||||
}
|
||||
details := core.CreatePublicIpDetails{
|
||||
CompartmentId: hostCompartment(cred, compartmentID),
|
||||
Lifetime: core.CreatePublicIpDetailsLifetimeReserved,
|
||||
}
|
||||
if displayName != "" {
|
||||
details.DisplayName = &displayName
|
||||
}
|
||||
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{CreatePublicIpDetails: details})
|
||||
if err != nil {
|
||||
return ReservedIP{}, fmt.Errorf("create reserved ip: %w", err)
|
||||
}
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return ReservedIP{}, err
|
||||
}
|
||||
return c.toReservedIP(ctx, vn, cc, resp.PublicIp), nil
|
||||
}
|
||||
|
||||
// AssignReservedIP 实现 Client:绑定保留 IP 到实例主私有 IP;instanceID 为空表示解绑。
|
||||
// 目标私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑),地址会变更,调用方需提示用户。
|
||||
func (c *RealClient) AssignReservedIP(ctx context.Context, cred Credentials, region, publicIPID, instanceID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := common.String("")
|
||||
if instanceID != "" {
|
||||
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
target = priv.Id
|
||||
}
|
||||
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
|
||||
PublicIpId: &publicIPID,
|
||||
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: target},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("assign reserved ip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignReservedIPToVnic 实现 Client:绑定保留 IP 到指定 VNIC 的主私有 IP(次要网卡场景);
|
||||
// 该私有 IP 已有公网 IP 时自动释放(临时删除、其他保留解绑)。
|
||||
func (c *RealClient) AssignReservedIPToVnic(ctx context.Context, cred Credentials, region, publicIPID, vnicID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priv, err := vnicPrimaryPrivateIP(ctx, vn, vnicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := detachExistingPublicIP(ctx, vn, priv.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = vn.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{
|
||||
PublicIpId: &publicIPID,
|
||||
UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: priv.Id},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("assign reserved ip to vnic: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vnicPrimaryPrivateIP 取 VNIC 的主私有 IP。
|
||||
func vnicPrimaryPrivateIP(ctx context.Context, vn core.VirtualNetworkClient, vnicID string) (core.PrivateIp, error) {
|
||||
resp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: &vnicID})
|
||||
if err != nil {
|
||||
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
|
||||
}
|
||||
for _, p := range resp.Items {
|
||||
if p.IsPrimary != nil && *p.IsPrimary {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return core.PrivateIp{}, fmt.Errorf("assign reserved ip: vnic has no primary private ip")
|
||||
}
|
||||
|
||||
// DeleteReservedIP 实现 Client:删除保留公网 IP(已绑定的会先被 OCI 拒绝)。
|
||||
func (c *RealClient) DeleteReservedIP(ctx context.Context, cred Credentials, region, publicIPID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vn.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: &publicIPID}); err != nil {
|
||||
return fmt.Errorf("delete reserved ip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
|
||||
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
|
||||
// 网络安全组(NSG)与非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
|
||||
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState,
|
||||
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
|
||||
|
||||
@@ -32,6 +32,9 @@ func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID s
|
||||
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteVcnNsgs(ctx, vn, vcnID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
|
||||
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
|
||||
}
|
||||
@@ -183,3 +186,25 @@ func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, com
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteVcnNsgs 删除 VCN 下全部网络安全组:遗留 NSG 会使 DeleteVcn 报
|
||||
// 409 associated with NSG。仅按 VcnId 过滤,跨 compartment 的 NSG 也能覆盖;
|
||||
// 子网删除后 VNIC 已不存在,NSG 不再有关联,可直接删除。
|
||||
func deleteVcnNsgs(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
|
||||
resp, err := vn.ListNetworkSecurityGroups(ctx, core.ListNetworkSecurityGroupsRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list network security groups: %w", err)
|
||||
}
|
||||
for _, g := range resp.Items {
|
||||
if g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminated ||
|
||||
g.LifecycleState == core.NetworkSecurityGroupLifecycleStateTerminating {
|
||||
continue
|
||||
}
|
||||
if _, err := vn.DeleteNetworkSecurityGroup(ctx, core.DeleteNetworkSecurityGroupRequest{
|
||||
NetworkSecurityGroupId: g.Id,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete network security group %s: %w", deref(g.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ type AttachVnicInput struct {
|
||||
AssignPublicIP bool `json:"assignPublicIp"`
|
||||
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
|
||||
AssignIpv6 bool `json:"assignIpv6"`
|
||||
// ReservedPublicIPID 非空时不分配临时公网 IP,VNIC 就绪后由后台绑定该保留 IP。
|
||||
ReservedPublicIPID string `json:"reservedPublicIpId"`
|
||||
}
|
||||
|
||||
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
|
||||
@@ -113,6 +115,10 @@ func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, i
|
||||
if err != nil {
|
||||
return Vnic{}, err
|
||||
}
|
||||
// 选了保留 IP 就不分配临时公网 IP,避免绑定时还要先删一次
|
||||
if in.ReservedPublicIPID != "" {
|
||||
in.AssignPublicIP = false
|
||||
}
|
||||
details := &core.CreateVnicDetails{
|
||||
SubnetId: &in.SubnetID,
|
||||
AssignPublicIp: &in.AssignPublicIP,
|
||||
|
||||
Reference in New Issue
Block a user