初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// AccountProfile 是一次云端订阅查询得到的账户信息快照。
|
||||
type AccountProfile struct {
|
||||
AccountType string
|
||||
SubscriptionID string
|
||||
ServiceName string
|
||||
PaymentModel string
|
||||
SubscriptionTier string
|
||||
PromotionStatus string
|
||||
PromotionAmount float32
|
||||
PromotionExpires *time.Time
|
||||
}
|
||||
|
||||
// ClassifyAccount 按订阅字段判定账户类别:
|
||||
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
||||
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
||||
// 判为试用;promotion EXPIRED 或 tier FREE 判为免费(试用权益已结束);
|
||||
// - 其余情况无法判定。
|
||||
func ClassifyAccount(paymentModel, subscriptionTier, promotionStatus string) string {
|
||||
pm := strings.ToUpper(strings.TrimSpace(paymentModel))
|
||||
tier := strings.ToUpper(strings.TrimSpace(subscriptionTier))
|
||||
promo := strings.ToUpper(strings.TrimSpace(promotionStatus))
|
||||
if pm != "" && pm != "FREE_TRIAL" {
|
||||
return model.AccountTypePaid
|
||||
}
|
||||
if pm == "FREE_TRIAL" {
|
||||
switch {
|
||||
case promo == "ACTIVE" || tier == "FREE_AND_TRIAL":
|
||||
return model.AccountTypeTrial
|
||||
case promo == "EXPIRED" || tier == "FREE":
|
||||
return model.AccountTypeFree
|
||||
}
|
||||
}
|
||||
return model.AccountTypeUnknown
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
func TestClassifyAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
paymentModel string
|
||||
tier string
|
||||
promotionStatus string
|
||||
want string
|
||||
}{
|
||||
{name: "按量付费为付费账户", paymentModel: "Pay as you go", want: model.AccountTypePaid},
|
||||
{name: "月度账单为付费账户", paymentModel: "Monthly", want: model.AccountTypePaid},
|
||||
{name: "试用中促销激活", paymentModel: "FREE_TRIAL", promotionStatus: "ACTIVE", want: model.AccountTypeTrial},
|
||||
{name: "试用中按层级判定", paymentModel: "FREE_TRIAL", tier: "FREE_AND_TRIAL", want: model.AccountTypeTrial},
|
||||
{name: "促销过期为免费账户", paymentModel: "FREE_TRIAL", promotionStatus: "EXPIRED", want: model.AccountTypeFree},
|
||||
{name: "免费层级为免费账户", paymentModel: "FREE_TRIAL", tier: "FREE", want: model.AccountTypeFree},
|
||||
{name: "促销激活优先于免费层级", paymentModel: "FREE_TRIAL", tier: "FREE", promotionStatus: "ACTIVE", want: model.AccountTypeTrial},
|
||||
{name: "试用但无促销无层级", paymentModel: "FREE_TRIAL", want: model.AccountTypeUnknown},
|
||||
{name: "全部为空无法判定", want: model.AccountTypeUnknown},
|
||||
{name: "大小写不敏感", paymentModel: "free_trial", promotionStatus: "active", want: model.AccountTypeTrial},
|
||||
{name: "促销初始化状态不判定", paymentModel: "FREE_TRIAL", promotionStatus: "INITIALIZED", want: model.AccountTypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ClassifyAccount(tt.paymentModel, tt.tier, tt.promotionStatus)
|
||||
if got != tt.want {
|
||||
t.Errorf("ClassifyAccount(%q, %q, %q) = %q, want %q",
|
||||
tt.paymentModel, tt.tier, tt.promotionStatus, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// BootVolumeAttachment 是引导卷与实例的挂载关系。
|
||||
type BootVolumeAttachment struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
BootVolumeID string `json:"bootVolumeId"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
}
|
||||
|
||||
// VolumeAttachment 是块存储卷与实例的挂载关系。
|
||||
type VolumeAttachment struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
VolumeID string `json:"volumeId"`
|
||||
VolumeName string `json:"volumeName,omitempty"` // 卷本身的名称(attachment 名是自动生成的)
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Device string `json:"device"`
|
||||
IsReadOnly bool `json:"isReadOnly"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
}
|
||||
|
||||
// BlockVolume 是一个块存储卷。
|
||||
type BlockVolume struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
SizeInGBs int64 `json:"sizeInGBs"`
|
||||
VpusPerGB int64 `json:"vpusPerGB"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// ---- 引导卷挂载 ----
|
||||
|
||||
// ListBootVolumeAttachments 实现 Client:列出实例的引导卷挂载。
|
||||
func (c *RealClient) ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
// 挂载记录在实例所在 compartment,复用上面查到的实例定位,传租户根会漏查
|
||||
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||
AvailabilityDomain: instResp.AvailabilityDomain,
|
||||
CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)),
|
||||
InstanceId: &instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list boot volume attachments: %w", err)
|
||||
}
|
||||
atts := make([]BootVolumeAttachment, 0, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
atts = append(atts, toBootVolumeAttachment(item))
|
||||
}
|
||||
return atts, nil
|
||||
}
|
||||
|
||||
// AttachBootVolume 实现 Client;实例需处于 STOPPED,引导卷与实例同可用域。
|
||||
func (c *RealClient) AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return BootVolumeAttachment{}, err
|
||||
}
|
||||
resp, err := cc.AttachBootVolume(ctx, core.AttachBootVolumeRequest{
|
||||
AttachBootVolumeDetails: core.AttachBootVolumeDetails{
|
||||
InstanceId: &instanceID,
|
||||
BootVolumeId: &bootVolumeID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return BootVolumeAttachment{}, fmt.Errorf("attach boot volume: %w", err)
|
||||
}
|
||||
return toBootVolumeAttachment(resp.BootVolumeAttachment), nil
|
||||
}
|
||||
|
||||
// DetachBootVolume 实现 Client;实例需处于 STOPPED。
|
||||
func (c *RealClient) DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cc.DetachBootVolume(ctx, core.DetachBootVolumeRequest{BootVolumeAttachmentId: &attachmentID}); err != nil {
|
||||
return fmt.Errorf("detach boot volume %s: %w", attachmentID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceBootVolume 实现 Client:分离实例当前引导卷,挂载新引导卷。
|
||||
// 实例必须处于 STOPPED,全程等待分离完成后再挂载。
|
||||
func (c *RealClient) ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return BootVolumeAttachment{}, err
|
||||
}
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return BootVolumeAttachment{}, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
if instResp.LifecycleState != core.InstanceLifecycleStateStopped {
|
||||
return BootVolumeAttachment{}, fmt.Errorf("replace boot volume: instance must be STOPPED, current %s", instResp.LifecycleState)
|
||||
}
|
||||
atts, err := c.ListBootVolumeAttachments(ctx, cred, region, instanceID)
|
||||
if err != nil {
|
||||
return BootVolumeAttachment{}, err
|
||||
}
|
||||
for _, att := range atts {
|
||||
if att.LifecycleState != string(core.BootVolumeAttachmentLifecycleStateDetached) {
|
||||
if err := c.DetachBootVolume(ctx, cred, region, att.ID); err != nil {
|
||||
return BootVolumeAttachment{}, err
|
||||
}
|
||||
if err := waitBootVolumeDetached(ctx, cc, att.ID); err != nil {
|
||||
return BootVolumeAttachment{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.AttachBootVolume(ctx, cred, region, instanceID, newBootVolumeID)
|
||||
}
|
||||
|
||||
// waitBootVolumeDetached 轮询等待引导卷挂载进入 DETACHED;
|
||||
// OCI 可能在分离完成后回收挂载记录,404 同样视为已分离。
|
||||
func waitBootVolumeDetached(ctx context.Context, cc core.ComputeClient, attachmentID string) error {
|
||||
for i := 0; i < 60; i++ {
|
||||
resp, err := cc.GetBootVolumeAttachment(ctx, core.GetBootVolumeAttachmentRequest{BootVolumeAttachmentId: &attachmentID})
|
||||
if err != nil {
|
||||
var svcErr common.ServiceError
|
||||
if errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 404 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("poll boot volume attachment: %w", err)
|
||||
}
|
||||
if resp.LifecycleState == core.BootVolumeAttachmentLifecycleStateDetached {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("poll boot volume detached: %w", ctx.Err())
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("poll boot volume detached: timed out")
|
||||
}
|
||||
|
||||
// ---- 块卷挂载 ----
|
||||
|
||||
// ListVolumeAttachments 实现 Client:列出实例的块卷挂载。
|
||||
func (c *RealClient) ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := core.ListVolumeAttachmentsRequest{CompartmentId: cred.EffectiveCompartment()}
|
||||
if instanceID != "" {
|
||||
// 挂载记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
req.CompartmentId = hostCompartment(cred, deref(instResp.CompartmentId))
|
||||
req.InstanceId = &instanceID
|
||||
}
|
||||
resp, err := cc.ListVolumeAttachments(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list volume attachments: %w", err)
|
||||
}
|
||||
atts := make([]VolumeAttachment, 0, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
atts = append(atts, toVolumeAttachment(item))
|
||||
}
|
||||
c.attachVolumeNames(ctx, cred, region, atts)
|
||||
return atts, nil
|
||||
}
|
||||
|
||||
// attachVolumeNames 补全每条挂载的卷名(attachment 名是自动生成的无意义串);
|
||||
// 引导卷可作数据卷附加,按 OCID 类型分别查询,查询失败留空不影响列表。
|
||||
func (c *RealClient) attachVolumeNames(ctx context.Context, cred Credentials, region string, atts []VolumeAttachment) {
|
||||
bc, err := c.blockstorageClient(cred, region)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for i := range atts {
|
||||
id := atts[i].VolumeID
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
name, ok := names[id]
|
||||
if !ok {
|
||||
name = lookupVolumeName(ctx, bc, id)
|
||||
names[id] = name
|
||||
}
|
||||
atts[i].VolumeName = name
|
||||
}
|
||||
}
|
||||
|
||||
func lookupVolumeName(ctx context.Context, bc core.BlockstorageClient, id string) string {
|
||||
if strings.Contains(id, ".bootvolume.") {
|
||||
resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &id})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return deref(resp.DisplayName)
|
||||
}
|
||||
resp, err := bc.GetVolume(ctx, core.GetVolumeRequest{VolumeId: &id})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return deref(resp.DisplayName)
|
||||
}
|
||||
|
||||
// AttachVolume 实现 Client:以半虚拟化方式挂载块卷(无需 iSCSI 配置)。
|
||||
func (c *RealClient) AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return VolumeAttachment{}, err
|
||||
}
|
||||
resp, err := cc.AttachVolume(ctx, core.AttachVolumeRequest{
|
||||
AttachVolumeDetails: core.AttachParavirtualizedVolumeDetails{
|
||||
InstanceId: &instanceID,
|
||||
VolumeId: &volumeID,
|
||||
IsReadOnly: &readOnly,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return VolumeAttachment{}, fmt.Errorf("attach volume: %w", err)
|
||||
}
|
||||
return toVolumeAttachment(resp.VolumeAttachment), nil
|
||||
}
|
||||
|
||||
// DetachVolume 实现 Client。
|
||||
func (c *RealClient) DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cc.DetachVolume(ctx, core.DetachVolumeRequest{VolumeAttachmentId: &attachmentID}); err != nil {
|
||||
return fmt.Errorf("detach volume %s: %w", attachmentID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBlockVolumes 实现 Client;availabilityDomain 为空时列出全区域块卷。
|
||||
func (c *RealClient) ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) {
|
||||
bc, err := c.blockstorageClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var volumes []BlockVolume
|
||||
var page *string
|
||||
for {
|
||||
req := core.ListVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page}
|
||||
if availabilityDomain != "" {
|
||||
req.AvailabilityDomain = &availabilityDomain
|
||||
}
|
||||
resp, err := bc.ListVolumes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list volumes: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
volumes = append(volumes, toBlockVolume(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(volumes), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func toBootVolumeAttachment(a core.BootVolumeAttachment) BootVolumeAttachment {
|
||||
return BootVolumeAttachment{
|
||||
ID: deref(a.Id),
|
||||
InstanceID: deref(a.InstanceId),
|
||||
BootVolumeID: deref(a.BootVolumeId),
|
||||
LifecycleState: string(a.LifecycleState),
|
||||
DisplayName: deref(a.DisplayName),
|
||||
AvailabilityDomain: deref(a.AvailabilityDomain),
|
||||
}
|
||||
}
|
||||
|
||||
func toVolumeAttachment(a core.VolumeAttachment) VolumeAttachment {
|
||||
out := VolumeAttachment{
|
||||
ID: deref(a.GetId()),
|
||||
InstanceID: deref(a.GetInstanceId()),
|
||||
VolumeID: deref(a.GetVolumeId()),
|
||||
LifecycleState: string(a.GetLifecycleState()),
|
||||
DisplayName: deref(a.GetDisplayName()),
|
||||
Device: deref(a.GetDevice()),
|
||||
AvailabilityDomain: deref(a.GetAvailabilityDomain()),
|
||||
}
|
||||
if ro := a.GetIsReadOnly(); ro != nil {
|
||||
out.IsReadOnly = *ro
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toBlockVolume(v core.Volume) BlockVolume {
|
||||
out := BlockVolume{
|
||||
ID: deref(v.Id),
|
||||
DisplayName: deref(v.DisplayName),
|
||||
LifecycleState: string(v.LifecycleState),
|
||||
AvailabilityDomain: deref(v.AvailabilityDomain),
|
||||
TimeCreated: sdkTime(v.TimeCreated),
|
||||
}
|
||||
if v.SizeInGBs != nil {
|
||||
out.SizeInGBs = *v.SizeInGBs
|
||||
}
|
||||
if v.VpusPerGB != nil {
|
||||
out.VpusPerGB = *v.VpusPerGB
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
)
|
||||
|
||||
// maxAuditPages 限制单次查询的翻页数:繁忙租户单日事件可上千,
|
||||
// 到限即返回 Truncated=true,由调用方收窄时间窗。
|
||||
// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。
|
||||
const maxAuditPages = 10
|
||||
|
||||
// AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id,
|
||||
// 详情反查的键。Raw 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存,
|
||||
// 列表响应不再携带(详情接口按 eventId 取回)。
|
||||
type AuditEvent struct {
|
||||
EventId string `json:"eventId"`
|
||||
EventTime *time.Time `json:"eventTime"`
|
||||
EventName string `json:"eventName"`
|
||||
Source string `json:"source"`
|
||||
ResourceName string `json:"resourceName"`
|
||||
CompartmentName string `json:"compartmentName"`
|
||||
PrincipalName string `json:"principalName"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
Status string `json:"status"`
|
||||
RequestAction string `json:"requestAction"`
|
||||
RequestPath string `json:"requestPath"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断,
|
||||
// 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。
|
||||
type AuditEventsResult struct {
|
||||
Items []AuditEvent `json:"items"`
|
||||
Truncated bool `json:"truncated"`
|
||||
NextPage string `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) {
|
||||
ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return ac, fmt.Errorf("new audit client: %w", err)
|
||||
}
|
||||
applyProxy(&ac.BaseClient, cred)
|
||||
if region != "" {
|
||||
ac.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return ac, nil
|
||||
}
|
||||
|
||||
// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的
|
||||
// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。
|
||||
// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。
|
||||
func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) {
|
||||
ac, err := c.auditClient(cred, region)
|
||||
if err != nil {
|
||||
return AuditEventsResult{}, err
|
||||
}
|
||||
// Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0
|
||||
req := audit.ListEventsRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)},
|
||||
EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)},
|
||||
}
|
||||
if page != "" {
|
||||
req.Page = &page
|
||||
}
|
||||
result := AuditEventsResult{Items: []AuditEvent{}}
|
||||
for i := 0; i < maxAuditPages; i++ {
|
||||
resp, err := ac.ListEvents(ctx, req)
|
||||
if err != nil {
|
||||
return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
appendAuditEvents(&result, resp.Items)
|
||||
if resp.OpcNextPage == nil {
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
req.Page = resp.OpcNextPage
|
||||
}
|
||||
result.Truncated = true
|
||||
result.NextPage = deref(req.Page)
|
||||
sortAuditEvents(result.Items)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
||||
for _, ev := range items {
|
||||
out := toAuditEvent(ev)
|
||||
if !keepAuditEvent(out) {
|
||||
continue
|
||||
}
|
||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||
out.Raw = raw
|
||||
}
|
||||
result.Items = append(result.Items, out)
|
||||
}
|
||||
}
|
||||
|
||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||
var auditInternalCIDRs = func() []*net.IPNet {
|
||||
out := make([]*net.IPNet, 0, 4)
|
||||
for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
out = append(out, block)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// keepAuditEvent 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗),
|
||||
// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调;
|
||||
// 无 IP 的事件(控制面内部)保留。
|
||||
func keepAuditEvent(ev AuditEvent) bool {
|
||||
if ev.EventName == "SummarizeMetricsData" {
|
||||
return false
|
||||
}
|
||||
if ev.IPAddress == "" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(ev.IPAddress)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
for _, block := range auditInternalCIDRs {
|
||||
if block.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。
|
||||
func toAuditEvent(ev audit.AuditEvent) AuditEvent {
|
||||
out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)}
|
||||
if ev.EventTime != nil {
|
||||
out.EventTime = &ev.EventTime.Time
|
||||
}
|
||||
if ev.Data == nil {
|
||||
return out
|
||||
}
|
||||
out.EventName = deref(ev.Data.EventName)
|
||||
out.ResourceName = deref(ev.Data.ResourceName)
|
||||
out.CompartmentName = deref(ev.Data.CompartmentName)
|
||||
if id := ev.Data.Identity; id != nil {
|
||||
out.PrincipalName = deref(id.PrincipalName)
|
||||
out.IPAddress = deref(id.IpAddress)
|
||||
}
|
||||
if req := ev.Data.Request; req != nil {
|
||||
out.RequestAction = deref(req.Action)
|
||||
out.RequestPath = deref(req.Path)
|
||||
}
|
||||
if resp := ev.Data.Response; resp != nil {
|
||||
out.Status = deref(resp.Status)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。
|
||||
func sortAuditEvents(items []AuditEvent) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ti, tj := items[i].EventTime, items[j].EventTime
|
||||
switch {
|
||||
case ti == nil:
|
||||
return false
|
||||
case tj == nil:
|
||||
return true
|
||||
default:
|
||||
return ti.After(*tj)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/audit"
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
)
|
||||
|
||||
func TestToAuditEvent(t *testing.T) {
|
||||
eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
ev audit.AuditEvent
|
||||
want AuditEvent
|
||||
}{
|
||||
{
|
||||
name: "全字段齐全",
|
||||
ev: audit.AuditEvent{
|
||||
EventId: common.String("evt-abc"),
|
||||
Source: common.String("ComputeApi"),
|
||||
EventTime: &common.SDKTime{Time: eventTime},
|
||||
Data: &audit.Data{
|
||||
EventName: common.String("TerminateInstance"),
|
||||
ResourceName: common.String("web-1"),
|
||||
CompartmentName: common.String("prod"),
|
||||
Identity: &audit.Identity{
|
||||
PrincipalName: common.String("api-admin"),
|
||||
IpAddress: common.String("1.2.3.4"),
|
||||
},
|
||||
Request: &audit.Request{
|
||||
Action: common.String("DELETE"),
|
||||
Path: common.String("/20160918/instances/ocid1..."),
|
||||
},
|
||||
Response: &audit.Response{Status: common.String("204")},
|
||||
},
|
||||
},
|
||||
want: AuditEvent{
|
||||
EventId: "evt-abc",
|
||||
EventTime: &eventTime,
|
||||
EventName: "TerminateInstance",
|
||||
Source: "ComputeApi",
|
||||
ResourceName: "web-1",
|
||||
CompartmentName: "prod",
|
||||
PrincipalName: "api-admin",
|
||||
IPAddress: "1.2.3.4",
|
||||
Status: "204",
|
||||
RequestAction: "DELETE",
|
||||
RequestPath: "/20160918/instances/ocid1...",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Data 为 nil 时只保留信封字段",
|
||||
ev: audit.AuditEvent{
|
||||
Source: common.String("VcnApi"),
|
||||
EventTime: &common.SDKTime{Time: eventTime},
|
||||
},
|
||||
want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"},
|
||||
},
|
||||
{
|
||||
name: "嵌套局部 nil 各自安全跳过",
|
||||
ev: audit.AuditEvent{
|
||||
Data: &audit.Data{
|
||||
EventName: common.String("GetInstance"),
|
||||
Identity: nil,
|
||||
Request: &audit.Request{Path: common.String("/instances")},
|
||||
Response: nil,
|
||||
},
|
||||
},
|
||||
want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"},
|
||||
},
|
||||
{
|
||||
name: "空事件全部零值",
|
||||
ev: audit.AuditEvent{},
|
||||
want: AuditEvent{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := toAuditEvent(tt.ev); !auditEventEqual(got, tt.want) {
|
||||
t.Errorf("toAuditEvent() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。
|
||||
func auditEventEqual(a, b AuditEvent) bool {
|
||||
if (a.EventTime == nil) != (b.EventTime == nil) {
|
||||
return false
|
||||
}
|
||||
if a.EventTime != nil && !a.EventTime.Equal(*b.EventTime) {
|
||||
return false
|
||||
}
|
||||
a.EventTime, b.EventTime = nil, nil
|
||||
a.Raw, b.Raw = nil, nil
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func TestSortAuditEvents(t *testing.T) {
|
||||
t1 := time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2026, 7, 6, 9, 0, 0, 0, time.UTC)
|
||||
items := []AuditEvent{
|
||||
{EventName: "old", EventTime: &t1},
|
||||
{EventName: "no-time", EventTime: nil},
|
||||
{EventName: "new", EventTime: &t2},
|
||||
}
|
||||
sortAuditEvents(items)
|
||||
got := []string{items[0].EventName, items[1].EventName, items[2].EventName}
|
||||
want := []string{"new", "old", "no-time"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sortAuditEvents() order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepAuditEvent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
event AuditEvent
|
||||
keep bool
|
||||
}{
|
||||
{"公网IP的普通事件保留", AuditEvent{EventName: "CreateUser", IPAddress: "203.0.113.7"}, true},
|
||||
{"SummarizeMetricsData 噪声排除", AuditEvent{EventName: "SummarizeMetricsData", IPAddress: "203.0.113.7"}, false},
|
||||
{"内网10段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "10.1.2.3"}, false},
|
||||
{"内网172.16段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "172.20.0.1"}, false},
|
||||
{"172.15不属内网保留", AuditEvent{EventName: "ListInstances", IPAddress: "172.15.0.1"}, true},
|
||||
{"内网192.168段发起排除", AuditEvent{EventName: "ListInstances", IPAddress: "192.168.1.1"}, false},
|
||||
{"CGNAT 100.64段排除", AuditEvent{EventName: "ListInstances", IPAddress: "100.100.0.1"}, false},
|
||||
{"100.128不属CGNAT保留", AuditEvent{EventName: "ListInstances", IPAddress: "100.128.0.1"}, true},
|
||||
{"IP为空保留(控制面事件)", AuditEvent{EventName: "TerminateInstance", IPAddress: ""}, true},
|
||||
{"IP不可解析保留", AuditEvent{EventName: "ListInstances", IPAddress: "not-an-ip"}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := keepAuditEvent(tc.event); got != tc.keep {
|
||||
t.Fatalf("keepAuditEvent(%+v) = %v, want %v", tc.event, got, tc.keep)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// BootVolume 是一个引导卷;引导卷由创建实例产生,本面板不单独创建。
|
||||
// AttachedInstanceID / AttachedInstanceName 与 ImageName 在列表与详情查询时都会补全。
|
||||
type BootVolume struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
CompartmentID string `json:"compartmentId"`
|
||||
SizeInGBs int64 `json:"sizeInGBs"`
|
||||
VpusPerGB int64 `json:"vpusPerGB"`
|
||||
ImageID string `json:"imageId"`
|
||||
ImageName string `json:"imageName,omitempty"`
|
||||
AttachedInstanceID string `json:"attachedInstanceId,omitempty"`
|
||||
AttachedInstanceName string `json:"attachedInstanceName,omitempty"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// UpdateBootVolumeInput 是更新引导卷的输入;零值字段不更新。
|
||||
// SizeInGBs 只能扩容,VpusPerGB 调整卷性能(10 均衡 / 20 高性能)。
|
||||
type UpdateBootVolumeInput struct {
|
||||
Region string
|
||||
DisplayName string
|
||||
SizeInGBs int64
|
||||
VpusPerGB int64
|
||||
}
|
||||
|
||||
func (c *RealClient) blockstorageClient(cred Credentials, region string) (core.BlockstorageClient, error) {
|
||||
bc, err := core.NewBlockstorageClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return bc, fmt.Errorf("new blockstorage client: %w", err)
|
||||
}
|
||||
applyProxy(&bc.BaseClient, cred)
|
||||
if region != "" {
|
||||
bc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return bc, nil
|
||||
}
|
||||
|
||||
// ListBootVolumes 实现 Client;availabilityDomain 为空时列出全区域引导卷。
|
||||
func (c *RealClient) ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) {
|
||||
bc, err := c.blockstorageClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var volumes []BootVolume
|
||||
var page *string
|
||||
for {
|
||||
req := core.ListBootVolumesRequest{CompartmentId: cred.EffectiveCompartment(), Page: page}
|
||||
if availabilityDomain != "" {
|
||||
req.AvailabilityDomain = &availabilityDomain
|
||||
}
|
||||
resp, err := bc.ListBootVolumes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list boot volumes: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
volumes = append(volumes, toBootVolume(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
c.attachBootVolumeInstances(ctx, cred, region, volumes)
|
||||
c.attachBootVolumeImages(ctx, cred, region, volumes)
|
||||
return orEmpty(volumes), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// attachBootVolumeImages 批量补全来源镜像名:按 imageId 去重后逐个查询,
|
||||
// 镜像已删除或查询失败时保持为空。
|
||||
func (c *RealClient) attachBootVolumeImages(ctx context.Context, cred Credentials, region string, volumes []BootVolume) {
|
||||
names := map[string]string{}
|
||||
for _, v := range volumes {
|
||||
if v.ImageID != "" {
|
||||
names[v.ImageID] = ""
|
||||
}
|
||||
}
|
||||
for id := range names {
|
||||
img, err := c.GetImage(ctx, cred, region, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
names[id] = img.DisplayName
|
||||
}
|
||||
for i := range volumes {
|
||||
volumes[i].ImageName = names[volumes[i].ImageID]
|
||||
}
|
||||
}
|
||||
|
||||
// attachBootVolumeInstances 批量补全挂载实例:按 AD + compartment 分组各拉一次
|
||||
// 全量挂载记录(免逐卷查询),构建 bootVolumeId → instanceId 映射后回填。
|
||||
func (c *RealClient) attachBootVolumeInstances(ctx context.Context, cred Credentials, region string, volumes []BootVolume) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type scope struct{ ad, comp string }
|
||||
attached := map[string]string{}
|
||||
seen := map[scope]bool{}
|
||||
for _, v := range volumes {
|
||||
s := scope{v.AvailabilityDomain, v.CompartmentID}
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
collectBootVolumeAttachments(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached)
|
||||
collectVolumeAttachedBootVolumes(ctx, cc, s.ad, hostCompartment(cred, s.comp), attached)
|
||||
}
|
||||
names := instanceNames(ctx, cc, attached)
|
||||
for i := range volumes {
|
||||
id := attached[volumes[i].ID]
|
||||
volumes[i].AttachedInstanceID = id
|
||||
volumes[i].AttachedInstanceName = names[id]
|
||||
}
|
||||
}
|
||||
|
||||
// instanceNames 按去重后的实例 ID 批量查询显示名;查询失败的留空。
|
||||
func instanceNames(ctx context.Context, cc core.ComputeClient, attached map[string]string) map[string]string {
|
||||
names := map[string]string{}
|
||||
for _, instID := range attached {
|
||||
if instID == "" || names[instID] != "" {
|
||||
continue
|
||||
}
|
||||
resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instID})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
names[instID] = deref(resp.DisplayName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// collectVolumeAttachedBootVolumes 补齐「引导卷作数据卷附加」的挂载:这类挂载
|
||||
// 记录在块卷挂载列表(volumeId 为引导卷 OCID),引导卷挂载列表查不到。
|
||||
func collectVolumeAttachedBootVolumes(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) {
|
||||
var page *string
|
||||
for {
|
||||
resp, err := cc.ListVolumeAttachments(ctx, core.ListVolumeAttachmentsRequest{
|
||||
AvailabilityDomain: &ad,
|
||||
CompartmentId: compartmentID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, att := range resp.Items {
|
||||
id := deref(att.GetVolumeId())
|
||||
if att.GetLifecycleState() == core.VolumeAttachmentLifecycleStateAttached &&
|
||||
strings.Contains(id, ".bootvolume.") {
|
||||
out[id] = deref(att.GetInstanceId())
|
||||
}
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// collectBootVolumeAttachments 把一个 AD + compartment 下 ATTACHED 状态的挂载写入映射。
|
||||
func collectBootVolumeAttachments(ctx context.Context, cc core.ComputeClient, ad string, compartmentID *string, out map[string]string) {
|
||||
var page *string
|
||||
for {
|
||||
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||
AvailabilityDomain: &ad,
|
||||
CompartmentId: compartmentID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, att := range resp.Items {
|
||||
if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached {
|
||||
out[deref(att.BootVolumeId)] = deref(att.InstanceId)
|
||||
}
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// GetBootVolume 实现 Client:查询引导卷并补全挂载的实例。
|
||||
func (c *RealClient) GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error) {
|
||||
bc, err := c.blockstorageClient(cred, region)
|
||||
if err != nil {
|
||||
return BootVolume{}, err
|
||||
}
|
||||
resp, err := bc.GetBootVolume(ctx, core.GetBootVolumeRequest{BootVolumeId: &bootVolumeID})
|
||||
if err != nil {
|
||||
return BootVolume{}, fmt.Errorf("get boot volume %s: %w", bootVolumeID, err)
|
||||
}
|
||||
volume := toBootVolume(resp.BootVolume)
|
||||
volume.AttachedInstanceID = c.bootVolumeAttachedInstance(ctx, cred, region, volume)
|
||||
if volume.ImageID != "" {
|
||||
if img, err := c.GetImage(ctx, cred, region, volume.ImageID); err == nil {
|
||||
volume.ImageName = img.DisplayName
|
||||
}
|
||||
}
|
||||
return volume, nil
|
||||
}
|
||||
|
||||
// bootVolumeAttachedInstance 查询引导卷当前挂载的实例;查询失败视为未挂载。
|
||||
func (c *RealClient) bootVolumeAttachedInstance(ctx context.Context, cred Credentials, region string, volume BootVolume) string {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// 挂载记录在引导卷所在 compartment,传租户根会漏掉子 compartment 的挂载
|
||||
resp, err := cc.ListBootVolumeAttachments(ctx, core.ListBootVolumeAttachmentsRequest{
|
||||
AvailabilityDomain: &volume.AvailabilityDomain,
|
||||
CompartmentId: hostCompartment(cred, volume.CompartmentID),
|
||||
BootVolumeId: &volume.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, att := range resp.Items {
|
||||
if att.LifecycleState == core.BootVolumeAttachmentLifecycleStateAttached {
|
||||
return deref(att.InstanceId)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UpdateBootVolume 实现 Client。
|
||||
func (c *RealClient) UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) {
|
||||
bc, err := c.blockstorageClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return BootVolume{}, err
|
||||
}
|
||||
details := core.UpdateBootVolumeDetails{}
|
||||
if in.DisplayName != "" {
|
||||
details.DisplayName = &in.DisplayName
|
||||
}
|
||||
if in.SizeInGBs > 0 {
|
||||
details.SizeInGBs = &in.SizeInGBs
|
||||
}
|
||||
if in.VpusPerGB > 0 {
|
||||
details.VpusPerGB = &in.VpusPerGB
|
||||
}
|
||||
resp, err := bc.UpdateBootVolume(ctx, core.UpdateBootVolumeRequest{
|
||||
BootVolumeId: &bootVolumeID,
|
||||
UpdateBootVolumeDetails: details,
|
||||
})
|
||||
if err != nil {
|
||||
return BootVolume{}, fmt.Errorf("update boot volume %s: %w", bootVolumeID, err)
|
||||
}
|
||||
return toBootVolume(resp.BootVolume), nil
|
||||
}
|
||||
|
||||
// DeleteBootVolume 实现 Client;仅未挂载(detached)的引导卷可删除。
|
||||
func (c *RealClient) DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error {
|
||||
bc, err := c.blockstorageClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bc.DeleteBootVolume(ctx, core.DeleteBootVolumeRequest{BootVolumeId: &bootVolumeID}); err != nil {
|
||||
return fmt.Errorf("delete boot volume %s: %w", bootVolumeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toBootVolume(v core.BootVolume) BootVolume {
|
||||
out := BootVolume{
|
||||
ID: deref(v.Id),
|
||||
DisplayName: deref(v.DisplayName),
|
||||
LifecycleState: string(v.LifecycleState),
|
||||
AvailabilityDomain: deref(v.AvailabilityDomain),
|
||||
CompartmentID: deref(v.CompartmentId),
|
||||
ImageID: deref(v.ImageId),
|
||||
TimeCreated: sdkTime(v.TimeCreated),
|
||||
}
|
||||
if v.SizeInGBs != nil {
|
||||
out.SizeInGBs = *v.SizeInGBs
|
||||
}
|
||||
if v.VpusPerGB != nil {
|
||||
out.VpusPerGB = *v.VpusPerGB
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/cache"
|
||||
)
|
||||
|
||||
// 读缓存 TTL 分级:过渡态资源短、准静态长(调研①方案 A)。
|
||||
// 实例列表 10s:前端 5s 自动刷新隔次命中,OCI 调用减半,状态延迟可控。
|
||||
const (
|
||||
cacheTTLInstances = 10 * time.Second
|
||||
cacheTTLVolatile = 30 * time.Second // 卷 / 挂载 / 网卡
|
||||
cacheTTLNetwork = 2 * time.Minute // VCN / 子网 / 安全列表
|
||||
cacheTTLStatic = 10 * time.Minute // 镜像 / 可用域
|
||||
)
|
||||
|
||||
// CachedClient 用进程内 TTL 缓存包装真实客户端,削减 OCI 读放大;
|
||||
// 写操作直通并失效同租户全部读缓存(粗粒度换正确性,重建仅一次回源)。
|
||||
// 未覆写的方法经嵌入接口直通。
|
||||
type CachedClient struct {
|
||||
Client
|
||||
store *cache.Cache
|
||||
}
|
||||
|
||||
// NewCachedClient 包装 inner;上限 4096 键(单键几 KB,整体几 MB 级)。
|
||||
func NewCachedClient(inner Client) *CachedClient {
|
||||
return &CachedClient{Client: inner, store: cache.New(4096)}
|
||||
}
|
||||
|
||||
// ckey 组缓存键;租户 OCID 在最前,写失效按前缀一锅端。
|
||||
func ckey(cred Credentials, parts ...string) string {
|
||||
return cred.TenancyOCID + "|" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// bust 写操作成功后失效该租户全部读缓存。
|
||||
func (c *CachedClient) bust(err error, cred Credentials) {
|
||||
if err == nil {
|
||||
c.store.DeletePrefix(cred.TenancyOCID + "|")
|
||||
}
|
||||
}
|
||||
|
||||
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
||||
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
|
||||
v, err := cache.Do(c.store, key, ttl, fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Clone(v), nil
|
||||
}
|
||||
|
||||
// ---- 读:按 TTL 分级缓存 ----
|
||||
|
||||
func (c *CachedClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||
return cachedList(c, ckey(cred, "instances", region), cacheTTLInstances, func() ([]Instance, error) {
|
||||
return c.Client.ListInstances(ctx, cred, region)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error) {
|
||||
return cachedList(c, ckey(cred, "images", q.Region, q.OperatingSystem, q.Shape), cacheTTLStatic, func() ([]Image, error) {
|
||||
return c.Client.ListImages(ctx, cred, q)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) {
|
||||
return cachedList(c, ckey(cred, "vcns", region), cacheTTLNetwork, func() ([]VCN, error) {
|
||||
return c.Client.ListVCNs(ctx, cred, region)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) {
|
||||
return cachedList(c, ckey(cred, "subnets", region, vcnID), cacheTTLNetwork, func() ([]Subnet, error) {
|
||||
return c.Client.ListSubnets(ctx, cred, region, vcnID)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) {
|
||||
return cachedList(c, ckey(cred, "seclists", region, vcnID), cacheTTLNetwork, func() ([]SecurityList, error) {
|
||||
return c.Client.ListSecurityLists(ctx, cred, region, vcnID)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||
return cachedList(c, ckey(cred, "ads", region), cacheTTLStatic, func() ([]string, error) {
|
||||
return c.Client.ListAvailabilityDomains(ctx, cred, region)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error) {
|
||||
return cachedList(c, ckey(cred, "bootvols", region, availabilityDomain), cacheTTLVolatile, func() ([]BootVolume, error) {
|
||||
return c.Client.ListBootVolumes(ctx, cred, region, availabilityDomain)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error) {
|
||||
return cachedList(c, ckey(cred, "blockvols", region, availabilityDomain), cacheTTLVolatile, func() ([]BlockVolume, error) {
|
||||
return c.Client.ListBlockVolumes(ctx, cred, region, availabilityDomain)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error) {
|
||||
return cachedList(c, ckey(cred, "vnics", region, instanceID), cacheTTLVolatile, func() ([]Vnic, error) {
|
||||
return c.Client.ListInstanceVnics(ctx, cred, region, instanceID)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error) {
|
||||
return cachedList(c, ckey(cred, "bvattach", region, instanceID), cacheTTLVolatile, func() ([]BootVolumeAttachment, error) {
|
||||
return c.Client.ListBootVolumeAttachments(ctx, cred, region, instanceID)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error) {
|
||||
return cachedList(c, ckey(cred, "volattach", region, instanceID), cacheTTLVolatile, func() ([]VolumeAttachment, error) {
|
||||
return c.Client.ListVolumeAttachments(ctx, cred, region, instanceID)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 写:直通,成功后按租户失效 ----
|
||||
|
||||
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||
out, err := c.Client.LaunchInstance(ctx, cred, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) {
|
||||
out, err := c.Client.UpdateInstance(ctx, cred, instanceID, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error {
|
||||
err := c.Client.TerminateInstance(ctx, cred, region, instanceID, preserveBootVolume)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) {
|
||||
out, err := c.Client.InstanceAction(ctx, cred, region, instanceID, action)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||
out, err := c.Client.ChangeInstancePublicIP(ctx, cred, region, instanceID)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) {
|
||||
out, err := c.Client.AddInstanceIpv6(ctx, cred, region, instanceID, address)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error {
|
||||
err := c.Client.DeleteInstanceIpv6(ctx, cred, region, instanceID, address)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) {
|
||||
out, err := c.Client.CreateVCN(ctx, cred, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) {
|
||||
out, err := c.Client.UpdateVCN(ctx, cred, region, vcnID, displayName)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error {
|
||||
err := c.Client.DeleteVCN(ctx, cred, region, vcnID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error) {
|
||||
out, err := c.Client.EnableVCNIPv6(ctx, cred, region, vcnID)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) {
|
||||
out, err := c.Client.CreateSubnet(ctx, cred, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) {
|
||||
out, err := c.Client.UpdateSubnet(ctx, cred, region, subnetID, displayName)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error {
|
||||
err := c.Client.DeleteSubnet(ctx, cred, region, subnetID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) {
|
||||
out, err := c.Client.CreateSecurityList(ctx, cred, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) {
|
||||
out, err := c.Client.UpdateSecurityList(ctx, cred, securityListID, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error {
|
||||
err := c.Client.DeleteSecurityList(ctx, cred, region, securityListID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error) {
|
||||
out, err := c.Client.UpdateBootVolume(ctx, cred, bootVolumeID, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DeleteBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) error {
|
||||
err := c.Client.DeleteBootVolume(ctx, cred, region, bootVolumeID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error) {
|
||||
out, err := c.Client.AttachBootVolume(ctx, cred, region, instanceID, bootVolumeID)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
err := c.Client.DetachBootVolume(ctx, cred, region, attachmentID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error) {
|
||||
out, err := c.Client.ReplaceBootVolume(ctx, cred, region, instanceID, newBootVolumeID)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error) {
|
||||
out, err := c.Client.AttachVolume(ctx, cred, region, instanceID, volumeID, readOnly)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
err := c.Client.DetachVolume(ctx, cred, region, attachmentID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error) {
|
||||
out, err := c.Client.AttachVnic(ctx, cred, region, instanceID, in)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *CachedClient) DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
err := c.Client.DetachVnic(ctx, cred, region, attachmentID)
|
||||
c.bust(err, cred)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *CachedClient) AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error) {
|
||||
out, err := c.Client.AddVnicIpv6(ctx, cred, region, vnicID, address)
|
||||
c.bust(err, cred)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// countingClient 只实现被测方法并计数;其余方法经嵌入 nil 接口(不被调用)。
|
||||
type countingClient struct {
|
||||
Client
|
||||
instCalls int
|
||||
adCalls int
|
||||
launches int
|
||||
}
|
||||
|
||||
func (f *countingClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||
f.instCalls++
|
||||
return []Instance{{ID: "i-1", DisplayName: "b"}, {ID: "i-2", DisplayName: "a"}}, nil
|
||||
}
|
||||
|
||||
func (f *countingClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||
f.adCalls++
|
||||
return []string{"ad-1"}, nil
|
||||
}
|
||||
|
||||
func (f *countingClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||
f.launches++
|
||||
return Instance{ID: "i-new"}, nil
|
||||
}
|
||||
|
||||
func testCred(tenancy string) Credentials { return Credentials{TenancyOCID: tenancy} }
|
||||
|
||||
func TestCachedClientHitAndIsolation(t *testing.T) {
|
||||
inner := &countingClient{}
|
||||
c := NewCachedClient(inner)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := c.ListInstances(ctx, testCred("t1"), "r1"); err != nil {
|
||||
t.Fatalf("ListInstances: %v", err)
|
||||
}
|
||||
}
|
||||
if inner.instCalls != 1 {
|
||||
t.Errorf("同 key 三连读回源 %d 次, want 1", inner.instCalls)
|
||||
}
|
||||
// 不同租户 / 区域各自回源
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r2")
|
||||
if inner.instCalls != 3 {
|
||||
t.Errorf("跨租户/区域回源 %d 次, want 3", inner.instCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedClientWriteBusts(t *testing.T) {
|
||||
inner := &countingClient{}
|
||||
c := NewCachedClient(inner)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListAvailabilityDomains(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
if _, err := c.LaunchInstance(ctx, testCred("t1"), CreateInstanceInput{}); err != nil {
|
||||
t.Fatalf("LaunchInstance: %v", err)
|
||||
}
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListAvailabilityDomains(ctx, testCred("t1"), "r1")
|
||||
if inner.instCalls != 3 || inner.adCalls != 2 {
|
||||
t.Errorf("写后同租户应全部回源: inst=%d want 3, ad=%d want 2", inner.instCalls, inner.adCalls)
|
||||
}
|
||||
// 其他租户缓存不受影响
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
if inner.instCalls != 3 {
|
||||
t.Errorf("t2 缓存被误失效: inst=%d want 3", inner.instCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedClientReturnsClone(t *testing.T) {
|
||||
inner := &countingClient{}
|
||||
c := NewCachedClient(inner)
|
||||
ctx := context.Background()
|
||||
|
||||
first, _ := c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
first[0], first[1] = first[1], first[0] // 调用方就地重排
|
||||
second, _ := c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
if second[0].ID != "i-1" {
|
||||
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Package oci 封装 Oracle Cloud Infrastructure 官方 Go SDK,
|
||||
// 对上层提供统一的客户端抽象;真实云请求只允许出现在本包。
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// cloudcmServiceName 是 OCI 云消费(Cloud Consumption Model)订阅的服务名,
|
||||
// 账户类别信息挂在该订阅之下。
|
||||
const cloudcmServiceName = "CLOUDCM"
|
||||
|
||||
// ErrNoCloudcmSubscription 表示租户下没有 serviceName=CLOUDCM 的订阅。
|
||||
var ErrNoCloudcmSubscription = errors.New("no CLOUDCM subscription found")
|
||||
|
||||
// TenancyInfo 是测活成功后取得的租户基本信息。
|
||||
type TenancyInfo struct {
|
||||
Name string
|
||||
HomeRegionKey string
|
||||
}
|
||||
|
||||
// Client 是真实 OCI 调用的统一入口,service 层只依赖此接口。
|
||||
type Client interface {
|
||||
// ValidateKey 用 Identity GetTenancy 验证凭据有效性(测活)。
|
||||
ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error)
|
||||
// FetchAccountProfile 查询 CLOUDCM 订阅并判定账户类别。
|
||||
FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error)
|
||||
// ListCompartments 递归列出租户下全部 ACTIVE compartment(不含租户根)。
|
||||
ListCompartments(ctx context.Context, cred Credentials) ([]Compartment, error)
|
||||
// ListRegionSubscriptions 列出租户已订阅的区域。
|
||||
ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error)
|
||||
// SubscribeRegion 为租户订阅新区域;请求发往 homeRegion,操作不可撤销。
|
||||
SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error
|
||||
// ListLimits 查询服务配额,name 为大小写不敏感的模糊过滤,按需附带用量。
|
||||
ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error)
|
||||
// ListLimitServices 列出配额体系支持的全部服务。
|
||||
ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error)
|
||||
// ListSubscriptions 列出租户全部订阅摘要。
|
||||
ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error)
|
||||
// GetSubscription 查询单个订阅的完整信息。
|
||||
GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error)
|
||||
// ListShapes 列出租户在目标区域可用的实例规格(带缓存)。
|
||||
ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error)
|
||||
// ListImages 列出可用的实例镜像。
|
||||
ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error)
|
||||
// GetImage 查询单个镜像。
|
||||
GetImage(ctx context.Context, cred Credentials, region, imageID string) (Image, error)
|
||||
// VCN 增删改查与一键 IPv6。
|
||||
ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error)
|
||||
CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error)
|
||||
GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error)
|
||||
UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error)
|
||||
DeleteVCN(ctx context.Context, cred Credentials, region, vcnID string) error
|
||||
EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error)
|
||||
// 子网增删改查。
|
||||
ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error)
|
||||
CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error)
|
||||
GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error)
|
||||
UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error)
|
||||
DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error
|
||||
// 安全列表增删改查。
|
||||
ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error)
|
||||
CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error)
|
||||
GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error)
|
||||
UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error)
|
||||
DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error
|
||||
// ListAvailabilityDomains 列出区域内可用域,创建实例的前置查询。
|
||||
ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error)
|
||||
// 实例增删改查与电源操作。
|
||||
ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error)
|
||||
LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error)
|
||||
GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error)
|
||||
UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error)
|
||||
TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error
|
||||
InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error)
|
||||
// 引导卷查删改;引导卷由创建实例产生,不单独创建。
|
||||
ListBootVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BootVolume, error)
|
||||
GetBootVolume(ctx context.Context, cred Credentials, region, bootVolumeID string) (BootVolume, error)
|
||||
UpdateBootVolume(ctx context.Context, cred Credentials, bootVolumeID string, in UpdateBootVolumeInput) (BootVolume, error)
|
||||
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)
|
||||
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。
|
||||
ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error)
|
||||
AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error)
|
||||
DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
||||
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
||||
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
||||
GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error)
|
||||
GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error)
|
||||
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
|
||||
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
||||
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
||||
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
||||
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error)
|
||||
DeleteConsoleConnection(ctx context.Context, cred Credentials, region, connectionID string) error
|
||||
// 引导卷挂载:列出、挂载、分离、替换。
|
||||
ListBootVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]BootVolumeAttachment, error)
|
||||
AttachBootVolume(ctx context.Context, cred Credentials, region, instanceID, bootVolumeID string) (BootVolumeAttachment, error)
|
||||
DetachBootVolume(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||
ReplaceBootVolume(ctx context.Context, cred Credentials, region, instanceID, newBootVolumeID string) (BootVolumeAttachment, error)
|
||||
// 块卷挂载:列出块卷、列出挂载、附加、分离。
|
||||
ListBlockVolumes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]BlockVolume, error)
|
||||
ListVolumeAttachments(ctx context.Context, cred Credentials, region, instanceID string) ([]VolumeAttachment, error)
|
||||
AttachVolume(ctx context.Context, cred Credentials, region, instanceID, volumeID string, readOnly bool) (VolumeAttachment, error)
|
||||
DetachVolume(ctx context.Context, cred Credentials, region, attachmentID string) error
|
||||
// 租户观测:实例流量统计、成本分析。
|
||||
SummarizeInstanceTraffic(ctx context.Context, cred Credentials, q TrafficQuery) (InstanceTraffic, error)
|
||||
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
|
||||
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
||||
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
||||
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
||||
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
|
||||
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
|
||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
|
||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
|
||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
|
||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
|
||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error)
|
||||
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||
// 域设置:通知收件人、密码策略、身份设置。
|
||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
|
||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
|
||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
|
||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
|
||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
||||
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
|
||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
|
||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
|
||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
|
||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
|
||||
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
||||
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
||||
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
||||
EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error)
|
||||
GetRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) (RelayResource, error)
|
||||
EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error)
|
||||
EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error)
|
||||
RelayState(ctx context.Context, cred Credentials, endpoint string) (RelayState, error)
|
||||
DeleteRelayConnector(ctx context.Context, cred Credentials, connectorID string) error
|
||||
DeleteRelayPolicy(ctx context.Context, cred Credentials, homeRegion, policyID string) error
|
||||
DeleteRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) error
|
||||
DeleteRelayTopic(ctx context.Context, cred Credentials, topicID string) error
|
||||
}
|
||||
|
||||
// RealClient 基于官方 SDK 实现 Client。
|
||||
type RealClient struct {
|
||||
limitDefs sync.Map // tenancy|region|service → limitDefEntry,配额定义预筛缓存
|
||||
shapes sync.Map // tenancy|region|ad → shapeCacheEntry,shape 清单缓存
|
||||
}
|
||||
|
||||
// NewClient 返回生产使用的真实客户端。
|
||||
func NewClient() *RealClient { return &RealClient{} }
|
||||
|
||||
func provider(cred Credentials) common.ConfigurationProvider {
|
||||
var pass *string
|
||||
if cred.Passphrase != "" {
|
||||
pass = &cred.Passphrase
|
||||
}
|
||||
return common.NewRawConfigurationProvider(
|
||||
cred.TenancyOCID, cred.UserOCID, cred.Region,
|
||||
cred.Fingerprint, cred.PrivateKey, pass,
|
||||
)
|
||||
}
|
||||
|
||||
// normalizeRegion 兼容区域短名:iad → us-ashburn-1;空值与全名原样返回。
|
||||
// 实例等资源对象上的 region 字段是短名,直接透传时借此归一。
|
||||
func normalizeRegion(region string) string {
|
||||
if region == "" {
|
||||
return ""
|
||||
}
|
||||
return string(common.StringToRegion(region))
|
||||
}
|
||||
|
||||
// ValidateKey 实现 Client。
|
||||
func (c *RealClient) ValidateKey(ctx context.Context, cred Credentials) (TenancyInfo, error) {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return TenancyInfo{}, fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
resp, err := ic.GetTenancy(ctx, identity.GetTenancyRequest{TenancyId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return TenancyInfo{}, fmt.Errorf("get tenancy: %w", err)
|
||||
}
|
||||
return TenancyInfo{
|
||||
Name: deref(resp.Name),
|
||||
HomeRegionKey: deref(resp.HomeRegionKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchAccountProfile 实现 Client:从订阅列表选中 CLOUDCM,
|
||||
// 再查询完整订阅并按付费模式、促销状态判定账户类别。
|
||||
func (c *RealClient) FetchAccountProfile(ctx context.Context, cred Credentials) (AccountProfile, error) {
|
||||
subs, err := c.ListSubscriptions(ctx, cred)
|
||||
if err != nil {
|
||||
return AccountProfile{}, err
|
||||
}
|
||||
subID := ""
|
||||
for _, sub := range subs {
|
||||
if strings.EqualFold(sub.ServiceName, cloudcmServiceName) {
|
||||
subID = sub.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if subID == "" {
|
||||
return AccountProfile{}, ErrNoCloudcmSubscription
|
||||
}
|
||||
detail, err := c.GetSubscription(ctx, cred, subID)
|
||||
if err != nil {
|
||||
return AccountProfile{}, err
|
||||
}
|
||||
return buildAccountProfile(detail), nil
|
||||
}
|
||||
|
||||
func buildAccountProfile(detail SubscriptionDetail) AccountProfile {
|
||||
p := AccountProfile{
|
||||
SubscriptionID: detail.ID,
|
||||
ServiceName: detail.ServiceName,
|
||||
PaymentModel: detail.PaymentModel,
|
||||
SubscriptionTier: detail.SubscriptionTier,
|
||||
}
|
||||
p.PromotionStatus, p.PromotionAmount, p.PromotionExpires = pickPromotion(detail.Promotions)
|
||||
p.AccountType = ClassifyAccount(p.PaymentModel, p.SubscriptionTier, p.PromotionStatus)
|
||||
return p
|
||||
}
|
||||
|
||||
// pickPromotion 从多条促销记录中选出最能代表当前状态的一条:
|
||||
// 优先 ACTIVE,其次 EXPIRED,否则回退第一条。
|
||||
func pickPromotion(promos []PromotionInfo) (string, float32, *time.Time) {
|
||||
best, bestRank := -1, -1
|
||||
for i, p := range promos {
|
||||
if rank := promotionRank(p.Status); rank > bestRank {
|
||||
best, bestRank = i, rank
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return "", 0, nil
|
||||
}
|
||||
p := promos[best]
|
||||
return p.Status, p.Amount, p.TimeExpired
|
||||
}
|
||||
|
||||
func promotionRank(status string) int {
|
||||
switch strings.ToUpper(status) {
|
||||
case "ACTIVE":
|
||||
return 2
|
||||
case "EXPIRED":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s != nil {
|
||||
return *s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// orEmpty 把 nil 切片归一为空切片:JSON 序列化输出 [] 而非 null,
|
||||
// 避免前端对 null 调用 .length / .map / .join 崩溃。
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
)
|
||||
|
||||
// Compartment 是租户下的一个 compartment(不含租户根本身)。
|
||||
type Compartment struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ParentID string `json:"parentId"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// ListCompartments 实现 Client:递归列出租户下全部 ACTIVE compartment。
|
||||
func (c *RealClient) ListCompartments(ctx context.Context, cred Credentials) ([]Compartment, error) {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
var items []Compartment
|
||||
var page *string
|
||||
for {
|
||||
resp, err := ic.ListCompartments(ctx, identity.ListCompartmentsRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
CompartmentIdInSubtree: common.Bool(true),
|
||||
AccessLevel: identity.ListCompartmentsAccessLevelAny,
|
||||
LifecycleState: identity.CompartmentLifecycleStateActive,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list compartments: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
items = append(items, toCompartment(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(items), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func toCompartment(item identity.Compartment) Compartment {
|
||||
out := Compartment{
|
||||
ID: deref(item.Id),
|
||||
Name: deref(item.Name),
|
||||
Description: deref(item.Description),
|
||||
ParentID: deref(item.CompartmentId),
|
||||
LifecycleState: string(item.LifecycleState),
|
||||
}
|
||||
if item.TimeCreated != nil {
|
||||
out.TimeCreated = &item.TimeCreated.Time
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// ConsoleConnection 是实例控制台连接。VNC 连接串在本地建立 SSH 隧道后,
|
||||
// 用 VNC 客户端连 localhost:5900 即可(串口控制台直接执行 ConnectionString)。
|
||||
type ConsoleConnection struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
ConnectionString string `json:"connectionString"` // 串口控制台 SSH 命令
|
||||
VncConnectionString string `json:"vncConnectionString"` // VNC over SSH 隧道命令
|
||||
}
|
||||
|
||||
// CreateConsoleConnection 实现 Client:为实例创建控制台连接并等待就绪,
|
||||
// 返回包含 VNC/串口连接串的对象;等待超时返回当前状态(可稍后再查列表)。
|
||||
func (c *RealClient) CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return ConsoleConnection{}, err
|
||||
}
|
||||
resp, err := cc.CreateInstanceConsoleConnection(ctx, core.CreateInstanceConsoleConnectionRequest{
|
||||
CreateInstanceConsoleConnectionDetails: core.CreateInstanceConsoleConnectionDetails{
|
||||
InstanceId: &instanceID,
|
||||
PublicKey: &sshPublicKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return ConsoleConnection{}, fmt.Errorf("create console connection: %w", err)
|
||||
}
|
||||
return c.waitConsoleConnectionActive(ctx, cc, deref(resp.Id))
|
||||
}
|
||||
|
||||
// waitConsoleConnectionActive 轮询等待控制台连接 ACTIVE,最长约 60 秒。
|
||||
func (c *RealClient) waitConsoleConnectionActive(ctx context.Context, cc core.ComputeClient, connectionID string) (ConsoleConnection, error) {
|
||||
var last ConsoleConnection
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := cc.GetInstanceConsoleConnection(ctx, core.GetInstanceConsoleConnectionRequest{
|
||||
InstanceConsoleConnectionId: &connectionID,
|
||||
})
|
||||
if err != nil {
|
||||
return last, fmt.Errorf("poll console connection: %w", err)
|
||||
}
|
||||
last = toConsoleConnection(resp.InstanceConsoleConnection)
|
||||
if last.LifecycleState == string(core.InstanceConsoleConnectionLifecycleStateActive) {
|
||||
return last, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return last, fmt.Errorf("poll console connection: %w", ctx.Err())
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
// ListConsoleConnections 实现 Client:列出实例的控制台连接。
|
||||
func (c *RealClient) ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID string) ([]ConsoleConnection, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 控制台连接记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
resp, err := cc.ListInstanceConsoleConnections(ctx, core.ListInstanceConsoleConnectionsRequest{
|
||||
CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)),
|
||||
InstanceId: &instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list console connections: %w", err)
|
||||
}
|
||||
connections := make([]ConsoleConnection, 0, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
connections = append(connections, toConsoleConnection(item))
|
||||
}
|
||||
return connections, nil
|
||||
}
|
||||
|
||||
// DeleteConsoleConnection 实现 Client:删除控制台连接。
|
||||
func (c *RealClient) DeleteConsoleConnection(ctx context.Context, cred Credentials, region, connectionID string) error {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cc.DeleteInstanceConsoleConnection(ctx, core.DeleteInstanceConsoleConnectionRequest{
|
||||
InstanceConsoleConnectionId: &connectionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete console connection %s: %w", connectionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toConsoleConnection(conn core.InstanceConsoleConnection) ConsoleConnection {
|
||||
return ConsoleConnection{
|
||||
ID: deref(conn.Id),
|
||||
InstanceID: deref(conn.InstanceId),
|
||||
LifecycleState: string(conn.LifecycleState),
|
||||
ConnectionString: deref(conn.ConnectionString),
|
||||
VncConnectionString: deref(conn.VncConnectionString),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/usageapi"
|
||||
)
|
||||
|
||||
// CostQuery 是一次成本/用量分析的条件。
|
||||
type CostQuery struct {
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Granularity string // DAILY / MONTHLY
|
||||
QueryType string // COST / USAGE
|
||||
GroupBy string // service / skuName / region 等维度
|
||||
}
|
||||
|
||||
// CostItem 是一个时间桶内某分组维度的成本或用量。
|
||||
type CostItem struct {
|
||||
TimeStart *time.Time `json:"timeStart"`
|
||||
GroupValue string `json:"groupValue"`
|
||||
ComputedAmount float32 `json:"computedAmount"`
|
||||
ComputedQuantity float32 `json:"computedQuantity"`
|
||||
Currency string `json:"currency"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// SummarizeCosts 实现 Client:调用 Usage API 汇总租户成本或用量。
|
||||
func (c *RealClient) SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error) {
|
||||
uc, err := usageapi.NewUsageapiClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new usageapi client: %w", err)
|
||||
}
|
||||
applyProxy(&uc.BaseClient, cred)
|
||||
details, err := buildUsageDetails(cred.TenancyOCID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []CostItem
|
||||
var page *string
|
||||
for {
|
||||
resp, err := uc.RequestSummarizedUsages(ctx, usageapi.RequestSummarizedUsagesRequest{
|
||||
RequestSummarizedUsagesDetails: details,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request summarized usages: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
items = append(items, toCostItem(item, q.GroupBy))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(items), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func buildUsageDetails(tenancyOCID string, q CostQuery) (usageapi.RequestSummarizedUsagesDetails, error) {
|
||||
granularity, ok := usageapi.GetMappingRequestSummarizedUsagesDetailsGranularityEnum(strings.ToUpper(q.Granularity))
|
||||
if !ok {
|
||||
return usageapi.RequestSummarizedUsagesDetails{}, fmt.Errorf("cost query: unsupported granularity %q", q.Granularity)
|
||||
}
|
||||
queryType, ok := usageapi.GetMappingRequestSummarizedUsagesDetailsQueryTypeEnum(strings.ToUpper(q.QueryType))
|
||||
if !ok {
|
||||
return usageapi.RequestSummarizedUsagesDetails{}, fmt.Errorf("cost query: unsupported queryType %q", q.QueryType)
|
||||
}
|
||||
return usageapi.RequestSummarizedUsagesDetails{
|
||||
TenantId: &tenancyOCID,
|
||||
TimeUsageStarted: &common.SDKTime{Time: q.StartTime},
|
||||
TimeUsageEnded: &common.SDKTime{Time: q.EndTime},
|
||||
Granularity: granularity,
|
||||
QueryType: queryType,
|
||||
GroupBy: []string{q.GroupBy},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toCostItem 把响应条目转为本地 DTO,分组值按 groupBy 维度取对应字段。
|
||||
func toCostItem(item usageapi.UsageSummary, groupBy string) CostItem {
|
||||
out := CostItem{
|
||||
Currency: deref(item.Currency),
|
||||
Unit: deref(item.Unit),
|
||||
}
|
||||
if item.TimeUsageStarted != nil {
|
||||
out.TimeStart = &item.TimeUsageStarted.Time
|
||||
}
|
||||
if item.ComputedAmount != nil {
|
||||
out.ComputedAmount = *item.ComputedAmount
|
||||
}
|
||||
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)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Credentials 是调用 OCI API 所需的完整凭据,私钥为 PEM 明文。
|
||||
// CompartmentID 限定资源操作的目标 compartment,空表示租户根;
|
||||
// 只影响 compute / 网络 / 块存储等资源查询,身份与订阅类调用始终走租户根。
|
||||
type Credentials struct {
|
||||
TenancyOCID string
|
||||
UserOCID string
|
||||
Region string
|
||||
Fingerprint string
|
||||
PrivateKey string
|
||||
Passphrase string
|
||||
CompartmentID string
|
||||
// Proxy 非 nil 时该租户全部 SDK 出站请求经此代理
|
||||
Proxy *ProxySpec
|
||||
}
|
||||
|
||||
// EffectiveCompartment 返回资源操作的目标 compartment,未指定时为租户根。
|
||||
func (c Credentials) EffectiveCompartment() *string {
|
||||
if c.CompartmentID != "" {
|
||||
id := c.CompartmentID
|
||||
return &id
|
||||
}
|
||||
id := c.TenancyOCID
|
||||
return &id
|
||||
}
|
||||
|
||||
// Validate 检查凭据必填字段。
|
||||
func (c Credentials) Validate() error {
|
||||
switch {
|
||||
case c.TenancyOCID == "":
|
||||
return fmt.Errorf("credentials: tenancy is empty")
|
||||
case c.UserOCID == "":
|
||||
return fmt.Errorf("credentials: user is empty")
|
||||
case c.Region == "":
|
||||
return fmt.Errorf("credentials: region is empty")
|
||||
case c.Fingerprint == "":
|
||||
return fmt.Errorf("credentials: fingerprint is empty")
|
||||
case !strings.Contains(c.PrivateKey, "PRIVATE KEY"):
|
||||
return fmt.Errorf("credentials: private key is not a PEM")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseConfigINI 解析 OCI 标准 config(ini)文本的 [DEFAULT] 段。
|
||||
// key_file 只在本机指向文件,导入场景私钥内容必须单独提供,因此忽略该行。
|
||||
func ParseConfigINI(text string) (Credentials, error) {
|
||||
var cred Credentials
|
||||
section := "DEFAULT"
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.TrimSpace(line[1 : len(line)-1])
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(section, "DEFAULT") {
|
||||
continue
|
||||
}
|
||||
applyConfigLine(&cred, line)
|
||||
}
|
||||
if cred.TenancyOCID == "" && cred.UserOCID == "" {
|
||||
return cred, fmt.Errorf("parse oci config: no recognizable fields")
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func applyConfigLine(cred *Credentials, line string) {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// OCID、指纹、region 值里不含 #,可安全去掉行尾注释
|
||||
if i := strings.Index(value, "#"); i >= 0 {
|
||||
value = value[:i]
|
||||
}
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "user":
|
||||
cred.UserOCID = value
|
||||
case "tenancy":
|
||||
cred.TenancyOCID = value
|
||||
case "fingerprint":
|
||||
cred.Fingerprint = value
|
||||
case "region":
|
||||
cred.Region = value
|
||||
case "pass_phrase":
|
||||
cred.Passphrase = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package oci
|
||||
|
||||
import "testing"
|
||||
|
||||
const sampleINI = `[DEFAULT]
|
||||
user=ocid1.user.oc1..aaaa
|
||||
fingerprint=e2:d4:5a:95
|
||||
tenancy=ocid1.tenancy.oc1..bbbb
|
||||
region=eu-frankfurt-1
|
||||
key_file=<path to your private keyfile> # TODO
|
||||
`
|
||||
|
||||
func TestParseConfigINI(t *testing.T) {
|
||||
cred, err := ParseConfigINI(sampleINI)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigINI: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
field string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{field: "UserOCID", got: cred.UserOCID, want: "ocid1.user.oc1..aaaa"},
|
||||
{field: "Fingerprint", got: cred.Fingerprint, want: "e2:d4:5a:95"},
|
||||
{field: "TenancyOCID", got: cred.TenancyOCID, want: "ocid1.tenancy.oc1..bbbb"},
|
||||
{field: "Region", got: cred.Region, want: "eu-frankfurt-1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %q, want %q", tt.field, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigINIIgnoresOtherSections(t *testing.T) {
|
||||
text := "[OTHER]\nuser=ocid1.user.oc1..other\n[DEFAULT]\nuser=ocid1.user.oc1..default\ntenancy=ocid1.tenancy.oc1..t\n"
|
||||
cred, err := ParseConfigINI(text)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfigINI: %v", err)
|
||||
}
|
||||
if got, want := cred.UserOCID, "ocid1.user.oc1..default"; got != want {
|
||||
t.Errorf("UserOCID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigINIEmpty(t *testing.T) {
|
||||
if _, err := ParseConfigINI("# only comments\n"); err == nil {
|
||||
t.Error("ParseConfigINI(comments only): got nil error, want failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsValidate(t *testing.T) {
|
||||
valid := Credentials{
|
||||
TenancyOCID: "t", UserOCID: "u", Region: "r", Fingerprint: "f",
|
||||
PrivateKey: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Credentials)
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "完整凭据", mutate: func(*Credentials) {}, wantErr: false},
|
||||
{name: "缺租户", mutate: func(c *Credentials) { c.TenancyOCID = "" }, wantErr: true},
|
||||
{name: "缺用户", mutate: func(c *Credentials) { c.UserOCID = "" }, wantErr: true},
|
||||
{name: "缺区域", mutate: func(c *Credentials) { c.Region = "" }, wantErr: true},
|
||||
{name: "缺指纹", mutate: func(c *Credentials) { c.Fingerprint = "" }, wantErr: true},
|
||||
{name: "私钥非 PEM", mutate: func(c *Credentials) { c.PrivateKey = "not a pem" }, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cred := valid
|
||||
tt.mutate(&cred)
|
||||
err := cred.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// notificationSettingID 是 Identity Domain 通知设置的固定资源 ID。
|
||||
const notificationSettingID = "NotificationSettings"
|
||||
|
||||
// NotificationRecipients 是域通知收件人设置:test mode 开启后
|
||||
// 域内全部通知只发给 recipients 列表。
|
||||
type NotificationRecipients struct {
|
||||
Recipients []string `json:"recipients"`
|
||||
TestModeEnabled bool `json:"testModeEnabled"`
|
||||
}
|
||||
|
||||
// PasswordPolicyInfo 是一条域密码策略的关键字段。
|
||||
type PasswordPolicyInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
PasswordStrength string `json:"passwordStrength"`
|
||||
PasswordExpiresAfter *int `json:"passwordExpiresAfter,omitempty"`
|
||||
MinLength *int `json:"minLength,omitempty"`
|
||||
NumPasswordsInHistory *int `json:"numPasswordsInHistory,omitempty"`
|
||||
}
|
||||
|
||||
// UpdatePasswordPolicyInput 是密码策略修改项;nil 字段不修改。
|
||||
// PasswordExpiresAfter 为 0 表示密码永不过期;
|
||||
// NumPasswordsInHistory 为 0 表示可复用以前的密码(不记历史)。
|
||||
type UpdatePasswordPolicyInput struct {
|
||||
PasswordExpiresAfter *int
|
||||
MinLength *int
|
||||
NumPasswordsInHistory *int
|
||||
}
|
||||
|
||||
// IdentitySettingInfo 是域身份设置里本面板关心的开关。
|
||||
type IdentitySettingInfo struct {
|
||||
ID string `json:"id"`
|
||||
// 用户需要提供主电子邮件地址:开启后创建用户 / JIT 预配必须带邮箱
|
||||
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||||
}
|
||||
|
||||
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
|
||||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
ic, err := c.identityClientAt(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list identity domains: %w", err)
|
||||
}
|
||||
url := pickDomainURL(resp.Items)
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("no active identity domain found")
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
|
||||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
if err != nil {
|
||||
return identitydomains.IdentityDomainsClient{}, err
|
||||
}
|
||||
dc, err := identitydomains.NewIdentityDomainsClientWithConfigurationProvider(provider(cred), url)
|
||||
if err != nil {
|
||||
return dc, fmt.Errorf("new identity domains client: %w", err)
|
||||
}
|
||||
applyProxy(&dc.BaseClient, cred)
|
||||
// IDCS 错误体是 SCIM 格式,改写后 SDK 才能解析出真实错误消息
|
||||
dc.HTTPClient = scimErrorDispatcher{base: dc.HTTPClient}
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||
func pickDomainURL(domains []identity.DomainSummary) string {
|
||||
fallback := ""
|
||||
for _, d := range domains {
|
||||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||||
continue
|
||||
}
|
||||
if deref(d.DisplayName) == "Default" {
|
||||
return deref(d.Url)
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = deref(d.Url)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
setting, err := getNotificationSetting(ctx, dc)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
return NotificationRecipients{
|
||||
Recipients: orEmpty(setting.TestRecipients),
|
||||
TestModeEnabled: setting.TestModeEnabled != nil && *setting.TestModeEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||||
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
setting, err := getNotificationSetting(ctx, dc)
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, err
|
||||
}
|
||||
enabled := len(emails) > 0
|
||||
setting.TestRecipients = emails
|
||||
setting.TestModeEnabled = &enabled
|
||||
id := notificationSettingID
|
||||
resp, err := dc.PutNotificationSetting(ctx, identitydomains.PutNotificationSettingRequest{
|
||||
NotificationSettingId: &id,
|
||||
NotificationSetting: setting,
|
||||
})
|
||||
if err != nil {
|
||||
return NotificationRecipients{}, fmt.Errorf("put notification setting: %w", err)
|
||||
}
|
||||
return NotificationRecipients{
|
||||
Recipients: orEmpty(resp.TestRecipients),
|
||||
TestModeEnabled: resp.TestModeEnabled != nil && *resp.TestModeEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.NotificationSetting, error) {
|
||||
id := notificationSettingID
|
||||
resp, err := dc.GetNotificationSetting(ctx, identitydomains.GetNotificationSettingRequest{
|
||||
NotificationSettingId: &id,
|
||||
})
|
||||
if err != nil {
|
||||
return identitydomains.NotificationSetting{}, fmt.Errorf("get notification setting: %w", err)
|
||||
}
|
||||
return resp.NotificationSetting, nil
|
||||
}
|
||||
|
||||
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := dc.ListPasswordPolicies(ctx, identitydomains.ListPasswordPoliciesRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list password policies: %w", err)
|
||||
}
|
||||
policies := make([]PasswordPolicyInfo, 0, len(resp.Resources))
|
||||
for _, p := range resp.Resources {
|
||||
policies = append(policies, toPasswordPolicyInfo(p))
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||||
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
||||
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||||
ops := buildPolicyPatchOps(in)
|
||||
if len(ops) == 0 {
|
||||
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return PasswordPolicyInfo{}, err
|
||||
}
|
||||
resp, err := dc.PatchPasswordPolicy(ctx, identitydomains.PatchPasswordPolicyRequest{
|
||||
PasswordPolicyId: &policyID,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: ops,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if isForbidden(err) {
|
||||
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: only Custom-strength policies are editable: %w", policyID, err)
|
||||
}
|
||||
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: %w", policyID, err)
|
||||
}
|
||||
return toPasswordPolicyInfo(resp.PasswordPolicy), nil
|
||||
}
|
||||
|
||||
// isForbidden 判断服务端返回 403(内置只读资源等场景)。
|
||||
func isForbidden(err error) bool {
|
||||
var svcErr common.ServiceError
|
||||
return errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 403
|
||||
}
|
||||
|
||||
func buildPolicyPatchOps(in UpdatePasswordPolicyInput) []identitydomains.Operations {
|
||||
var ops []identitydomains.Operations
|
||||
if in.PasswordExpiresAfter != nil {
|
||||
ops = append(ops, intOrRemoveOp("passwordExpiresAfter", *in.PasswordExpiresAfter))
|
||||
}
|
||||
if in.MinLength != nil {
|
||||
ops = append(ops, replaceOp("minLength", *in.MinLength))
|
||||
}
|
||||
if in.NumPasswordsInHistory != nil {
|
||||
ops = append(ops, intOrRemoveOp("numPasswordsInHistory", *in.NumPasswordsInHistory))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// intOrRemoveOp 值为 0 时移除属性(IDCS 语义 null = 永不过期 / 可复用密码;
|
||||
// numPasswordsInHistory 合法范围 1-500,replace 0 会被拒),否则 replace。
|
||||
func intOrRemoveOp(path string, v int) identitydomains.Operations {
|
||||
if v == 0 {
|
||||
return removeOp(path)
|
||||
}
|
||||
return replaceOp(path, v)
|
||||
}
|
||||
|
||||
func removeOp(path string) identitydomains.Operations {
|
||||
return identitydomains.Operations{
|
||||
Op: identitydomains.OperationsOpRemove,
|
||||
Path: &path,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
resp, err := dc.ListIdentitySettings(ctx, identitydomains.ListIdentitySettingsRequest{})
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, fmt.Errorf("list identity settings: %w", err)
|
||||
}
|
||||
if len(resp.Resources) == 0 {
|
||||
return IdentitySettingInfo{}, fmt.Errorf("identity settings resource not found")
|
||||
}
|
||||
s := resp.Resources[0]
|
||||
info := IdentitySettingInfo{ID: deref(s.Id)}
|
||||
if s.PrimaryEmailRequired != nil {
|
||||
info.PrimaryEmailRequired = *s.PrimaryEmailRequired
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||
current, err := c.GetIdentitySetting(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
resp, err := dc.PatchIdentitySetting(ctx, identitydomains.PatchIdentitySettingRequest{
|
||||
IdentitySettingId: ¤t.ID,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{replaceOp("primaryEmailRequired", primaryEmailRequired)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, fmt.Errorf("patch identity settings: %w", err)
|
||||
}
|
||||
info := IdentitySettingInfo{ID: deref(resp.IdentitySetting.Id)}
|
||||
if resp.IdentitySetting.PrimaryEmailRequired != nil {
|
||||
info.PrimaryEmailRequired = *resp.IdentitySetting.PrimaryEmailRequired
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func replaceOp(path string, value interface{}) identitydomains.Operations {
|
||||
return identitydomains.Operations{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: &path,
|
||||
Value: &value,
|
||||
}
|
||||
}
|
||||
|
||||
func toPasswordPolicyInfo(p identitydomains.PasswordPolicy) PasswordPolicyInfo {
|
||||
info := PasswordPolicyInfo{
|
||||
ID: deref(p.Id),
|
||||
Name: deref(p.Name),
|
||||
PasswordStrength: string(p.PasswordStrength),
|
||||
PasswordExpiresAfter: p.PasswordExpiresAfter,
|
||||
MinLength: p.MinLength,
|
||||
NumPasswordsInHistory: p.NumPasswordsInHistory,
|
||||
}
|
||||
if p.Priority != nil {
|
||||
info.Priority = *p.Priority
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
)
|
||||
|
||||
// ociErrorHints 把常见 OCI 错误码翻译成给用户看的中文提示。
|
||||
var ociErrorHints = map[string]string{
|
||||
"IncorrectState": "资源当前状态不允许该操作,通常是仍被其他资源占用或正在变更中",
|
||||
"NotAuthorizedOrNotFound": "无权限或资源不存在(可能已被删除)",
|
||||
"LimitExceeded": "已达到服务配额上限",
|
||||
"QuotaExceeded": "compartment 配额不足",
|
||||
"OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务",
|
||||
"TooManyRequests": "请求过于频繁,请稍后再试",
|
||||
"InvalidParameter": "请求参数无效",
|
||||
"InternalError": "OCI 服务内部错误,请稍后重试",
|
||||
"Conflict": "资源正在变更中,请稍后重试",
|
||||
}
|
||||
|
||||
// ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。
|
||||
var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`)
|
||||
|
||||
// shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」,
|
||||
// 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。
|
||||
func shortenOcids(s string) string {
|
||||
return ocidRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
i := strings.Index(m, ".")
|
||||
typ := m[i+1:]
|
||||
if j := strings.Index(typ, "."); j > 0 {
|
||||
typ = typ[:j]
|
||||
}
|
||||
return "ocid1." + typ + "…" + m[len(m)-6:]
|
||||
})
|
||||
}
|
||||
|
||||
// CompactError 把含 OCI ServiceError 的错误链提炼成短消息:
|
||||
// 操作前缀 + 服务端消息,剥掉 SDK 附加的错误码、排障链接、
|
||||
// 时间戳、SDK 版本等长文(错误码另经 API 的 ociCode 字段透出);
|
||||
// 消息中的长 OCID 压缩为类型加尾位的短形式。非 OCI 错误原样返回。
|
||||
func CompactError(err error) string {
|
||||
var svcErr common.ServiceError
|
||||
if !errors.As(err, &svcErr) {
|
||||
return shortenOcids(err.Error())
|
||||
}
|
||||
prefix := err.Error()
|
||||
if i := strings.Index(prefix, ": Error returned by"); i > 0 {
|
||||
prefix = prefix[:i] + ": "
|
||||
} else {
|
||||
prefix = ""
|
||||
}
|
||||
return shortenOcids(prefix + svcErr.GetMessage())
|
||||
}
|
||||
|
||||
// ErrorHint 返回常见 OCI 错误的中文提示;未识别返回空串。
|
||||
// 容量不足常以 code=InternalError 返回,按消息内容归到容量提示。
|
||||
func ErrorHint(err error) string {
|
||||
var svcErr common.ServiceError
|
||||
if !errors.As(err, &svcErr) {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(svcErr.GetMessage(), "Out of host capacity") {
|
||||
return ociErrorHints["OutOfHostCapacity"]
|
||||
}
|
||||
return ociErrorHints[svcErr.GetCode()]
|
||||
}
|
||||
|
||||
// ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。
|
||||
func ServiceStatus(err error) (int, bool) {
|
||||
var svcErr common.ServiceError
|
||||
if errors.As(err, &svcErr) {
|
||||
return svcErr.GetHTTPStatusCode(), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeServiceError 模拟 oci-go-sdk 的 common.ServiceError,
|
||||
// Error() 按 SDK 真实格式输出长文以验证截断。
|
||||
type fakeServiceError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e fakeServiceError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"Error returned by Compute Service. Http Status Code: %d. Error Code: %s. Message: %s. Troubleshooting Tips: See https://docs.oracle.com/ ...",
|
||||
e.status, e.code, e.message)
|
||||
}
|
||||
func (e fakeServiceError) GetHTTPStatusCode() int { return e.status }
|
||||
func (e fakeServiceError) GetMessage() string { return e.message }
|
||||
func (e fakeServiceError) GetCode() string { return e.code }
|
||||
func (e fakeServiceError) GetOpcRequestID() string { return "req-1" }
|
||||
|
||||
func TestCompactError(t *testing.T) {
|
||||
capacity := fakeServiceError{status: 500, code: "InternalError", message: "Out of host capacity."}
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "非 OCI 错误原样返回",
|
||||
err: errors.New("parse payload: unexpected end of JSON input"),
|
||||
want: "parse payload: unexpected end of JSON input",
|
||||
},
|
||||
{
|
||||
name: "带操作前缀的 OCI 错误保留前缀并剥掉长文",
|
||||
err: fmt.Errorf("launch instance: %w", capacity),
|
||||
want: "launch instance: Out of host capacity.",
|
||||
},
|
||||
{
|
||||
name: "多层包装保留完整前缀链",
|
||||
err: fmt.Errorf("snatch-1: %w", fmt.Errorf("launch instance: %w", capacity)),
|
||||
want: "snatch-1: launch instance: Out of host capacity.",
|
||||
},
|
||||
{
|
||||
name: "无前缀时只输出服务端消息",
|
||||
err: error(capacity),
|
||||
want: "Out of host capacity.",
|
||||
},
|
||||
{
|
||||
name: "消息中的长 OCID 压缩为类型加尾位",
|
||||
err: fmt.Errorf("update instance: %w", fakeServiceError{
|
||||
status: 500, code: "InternalError",
|
||||
message: "instance ocid1.instance.oc1.phx.anyhqljtp537sbqcr2f5ed2jwkglwyd3g6dfbmgtwdp4xdarxum5q7tdcyla: Out of host capacity.",
|
||||
}),
|
||||
want: "update instance: instance ocid1.instance…tdcyla: Out of host capacity.",
|
||||
},
|
||||
{
|
||||
name: "非 OCI 错误里的 OCID 同样压缩",
|
||||
err: errors.New("get instance ocid1.instance.oc1..aaaaaaaabbbbbbbbccccccccdddddddd failed"),
|
||||
want: "get instance ocid1.instance…dddddd failed",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := CompactError(tt.err); got != tt.want {
|
||||
t.Errorf("CompactError() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "非 OCI 错误无提示",
|
||||
err: errors.New("db locked"),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "已知错误码给出中文提示",
|
||||
err: fmt.Errorf("delete vcn: %w", fakeServiceError{409, "IncorrectState", "vcn in use"}),
|
||||
want: ociErrorHints["IncorrectState"],
|
||||
},
|
||||
{
|
||||
name: "InternalError 但消息为容量不足时归到容量提示",
|
||||
err: fmt.Errorf("launch: %w", fakeServiceError{500, "InternalError", "Out of host capacity."}),
|
||||
want: ociErrorHints["OutOfHostCapacity"],
|
||||
},
|
||||
{
|
||||
name: "未知错误码返回空串",
|
||||
err: fmt.Errorf("x: %w", fakeServiceError{400, "SomethingNew", "boom"}),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ErrorHint(tt.err); got != tt.want {
|
||||
t.Errorf("ErrorHint() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// defaultIdpRuleID 是 Default Identity Provider Policy 的预置规则固定 ID,
|
||||
// 其 return 中 SamlIDPs 数组决定登录页显示哪些 SAML IdP。
|
||||
const defaultIdpRuleID = "DefaultIDPRule"
|
||||
|
||||
// samlMetadataPath 是 Identity Domain 的 SP SAML 元数据端点。
|
||||
const samlMetadataPath = "/fed/v1/metadata"
|
||||
|
||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||
|
||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||
type IdentityProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PartnerProviderID string `json:"partnerProviderId"`
|
||||
JitEnabled bool `json:"jitEnabled"`
|
||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||
}
|
||||
|
||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||
type CreateIdpInput struct {
|
||||
Name string // partnerName,必填
|
||||
Metadata string // IdP metadata XML,必填
|
||||
Description string
|
||||
IconURL string // 登录页 logo,仅接受 http(s) 外链 URL(IDCS 拒绝 data URI),空则不设置
|
||||
|
||||
// 映射用户身份
|
||||
NameIDFormat string // 请求的名称 ID 格式,如 saml-none / saml-emailaddress
|
||||
AssertionAttribute string // 非空时改用断言属性映射,值为断言中的属性名;空用 NameID
|
||||
UserStoreAttribute string // 目标域用户属性:userName 或 emails.primary(主邮箱)
|
||||
|
||||
// JIT 预配
|
||||
JitEnabled bool
|
||||
JitCreate bool // JIT 时创建新用户
|
||||
JitUpdate bool // JIT 时更新既有用户属性
|
||||
JitAssignAdminGroup bool // 静态分配 Administrators 组
|
||||
JitMapEmail bool // 属性映射额外映射主邮箱(域要求邮箱必填时需开启)
|
||||
}
|
||||
|
||||
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := dc.ListIdentityProviders(ctx, identitydomains.ListIdentityProvidersRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identity providers: %w", err)
|
||||
}
|
||||
idps := make([]IdentityProviderInfo, 0, len(resp.Resources))
|
||||
for _, idp := range resp.Resources {
|
||||
if idp.Type != identitydomains.IdentityProviderTypeSaml {
|
||||
continue
|
||||
}
|
||||
idps = append(idps, toIdpInfo(idp))
|
||||
}
|
||||
return idps, nil
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
}
|
||||
resp, err := dc.CreateIdentityProvider(ctx, identitydomains.CreateIdentityProviderRequest{
|
||||
IdentityProvider: buildSamlIdp(in, adminGroup),
|
||||
})
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||
}
|
||||
if in.JitEnabled {
|
||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||
return toIdpInfo(resp.IdentityProvider), err
|
||||
}
|
||||
}
|
||||
return toIdpInfo(resp.IdentityProvider), nil
|
||||
}
|
||||
|
||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||
func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups) identitydomains.IdentityProvider {
|
||||
enabled := false
|
||||
boolFalse := false
|
||||
idp := identitydomains.IdentityProvider{
|
||||
Schemas: []string{idpSchema},
|
||||
PartnerName: &in.Name,
|
||||
Enabled: &enabled,
|
||||
Type: identitydomains.IdentityProviderTypeSaml,
|
||||
Metadata: &in.Metadata,
|
||||
NameIdFormat: &in.NameIDFormat,
|
||||
SignatureHashAlgorithm: identitydomains.IdentityProviderSignatureHashAlgorithm256,
|
||||
IncludeSigningCertInSignature: &boolFalse,
|
||||
UserMappingMethod: identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute,
|
||||
UserMappingStoreAttribute: &in.UserStoreAttribute,
|
||||
JitUserProvEnabled: &in.JitEnabled,
|
||||
JitUserProvCreateUserEnabled: &in.JitCreate,
|
||||
JitUserProvAttributeUpdateEnabled: &in.JitUpdate,
|
||||
JitUserProvGroupAssertionAttributeEnabled: &boolFalse,
|
||||
JitUserProvGroupStaticListEnabled: &in.JitAssignAdminGroup,
|
||||
JitUserProvGroupMappingMode: identitydomains.IdentityProviderJitUserProvGroupMappingModeImplicit,
|
||||
}
|
||||
if in.AssertionAttribute != "" {
|
||||
idp.UserMappingMethod = identitydomains.IdentityProviderUserMappingMethodAssertionattributetouserattribute
|
||||
idp.AssertionAttribute = &in.AssertionAttribute
|
||||
}
|
||||
if in.IconURL != "" {
|
||||
idp.IconUrl = &in.IconURL
|
||||
}
|
||||
if in.Description != "" {
|
||||
idp.Description = &in.Description
|
||||
}
|
||||
if adminGroup != nil {
|
||||
idp.JitUserProvAssignedGroups = []identitydomains.IdentityProviderJitUserProvAssignedGroups{*adminGroup}
|
||||
}
|
||||
return idp
|
||||
}
|
||||
|
||||
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
||||
count := 1
|
||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||
Filter: &filter,
|
||||
Attributes: common.String("id,displayName"),
|
||||
Count: &count,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find Administrators group: %w", err)
|
||||
}
|
||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
||||
}
|
||||
|
||||
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||
// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。
|
||||
func jitMappings(in CreateIdpInput) []interface{} {
|
||||
src := "$(assertion.fed.nameidvalue)"
|
||||
if in.AssertionAttribute != "" {
|
||||
src = "$(assertion." + in.AssertionAttribute + ")"
|
||||
}
|
||||
ms := []interface{}{
|
||||
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "userName"},
|
||||
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "name.familyName"},
|
||||
}
|
||||
if in.JitMapEmail {
|
||||
ms = append(ms, map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "emails[work].value"})
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
ref := idp.JitUserProvAttributes
|
||||
if ref == nil || ref.Value == nil {
|
||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload idp for jit attributes: %w", err)
|
||||
}
|
||||
ref = got.JitUserProvAttributes
|
||||
}
|
||||
if ref == nil || ref.Value == nil {
|
||||
return fmt.Errorf("idp %s has no jit attribute mapping resource", deref(idp.Id))
|
||||
}
|
||||
var value interface{} = mappings
|
||||
_, err := dc.PatchMappedAttribute(ctx, identitydomains.PatchMappedAttributeRequest{
|
||||
MappedAttributeId: ref.Value,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("attributeMappings"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch jit attribute mappings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
if !enabled {
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
}
|
||||
idp, err := patchIdpEnabled(ctx, dc, idpID, enabled)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
if enabled {
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, true); err != nil {
|
||||
return toIdpInfo(idp), err
|
||||
}
|
||||
}
|
||||
return toIdpInfo(idp), nil
|
||||
}
|
||||
|
||||
func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, enabled bool) (identitydomains.IdentityProvider, error) {
|
||||
var value interface{} = enabled
|
||||
resp, err := dc.PatchIdentityProvider(ctx, identitydomains.PatchIdentityProviderRequest{
|
||||
IdentityProviderId: &idpID,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("enabled"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return identitydomains.IdentityProvider{}, fmt.Errorf("patch idp %s enabled=%v: %w", idpID, enabled, err)
|
||||
}
|
||||
return resp.IdentityProvider, nil
|
||||
}
|
||||
|
||||
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := patchIdpEnabled(ctx, dc, idpID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dc.DeleteIdentityProvider(ctx, identitydomains.DeleteIdentityProviderRequest{
|
||||
IdentityProviderId: &idpID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete identity provider %s: %w", idpID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLoginPageIdps 修改预置 DefaultIDPRule 的 SamlIDPs 数组控制登录页显示。
|
||||
func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, show bool) error {
|
||||
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get default idp rule: %w", err)
|
||||
}
|
||||
returns, changed, err := rebuildSamlIdpsReturn(rule.Return, idpID, show)
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
var value interface{} = returns
|
||||
_, err = dc.PatchRule(ctx, identitydomains.PatchRuleRequest{
|
||||
RuleId: common.String(defaultIdpRuleID),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("return"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch default idp rule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||
returns := make([]interface{}, 0, len(items))
|
||||
changed := false
|
||||
for _, item := range items {
|
||||
name, value := deref(item.Name), deref(item.Value)
|
||||
if name == "SamlIDPs" {
|
||||
next, ok, err := toggleJSONList(value, idpID, show)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||
}
|
||||
value, changed = next, ok
|
||||
}
|
||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
return returns, changed, nil
|
||||
}
|
||||
|
||||
// toggleJSONList 在 JSON 数组字符串中添加或移除一个元素,返回是否有变化。
|
||||
func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
||||
var items []string
|
||||
if list != "" {
|
||||
if err := json.Unmarshal([]byte(list), &items); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
next := make([]string, 0, len(items)+1)
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it == item {
|
||||
found = true
|
||||
if !add {
|
||||
continue
|
||||
}
|
||||
}
|
||||
next = append(next, it)
|
||||
}
|
||||
if add && !found {
|
||||
next = append(next, item)
|
||||
}
|
||||
if add == found {
|
||||
return list, false, nil
|
||||
}
|
||||
b, err := json.Marshal(next)
|
||||
return string(b), true, err
|
||||
}
|
||||
|
||||
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := fetchSamlMetadata(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
return body, nil
|
||||
}
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("download saml metadata: HTTP %d: %s", status, truncate(string(body), 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var value interface{} = true
|
||||
_, err = dc.PatchSetting(ctx, identitydomains.PatchSettingRequest{
|
||||
SettingId: common.String("Settings"),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("signingCertPublicAccess"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable signing cert public access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func toIdpInfo(idp identitydomains.IdentityProvider) IdentityProviderInfo {
|
||||
info := IdentityProviderInfo{
|
||||
ID: deref(idp.Id),
|
||||
Name: deref(idp.PartnerName),
|
||||
Type: string(idp.Type),
|
||||
Enabled: idp.Enabled != nil && *idp.Enabled,
|
||||
PartnerProviderID: deref(idp.PartnerProviderId),
|
||||
JitEnabled: idp.JitUserProvEnabled != nil && *idp.JitUserProvEnabled,
|
||||
}
|
||||
if idp.Meta != nil && idp.Meta.Created != nil {
|
||||
if t, err := time.Parse(time.RFC3339, *idp.Meta.Created); err == nil {
|
||||
info.TimeCreated = &t
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// GenAiModel 是区域可用基础模型的摘要(管理面 ListModels)。
|
||||
type GenAiModel struct {
|
||||
Ocid string `json:"ocid"`
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
ChatOnly bool `json:"-"`
|
||||
Caps []string `json:"capabilities"`
|
||||
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
||||
Capability string `json:"capability"`
|
||||
// Deprecated 是 OCI 宣布的弃用时间(TimeDeprecated),nil 表示未宣布;
|
||||
// 弃用后模型仍可调用,直到 Retired(TimeOnDemandRetired 按需推理退役)。
|
||||
Deprecated *time.Time `json:"deprecated"`
|
||||
// Retired 是按需推理退役时间;已过表示调用必 404,同步层直接剔除
|
||||
Retired *time.Time `json:"retired"`
|
||||
}
|
||||
|
||||
// GenAiStream 逐事件读取流式聊天;Next 在流结束时返回 io.EOF。
|
||||
type GenAiStream interface {
|
||||
Next() (aiwire.ChatChunk, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
||||
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return gc, fmt.Errorf("new generative ai client: %w", err)
|
||||
}
|
||||
applyProxy(&gc.BaseClient, cred)
|
||||
if region != "" {
|
||||
gc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (c *RealClient) genAiInferenceClient(cred Credentials, region string) (generativeaiinference.GenerativeAiInferenceClient, error) {
|
||||
ic, err := generativeaiinference.NewGenerativeAiInferenceClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return ic, fmt.Errorf("new generative ai inference client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
if region != "" {
|
||||
ic.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return ic, nil
|
||||
}
|
||||
|
||||
// ListGenAiModels 实现 Client:列出区域的 CHAT / EMBEDDING 能力 ACTIVE 基础模型(去重按名称)。
|
||||
func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error) {
|
||||
gc, err := c.genAiClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := gc.ListModels(ctx, generativeai.ListModelsRequest{CompartmentId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list genai models: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
now := time.Now()
|
||||
var out []GenAiModel
|
||||
for _, m := range resp.Items {
|
||||
gm, ok := toGenAiModel(m, now)
|
||||
if !ok || seen[gm.Name] {
|
||||
continue
|
||||
}
|
||||
seen[gm.Name] = true
|
||||
out = append(out, gm)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
||||
// (调用必 404,ListModels 仍会返回且 state 为 ACTIVE)时返回 false 不入池。
|
||||
func toGenAiModel(m generativeai.ModelSummary, now time.Time) (GenAiModel, bool) {
|
||||
capability := modelCapability(m)
|
||||
name := deref(m.DisplayName)
|
||||
if capability == "" || name == "" {
|
||||
return GenAiModel{}, false
|
||||
}
|
||||
if m.TimeOnDemandRetired != nil && m.TimeOnDemandRetired.Time.Before(now) {
|
||||
return GenAiModel{}, false
|
||||
}
|
||||
gm := GenAiModel{Ocid: deref(m.Id), Name: name, Vendor: deref(m.Vendor),
|
||||
Caps: capStrings(m.Capabilities), Capability: capability}
|
||||
if m.TimeDeprecated != nil {
|
||||
gm.Deprecated = &m.TimeDeprecated.Time
|
||||
}
|
||||
if m.TimeOnDemandRetired != nil {
|
||||
gm.Retired = &m.TimeOnDemandRetired.Time
|
||||
}
|
||||
return gm, true
|
||||
}
|
||||
|
||||
// modelCapability 归一模型能力:ACTIVE 且具 CHAT / TEXT_EMBEDDINGS 才纳入(兼具时算 CHAT)。
|
||||
func modelCapability(m generativeai.ModelSummary) string {
|
||||
if m.LifecycleState != generativeai.ModelLifecycleStateActive {
|
||||
return ""
|
||||
}
|
||||
capability := ""
|
||||
for _, cap := range m.Capabilities {
|
||||
switch cap {
|
||||
case generativeai.ModelCapabilityChat:
|
||||
return "CHAT"
|
||||
case generativeai.ModelCapabilityTextEmbeddings:
|
||||
capability = "EMBEDDING"
|
||||
}
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
||||
out := make([]string, 0, len(caps))
|
||||
for _, c := range caps {
|
||||
out = append(out, string(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
|
||||
// cohere.* 模型走 COHERE 请求格式,其余走 GENERIC。
|
||||
func (c *RealClient) GenAiChat(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (*aiwire.ChatResponse, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := buildChatRequest(ir, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat: %w", err)
|
||||
}
|
||||
return chatResponseToIR(resp.ChatResult.ChatResponse, ir.Model)
|
||||
}
|
||||
|
||||
// buildChatRequest 按模型 vendor 组装底层请求体。
|
||||
func buildChatRequest(ir aiwire.ChatRequest, stream bool) (generativeaiinference.BaseChatRequest, error) {
|
||||
if isCohereModel(ir.Model) {
|
||||
return irToCohereSDK(ir, stream)
|
||||
}
|
||||
return irToSDK(ir, stream), nil
|
||||
}
|
||||
|
||||
// chatResponseToIR 按响应实际形态(GENERIC / COHERE)转回 IR。
|
||||
func chatResponseToIR(resp generativeaiinference.BaseChatResponse, model string) (*aiwire.ChatResponse, error) {
|
||||
switch r := resp.(type) {
|
||||
case generativeaiinference.GenericChatResponse:
|
||||
return sdkToIR(r, model), nil
|
||||
case generativeaiinference.CohereChatResponse:
|
||||
return cohereSDKToIR(r, model), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("genai chat: unexpected response format %T", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func chatRequest(cred Credentials, modelOcid string, req generativeaiinference.BaseChatRequest) generativeaiinference.ChatRequest {
|
||||
return generativeaiinference.ChatRequest{
|
||||
ChatDetails: generativeaiinference.ChatDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||
ChatRequest: req,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenAiChatStream 实现 Client:流式聊天,返回逐事件读取器。
|
||||
// SDK 对 text/event-stream 跳过 unmarshal,原始流经 RawResponse 交由 SSEReader 消费。
|
||||
func (c *RealClient) GenAiChatStream(ctx context.Context, cred Credentials, region, modelOcid string, ir aiwire.ChatRequest) (GenAiStream, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := buildChatRequest(ir, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ic.Chat(ctx, chatRequest(cred, modelOcid, req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat stream: %w", err)
|
||||
}
|
||||
reader, err := common.NewSSEReader(resp.RawResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genai chat stream: sse reader: %w", err)
|
||||
}
|
||||
return &genAiSSEStream{reader: reader, body: resp.RawResponse.Body, model: ir.Model}, nil
|
||||
}
|
||||
|
||||
// genAiSSEStream 把 OCI SSE 事件流适配为 IR chunk 流。
|
||||
type genAiSSEStream struct {
|
||||
reader *common.SseReader
|
||||
body io.ReadCloser
|
||||
model string
|
||||
}
|
||||
|
||||
// Next 读取下一条有效事件;空事件与 [DONE] 哨兵跳过,流尽返回 io.EOF。
|
||||
func (s *genAiSSEStream) Next() (aiwire.ChatChunk, error) {
|
||||
for {
|
||||
data, err := s.reader.ReadNextEvent()
|
||||
if err != nil {
|
||||
return aiwire.ChatChunk{}, err
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if text == "[DONE]" {
|
||||
return aiwire.ChatChunk{}, io.EOF
|
||||
}
|
||||
if chunk, ok := parseGenAiEvent([]byte(text), s.model); ok {
|
||||
return chunk, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *genAiSSEStream) Close() error {
|
||||
if s.body != nil {
|
||||
return s.body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenAiProbeChat 实现 Client:配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||
one := 1
|
||||
ir := aiwire.ChatRequest{
|
||||
Model: modelName,
|
||||
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
|
||||
MaxTokens: &one,
|
||||
}
|
||||
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
|
||||
if err == nil {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
if status, ok := ServiceStatus(err); ok {
|
||||
return status, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// GenAiEmbed 实现 Client:文本向量化(on-demand serving);dimensions 透传 OutputDimensions。
|
||||
func (c *RealClient) GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
|
||||
ic, err := c.genAiInferenceClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := ic.EmbedText(ctx, generativeaiinference.EmbedTextRequest{
|
||||
EmbedTextDetails: generativeaiinference.EmbedTextDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
ServingMode: generativeaiinference.OnDemandServingMode{ModelId: &modelOcid},
|
||||
Inputs: inputs,
|
||||
OutputDimensions: dimensions,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("genai embed: %w", err)
|
||||
}
|
||||
return resp.Embeddings, sdkUsageToIR(resp.Usage), nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// isCohereModel 依据模型名前缀判定 COHERE 系(走专属请求格式)。
|
||||
func isCohereModel(name string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(name), "cohere.")
|
||||
}
|
||||
|
||||
// irToCohereSDK 把 IR 转为 COHERE 聊天请求;工具与结构化输出做有损降级
|
||||
// (参数仅取 JSON Schema 顶层 properties,tool_choice / json_schema 的 name、strict 无对应)。
|
||||
func irToCohereSDK(ir aiwire.ChatRequest, stream bool) (generativeaiinference.CohereChatRequest, error) {
|
||||
var req generativeaiinference.CohereChatRequest
|
||||
if err := cohereRejectUnsupported(ir); err != nil {
|
||||
return req, err
|
||||
}
|
||||
p, err := cohereSplitMessages(ir.Messages)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req = generativeaiinference.CohereChatRequest{
|
||||
Message: &p.message,
|
||||
ChatHistory: p.history,
|
||||
ToolResults: p.toolResults,
|
||||
Tools: cohereTools(ir.Tools),
|
||||
ResponseFormat: cohereResponseFormat(ir.ResponseFormat),
|
||||
MaxTokens: ir.MaxTokens,
|
||||
Temperature: ir.Temperature,
|
||||
TopP: ir.TopP,
|
||||
TopK: ir.TopK,
|
||||
FrequencyPenalty: ir.FrequencyPenalty,
|
||||
PresencePenalty: ir.PresencePenalty,
|
||||
Seed: ir.Seed,
|
||||
}
|
||||
if p.preamble != "" {
|
||||
req.PreambleOverride = &p.preamble
|
||||
}
|
||||
if len(ir.Stop) > 0 {
|
||||
req.StopSequences = ir.Stop
|
||||
}
|
||||
if stream {
|
||||
req.IsStream = &stream
|
||||
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
||||
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// cohereRejectUnsupported 拒绝 COHERE 无法承接的能力(多模态图片)。
|
||||
func cohereRejectUnsupported(ir aiwire.ChatRequest) error {
|
||||
for _, m := range ir.Messages {
|
||||
for _, p := range m.Content.Parts {
|
||||
if p.Type == "image_url" {
|
||||
return fmt.Errorf("cohere 系模型暂不支持图片输入,请改用 meta/google 等多模态模型")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cohereParts 是 IR 消息拆装为 COHERE 请求的中间结果。
|
||||
type cohereParts struct {
|
||||
message string
|
||||
history []generativeaiinference.CohereMessage
|
||||
preamble string
|
||||
toolResults []generativeaiinference.CohereToolResult
|
||||
}
|
||||
|
||||
// cohereSplitMessages 拆装消息:system 拼 preamble,末尾 user 抽为 message,
|
||||
// assistant(含 toolCalls)与其余 user 进 history,tool 结果转顶层 toolResults
|
||||
// (工具结果在末尾时 message 留空,COHERE 以 toolResults 续跑)。
|
||||
func cohereSplitMessages(msgs []aiwire.ChatMessage) (cohereParts, error) {
|
||||
lastUser, lastTool := -1, -1
|
||||
for i, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
lastUser = i
|
||||
case "tool":
|
||||
lastTool = i
|
||||
}
|
||||
}
|
||||
if lastUser == -1 && lastTool == -1 {
|
||||
return cohereParts{}, fmt.Errorf("cohere 系模型至少需要一条 user 消息")
|
||||
}
|
||||
var p cohereParts
|
||||
var preamble []string
|
||||
calls := map[string]generativeaiinference.CohereToolCall{}
|
||||
for i, m := range msgs {
|
||||
text := m.Content.JoinText()
|
||||
switch m.Role {
|
||||
case "system", "developer":
|
||||
preamble = append(preamble, text)
|
||||
case "assistant":
|
||||
p.history = append(p.history, cohereBotMessage(text, m.ToolCalls, calls))
|
||||
case "tool":
|
||||
p.toolResults = append(p.toolResults, cohereToolResult(m, calls))
|
||||
default: // user
|
||||
if i == lastUser && lastUser > lastTool {
|
||||
p.message = text
|
||||
continue
|
||||
}
|
||||
p.history = append(p.history, generativeaiinference.CohereUserMessage{Message: &text})
|
||||
}
|
||||
}
|
||||
p.preamble = strings.Join(preamble, "\n")
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// cohereBotMessage 转 assistant 消息;toolCalls 同步登记进 id→call 映射供 tool 结果回查。
|
||||
func cohereBotMessage(text string, tcs []aiwire.ToolCall, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereChatBotMessage {
|
||||
msg := generativeaiinference.CohereChatBotMessage{}
|
||||
if text != "" {
|
||||
msg.Message = &text
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
name := tc.Function.Name
|
||||
var params interface{}
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), ¶ms) != nil || params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
||||
calls[tc.ID] = call
|
||||
msg.ToolCalls = append(msg.ToolCalls, call)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// cohereToolResult 把 IR tool 消息转顶层工具结果;COHERE 工具调用无 id,
|
||||
// 靠 assistant 历史登记的映射回查,缺失时以 tool_call_id 名义调用兜底。
|
||||
func cohereToolResult(m aiwire.ChatMessage, calls map[string]generativeaiinference.CohereToolCall) generativeaiinference.CohereToolResult {
|
||||
call, ok := calls[m.ToolCallID]
|
||||
if !ok {
|
||||
name := m.ToolCallID
|
||||
var params interface{} = map[string]any{}
|
||||
call = generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
||||
}
|
||||
text := m.Content.JoinText()
|
||||
var output interface{}
|
||||
if json.Unmarshal([]byte(text), &output) != nil || output == nil {
|
||||
output = map[string]any{"output": text}
|
||||
}
|
||||
if arr, isArr := output.([]interface{}); isArr {
|
||||
return generativeaiinference.CohereToolResult{Call: &call, Outputs: arr}
|
||||
}
|
||||
if _, isMap := output.(map[string]interface{}); !isMap {
|
||||
output = map[string]any{"output": output}
|
||||
}
|
||||
return generativeaiinference.CohereToolResult{Call: &call, Outputs: []interface{}{output}}
|
||||
}
|
||||
|
||||
// cohereTools 把 JSON Schema 工具定义降级为 COHERE 扁平参数表(嵌套结构有损:仅取顶层)。
|
||||
func cohereTools(tools []aiwire.Tool) []generativeaiinference.CohereTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]generativeaiinference.CohereTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
name, desc := t.Function.Name, t.Function.Description
|
||||
if desc == "" {
|
||||
desc = name // Description 为 COHERE 必填
|
||||
}
|
||||
out = append(out, generativeaiinference.CohereTool{
|
||||
Name: &name, Description: &desc,
|
||||
ParameterDefinitions: cohereParams(t.Function.Parameters),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cohereParams 取 JSON Schema 顶层 properties 转扁平参数定义;嵌套 schema 只保留类型名。
|
||||
func cohereParams(schema json.RawMessage) map[string]generativeaiinference.CohereParameterDefinition {
|
||||
var s struct {
|
||||
Properties map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
} `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if json.Unmarshal(schema, &s) != nil || len(s.Properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
req := map[string]bool{}
|
||||
for _, r := range s.Required {
|
||||
req[r] = true
|
||||
}
|
||||
out := make(map[string]generativeaiinference.CohereParameterDefinition, len(s.Properties))
|
||||
for k, v := range s.Properties {
|
||||
typ, desc := v.Type, v.Description
|
||||
if typ == "" {
|
||||
typ = "string"
|
||||
}
|
||||
pd := generativeaiinference.CohereParameterDefinition{Type: &typ}
|
||||
if desc != "" {
|
||||
pd.Description = &desc
|
||||
}
|
||||
if req[k] {
|
||||
t := true
|
||||
pd.IsRequired = &t
|
||||
}
|
||||
out[k] = pd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cohereResponseFormat 映射 json_object / json_schema(name、strict 无对应,有损降级)。
|
||||
func cohereResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.CohereResponseFormat {
|
||||
if rf == nil {
|
||||
return nil
|
||||
}
|
||||
switch rf.Type {
|
||||
case "json_object", "json_schema":
|
||||
out := generativeaiinference.CohereResponseJsonFormat{}
|
||||
var in struct {
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
}
|
||||
if json.Unmarshal(rf.JSONSchema, &in) == nil && len(in.Schema) > 0 {
|
||||
var schema interface{}
|
||||
if json.Unmarshal(in.Schema, &schema) == nil {
|
||||
out.Schema = &schema
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cohereSDKToIR 把 COHERE 非流式响应转回 IR;工具调用补派生 ID,存在时结束原因归 tool_calls。
|
||||
func cohereSDKToIR(resp generativeaiinference.CohereChatResponse, model string) *aiwire.ChatResponse {
|
||||
msg := aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent(deref(resp.Text))}
|
||||
for i, tc := range resp.ToolCalls {
|
||||
msg.ToolCalls = append(msg.ToolCalls, cohereCallToIR(deref(tc.Name), tc.Parameters, i))
|
||||
}
|
||||
finish := mapFinishReason(string(resp.FinishReason))
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
finish = "tool_calls"
|
||||
}
|
||||
return &aiwire.ChatResponse{
|
||||
Object: "chat.completion",
|
||||
Model: model,
|
||||
Choices: []aiwire.Choice{{Message: msg, FinishReason: finish}},
|
||||
Usage: sdkUsageToIR(resp.Usage),
|
||||
}
|
||||
}
|
||||
|
||||
// cohereCallToIR 生成 IR 工具调用;COHERE 无调用 id,按名称+序号派生(回传时按此回查)。
|
||||
func cohereCallToIR(name string, params *interface{}, idx int) aiwire.ToolCall {
|
||||
args := "{}"
|
||||
if params != nil && *params != nil {
|
||||
if b, err := json.Marshal(*params); err == nil {
|
||||
args = string(b)
|
||||
}
|
||||
}
|
||||
return aiwire.ToolCall{
|
||||
ID: fmt.Sprintf("call_%s_%d", name, idx),
|
||||
Type: "function",
|
||||
Function: aiwire.FunctionCall{Name: name, Arguments: args},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// irToSDK 把 IR(OpenAI 线格式)转为 OCI GENERIC 聊天请求;字段近一一镜像。
|
||||
func irToSDK(ir aiwire.ChatRequest, stream bool) generativeaiinference.GenericChatRequest {
|
||||
req := generativeaiinference.GenericChatRequest{
|
||||
Messages: irMessages(ir.Messages),
|
||||
MaxTokens: ir.MaxTokens,
|
||||
MaxCompletionTokens: ir.MaxCompletionTokens,
|
||||
Temperature: ir.Temperature,
|
||||
TopP: ir.TopP,
|
||||
TopK: ir.TopK,
|
||||
FrequencyPenalty: ir.FrequencyPenalty,
|
||||
PresencePenalty: ir.PresencePenalty,
|
||||
Seed: ir.Seed,
|
||||
NumGenerations: ir.N,
|
||||
Tools: irTools(ir.Tools),
|
||||
ToolChoice: irToolChoice(ir.ToolChoice),
|
||||
ResponseFormat: irResponseFormat(ir.ResponseFormat),
|
||||
}
|
||||
if len(ir.Stop) > 0 {
|
||||
req.Stop = ir.Stop
|
||||
}
|
||||
if ir.ReasoningEffort != "" {
|
||||
req.ReasoningEffort = generativeaiinference.GenericChatRequestReasoningEffortEnum(strings.ToUpper(ir.ReasoningEffort))
|
||||
}
|
||||
if stream {
|
||||
req.IsStream = &stream
|
||||
includeUsage := ir.StreamOptions == nil || ir.StreamOptions.IncludeUsage
|
||||
req.StreamOptions = &generativeaiinference.StreamOptions{IsIncludeUsage: &includeUsage}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// irMessages 按 role 拆装消息;user 消息保留图文块,其余角色只取文本。
|
||||
func irMessages(msgs []aiwire.ChatMessage) []generativeaiinference.Message {
|
||||
out := make([]generativeaiinference.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "system", "developer":
|
||||
out = append(out, generativeaiinference.SystemMessage{Content: textContents(m.Content.JoinText())})
|
||||
case "assistant":
|
||||
out = append(out, generativeaiinference.AssistantMessage{
|
||||
Content: textContents(m.Content.JoinText()),
|
||||
ToolCalls: irToolCalls(m.ToolCalls),
|
||||
})
|
||||
case "tool":
|
||||
tm := generativeaiinference.ToolMessage{Content: textContents(m.Content.JoinText())}
|
||||
if m.ToolCallID != "" {
|
||||
id := m.ToolCallID
|
||||
tm.ToolCallId = &id
|
||||
}
|
||||
out = append(out, tm)
|
||||
default: // user
|
||||
out = append(out, generativeaiinference.UserMessage{Content: chatContents(m.Content)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatContents 把 IR 内容转 SDK 内容块;文本直通,image_url 转 IMAGE 块(data URI / 公网地址)。
|
||||
func chatContents(c aiwire.Content) []generativeaiinference.ChatContent {
|
||||
if len(c.Parts) == 0 {
|
||||
return textContents(c.Text)
|
||||
}
|
||||
var out []generativeaiinference.ChatContent
|
||||
for _, p := range c.Parts {
|
||||
switch {
|
||||
case p.Type == "image_url" && p.ImageURL != nil:
|
||||
img := generativeaiinference.ImageUrl{Url: &p.ImageURL.URL}
|
||||
if d, ok := generativeaiinference.GetMappingImageUrlDetailEnum(p.ImageURL.Detail); ok {
|
||||
img.Detail = d
|
||||
}
|
||||
out = append(out, generativeaiinference.ImageContent{ImageUrl: &img})
|
||||
case p.Text != "":
|
||||
t := p.Text
|
||||
out = append(out, generativeaiinference.TextContent{Text: &t})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textContents 把纯文本包装为单元素 TextContent 数组;空文本返回 nil。
|
||||
func textContents(text string) []generativeaiinference.ChatContent {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}}
|
||||
}
|
||||
|
||||
func irToolCalls(calls []aiwire.ToolCall) []generativeaiinference.ToolCall {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]generativeaiinference.ToolCall, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
id, name, args := c.ID, c.Function.Name, c.Function.Arguments
|
||||
out = append(out, generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func irTools(tools []aiwire.Tool) []generativeaiinference.ToolDefinition {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]generativeaiinference.ToolDefinition, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
name, desc := t.Function.Name, t.Function.Description
|
||||
fd := generativeaiinference.FunctionDefinition{Name: &name}
|
||||
if desc != "" {
|
||||
fd.Description = &desc
|
||||
}
|
||||
if len(t.Function.Parameters) > 0 {
|
||||
var params interface{}
|
||||
if json.Unmarshal(t.Function.Parameters, ¶ms) == nil {
|
||||
fd.Parameters = ¶ms
|
||||
}
|
||||
}
|
||||
out = append(out, fd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// irToolChoice 解析 "auto"/"none"/"required" 或 {type:function,function:{name}}。
|
||||
func irToolChoice(raw json.RawMessage) generativeaiinference.ToolChoice {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
switch s {
|
||||
case "none":
|
||||
return generativeaiinference.ToolChoiceNone{}
|
||||
case "required":
|
||||
return generativeaiinference.ToolChoiceRequired{}
|
||||
case "auto":
|
||||
return generativeaiinference.ToolChoiceAuto{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var obj struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
}
|
||||
if json.Unmarshal(raw, &obj) == nil && obj.Function.Name != "" {
|
||||
return generativeaiinference.ToolChoiceFunction{Name: &obj.Function.Name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func irResponseFormat(rf *aiwire.ResponseFormat) generativeaiinference.ResponseFormat {
|
||||
if rf == nil {
|
||||
return nil
|
||||
}
|
||||
switch rf.Type {
|
||||
case "json_object":
|
||||
return generativeaiinference.JsonObjectResponseFormat{}
|
||||
case "json_schema":
|
||||
return irJSONSchemaFormat(rf.JSONSchema)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// irJSONSchemaFormat 映射 OpenAI json_schema{name,description,schema,strict}。
|
||||
func irJSONSchemaFormat(raw json.RawMessage) generativeaiinference.ResponseFormat {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
Strict *bool `json:"strict"`
|
||||
}
|
||||
if json.Unmarshal(raw, &in) != nil || in.Name == "" {
|
||||
return generativeaiinference.JsonObjectResponseFormat{}
|
||||
}
|
||||
js := generativeaiinference.ResponseJsonSchema{Name: &in.Name, IsStrict: in.Strict}
|
||||
if in.Description != "" {
|
||||
js.Description = &in.Description
|
||||
}
|
||||
if len(in.Schema) > 0 {
|
||||
var schema interface{}
|
||||
if json.Unmarshal(in.Schema, &schema) == nil {
|
||||
js.Schema = &schema
|
||||
}
|
||||
}
|
||||
return generativeaiinference.JsonSchemaResponseFormat{JsonSchema: &js}
|
||||
}
|
||||
|
||||
// sdkToIR 把非流式 GenericChatResponse 转回 IR 响应;id/created 由调用方补。
|
||||
func sdkToIR(resp generativeaiinference.GenericChatResponse, model string) *aiwire.ChatResponse {
|
||||
out := &aiwire.ChatResponse{Object: "chat.completion", Model: model}
|
||||
if resp.TimeCreated != nil {
|
||||
out.Created = resp.TimeCreated.Unix()
|
||||
}
|
||||
for i, ch := range resp.Choices {
|
||||
idx := i
|
||||
if ch.Index != nil {
|
||||
idx = *ch.Index
|
||||
}
|
||||
out.Choices = append(out.Choices, aiwire.Choice{
|
||||
Index: idx,
|
||||
Message: sdkMessageToIR(ch.Message),
|
||||
FinishReason: mapFinishReason(deref(ch.FinishReason)),
|
||||
})
|
||||
}
|
||||
out.Usage = sdkUsageToIR(resp.Usage)
|
||||
return out
|
||||
}
|
||||
|
||||
func sdkUsageToIR(u *generativeaiinference.Usage) *aiwire.Usage {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
out := &aiwire.Usage{}
|
||||
if u.PromptTokens != nil {
|
||||
out.PromptTokens = *u.PromptTokens
|
||||
}
|
||||
if u.CompletionTokens != nil {
|
||||
out.CompletionTokens = *u.CompletionTokens
|
||||
}
|
||||
if u.TotalTokens != nil {
|
||||
out.TotalTokens = *u.TotalTokens
|
||||
}
|
||||
if d := u.PromptTokensDetails; d != nil && d.CachedTokens != nil && *d.CachedTokens > 0 {
|
||||
out.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: *d.CachedTokens}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sdkMessageToIR 提取助手消息文本与工具调用。
|
||||
func sdkMessageToIR(m generativeaiinference.Message) aiwire.ChatMessage {
|
||||
out := aiwire.ChatMessage{Role: "assistant"}
|
||||
am, ok := m.(generativeaiinference.AssistantMessage)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range am.Content {
|
||||
if tc, ok := c.(generativeaiinference.TextContent); ok && tc.Text != nil {
|
||||
sb.WriteString(*tc.Text)
|
||||
}
|
||||
}
|
||||
out.Content = aiwire.NewTextContent(sb.String())
|
||||
for _, call := range am.ToolCalls {
|
||||
if fc, ok := call.(generativeaiinference.FunctionCall); ok {
|
||||
out.ToolCalls = append(out.ToolCalls, aiwire.ToolCall{
|
||||
ID: deref(fc.Id),
|
||||
Type: "function",
|
||||
Function: aiwire.FunctionCall{
|
||||
Name: deref(fc.Name),
|
||||
Arguments: deref(fc.Arguments),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapFinishReason 归一化结束原因;OCI 与 OpenAI 取值同名,做小写与别名兜底。
|
||||
func mapFinishReason(reason string) string {
|
||||
switch strings.ToLower(reason) {
|
||||
case "", "null":
|
||||
return ""
|
||||
case "stop", "completed", "end_turn":
|
||||
return "stop"
|
||||
case "length", "max_tokens":
|
||||
return "length"
|
||||
case "tool_calls", "tool_call", "tool_use":
|
||||
return "tool_calls"
|
||||
case "complete":
|
||||
return "stop"
|
||||
case "error_toxic":
|
||||
return "content_filter"
|
||||
default:
|
||||
return strings.ToLower(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// genAiStreamEvent 是 GENERIC 流式事件的宽容解析结构(字段按增量 choice 形状)。
|
||||
type genAiStreamEvent struct {
|
||||
Index *int `json:"index"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"toolCalls"`
|
||||
} `json:"message"`
|
||||
FinishReason *string `json:"finishReason"`
|
||||
Usage *generativeaiinference.Usage `json:"usage"`
|
||||
Choices []json.RawMessage `json:"choices"` // 兼容整包 choices 形态
|
||||
Text string `json:"text"` // COHERE 流事件的顶层增量文本
|
||||
// CohereCalls 是 COHERE 流事件顶层的工具调用(与 GENERIC 的 message.toolCalls 不同层级)
|
||||
CohereCalls []struct {
|
||||
Name string `json:"name"`
|
||||
Parameters interface{} `json:"parameters"`
|
||||
} `json:"toolCalls"`
|
||||
}
|
||||
|
||||
// parseGenAiEvent 把一条 SSE 事件 JSON 解析为 IR chunk;无有效负载时 ok=false。
|
||||
func parseGenAiEvent(data []byte, model string) (aiwire.ChatChunk, bool) {
|
||||
var ev genAiStreamEvent
|
||||
if err := json.Unmarshal(data, &ev); err != nil {
|
||||
return aiwire.ChatChunk{}, false
|
||||
}
|
||||
if len(ev.Choices) > 0 { // choices 包裹形态:取首个展开重解析
|
||||
var inner genAiStreamEvent
|
||||
if json.Unmarshal(ev.Choices[0], &inner) == nil {
|
||||
inner.Usage = ev.Usage
|
||||
ev = inner
|
||||
}
|
||||
}
|
||||
chunk := aiwire.ChatChunk{Object: "chat.completion.chunk", Model: model}
|
||||
choice := aiwire.ChunkChoice{}
|
||||
if ev.Index != nil {
|
||||
choice.Index = *ev.Index
|
||||
}
|
||||
choice.Delta.Role = strings.ToLower(ev.Message.Role)
|
||||
var sb strings.Builder
|
||||
for _, c := range ev.Message.Content {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
if sb.Len() == 0 && ev.Text != "" { // COHERE 事件形态
|
||||
sb.WriteString(ev.Text)
|
||||
}
|
||||
choice.Delta.Content = sb.String()
|
||||
for i, tc := range ev.Message.ToolCalls {
|
||||
d := aiwire.ToolCallDelta{Index: i, ID: tc.ID}
|
||||
if tc.ID != "" {
|
||||
d.Type = "function"
|
||||
}
|
||||
d.Function.Name = tc.Name
|
||||
d.Function.Arguments = tc.Arguments
|
||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
||||
}
|
||||
for i, tc := range ev.CohereCalls { // COHERE 顶层工具调用:整包出现,补派生 ID
|
||||
call := cohereCallToIR(tc.Name, &tc.Parameters, i)
|
||||
d := aiwire.ToolCallDelta{Index: len(choice.Delta.ToolCalls) + i, ID: call.ID, Type: "function"}
|
||||
d.Function.Name = call.Function.Name
|
||||
d.Function.Arguments = call.Function.Arguments
|
||||
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, d)
|
||||
}
|
||||
if r := mapFinishReason(deref(ev.FinishReason)); r != "" {
|
||||
choice.FinishReason = &r
|
||||
}
|
||||
hasPayload := choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0 || choice.FinishReason != nil
|
||||
if hasPayload {
|
||||
chunk.Choices = []aiwire.ChunkChoice{choice}
|
||||
}
|
||||
chunk.Usage = sdkUsageToIR(ev.Usage)
|
||||
return chunk, hasPayload || chunk.Usage != nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||
)
|
||||
|
||||
func TestToGenAiModelRetiredFilter(t *testing.T) {
|
||||
now := time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC)
|
||||
past := common.SDKTime{Time: now.Add(-24 * time.Hour)}
|
||||
future := common.SDKTime{Time: now.Add(30 * 24 * time.Hour)}
|
||||
chat := []generativeai.ModelCapabilityEnum{generativeai.ModelCapabilityChat}
|
||||
base := generativeai.ModelSummary{
|
||||
Id: common.String("ocid1..m"), DisplayName: common.String("meta.llama-x"),
|
||||
Vendor: common.String("meta"), Capabilities: chat,
|
||||
LifecycleState: generativeai.ModelLifecycleStateActive,
|
||||
}
|
||||
|
||||
// 已过按需退役时间:不入池(ListModels 仍返回且 state ACTIVE,调用必 404)
|
||||
retired := base
|
||||
retired.TimeOnDemandRetired = &past
|
||||
if _, ok := toGenAiModel(retired, now); ok {
|
||||
t.Error("已退役模型应被剔除")
|
||||
}
|
||||
// 退役时间在未来:入池并携带弃用/退役时间
|
||||
upcoming := base
|
||||
upcoming.TimeDeprecated = &past
|
||||
upcoming.TimeOnDemandRetired = &future
|
||||
gm, ok := toGenAiModel(upcoming, now)
|
||||
if !ok || gm.Retired == nil || !gm.Retired.Equal(future.Time) || gm.Deprecated == nil {
|
||||
t.Fatalf("未退役模型应入池并带时间: ok=%v %+v", ok, gm)
|
||||
}
|
||||
// 未宣布任何时间:正常入池
|
||||
if _, ok := toGenAiModel(base, now); !ok {
|
||||
t.Error("正常模型应入池")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射。
|
||||
func TestIrToSDK(t *testing.T) {
|
||||
temp, mt := 0.7, 100
|
||||
ir := aiwire.ChatRequest{
|
||||
Model: "meta.llama-3.3-70b-instruct",
|
||||
Messages: []aiwire.ChatMessage{
|
||||
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
||||
{Role: "user", Content: aiwire.NewTextContent("你好")},
|
||||
{Role: "assistant", ToolCalls: []aiwire.ToolCall{{ID: "c1", Type: "function", Function: aiwire.FunctionCall{Name: "get_weather", Arguments: `{"city":"东京"}`}}}},
|
||||
{Role: "tool", ToolCallID: "c1", Content: aiwire.NewTextContent("晴")},
|
||||
},
|
||||
Temperature: &temp,
|
||||
MaxTokens: &mt,
|
||||
Stop: aiwire.StringList{"END"},
|
||||
Tools: []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{Name: "get_weather", Parameters: json.RawMessage(`{"type":"object"}`)}}},
|
||||
ToolChoice: json.RawMessage(`"auto"`),
|
||||
}
|
||||
req := irToSDK(ir, true)
|
||||
if len(req.Messages) != 4 {
|
||||
t.Fatalf("messages = %d, want 4", len(req.Messages))
|
||||
}
|
||||
if _, ok := req.Messages[0].(generativeaiinference.SystemMessage); !ok {
|
||||
t.Fatalf("messages[0] = %T, want SystemMessage", req.Messages[0])
|
||||
}
|
||||
am, ok := req.Messages[2].(generativeaiinference.AssistantMessage)
|
||||
if !ok || len(am.ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls 未映射: %T %+v", req.Messages[2], am)
|
||||
}
|
||||
tm, ok := req.Messages[3].(generativeaiinference.ToolMessage)
|
||||
if !ok || deref(tm.ToolCallId) != "c1" {
|
||||
t.Fatalf("tool message 未映射 toolCallId: %+v", tm)
|
||||
}
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("temperature 未直通")
|
||||
}
|
||||
if req.MaxTokens == nil || *req.MaxTokens != 100 || len(req.Stop) != 1 {
|
||||
t.Fatalf("maxTokens/stop 未直通")
|
||||
}
|
||||
if _, ok := req.ToolChoice.(generativeaiinference.ToolChoiceAuto); !ok {
|
||||
t.Fatalf("toolChoice = %T, want auto", req.ToolChoice)
|
||||
}
|
||||
if len(req.Tools) != 1 || req.IsStream == nil || !*req.IsStream || req.StreamOptions == nil {
|
||||
t.Fatalf("tools/stream 选项未映射")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSdkToIR 断言响应文本、工具调用与用量的反向映射。
|
||||
func TestSdkToIR(t *testing.T) {
|
||||
text, fr := "东京晴", "tool_calls"
|
||||
idx, pt, ct, tt := 0, 10, 5, 15
|
||||
id, name, args := "c1", "get_weather", `{"city":"东京"}`
|
||||
resp := generativeaiinference.GenericChatResponse{
|
||||
Choices: []generativeaiinference.ChatChoice{{
|
||||
Index: &idx,
|
||||
Message: generativeaiinference.AssistantMessage{
|
||||
Content: []generativeaiinference.ChatContent{generativeaiinference.TextContent{Text: &text}},
|
||||
ToolCalls: []generativeaiinference.ToolCall{generativeaiinference.FunctionCall{Id: &id, Name: &name, Arguments: &args}},
|
||||
},
|
||||
FinishReason: &fr,
|
||||
}},
|
||||
Usage: &generativeaiinference.Usage{PromptTokens: &pt, CompletionTokens: &ct, TotalTokens: &tt},
|
||||
}
|
||||
out := sdkToIR(resp, "m1")
|
||||
if len(out.Choices) != 1 || out.Choices[0].Message.Content.JoinText() != "东京晴" {
|
||||
t.Fatalf("文本未映射: %+v", out)
|
||||
}
|
||||
if out.Choices[0].FinishReason != "tool_calls" || len(out.Choices[0].Message.ToolCalls) != 1 {
|
||||
t.Fatalf("finishReason/toolCalls 未映射: %+v", out.Choices[0])
|
||||
}
|
||||
if out.Usage == nil || out.Usage.TotalTokens != 15 {
|
||||
t.Fatalf("usage 未映射: %+v", out.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGenAiEvent 断言流事件宽容解析:文本增量、结束原因与 usage 事件。
|
||||
func TestParseGenAiEvent(t *testing.T) {
|
||||
chunk, ok := parseGenAiEvent([]byte(`{"index":0,"message":{"role":"ASSISTANT","content":[{"type":"TEXT","text":"你"}]}}`), "m1")
|
||||
if !ok || len(chunk.Choices) != 1 || chunk.Choices[0].Delta.Content != "你" {
|
||||
t.Fatalf("文本增量解析失败: %+v", chunk)
|
||||
}
|
||||
chunk, ok = parseGenAiEvent([]byte(`{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":2,"totalTokens":5}}`), "m1")
|
||||
if !ok || chunk.Choices[0].FinishReason == nil || *chunk.Choices[0].FinishReason != "stop" {
|
||||
t.Fatalf("finishReason 解析失败: %+v", chunk)
|
||||
}
|
||||
if chunk.Usage == nil || chunk.Usage.TotalTokens != 5 {
|
||||
t.Fatalf("usage 解析失败: %+v", chunk.Usage)
|
||||
}
|
||||
if _, ok := parseGenAiEvent([]byte(`{}`), "m1"); ok {
|
||||
t.Fatal("空事件应返回 ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSdkUsageCachedTokens(t *testing.T) {
|
||||
p, c, tot, cached := 10, 5, 15, 8
|
||||
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
||||
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
||||
if u.CachedTokens() != 8 {
|
||||
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
|
||||
}
|
||||
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
|
||||
t.Error("无细分时 CachedTokens 应为 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIrToCohereSDK(t *testing.T) {
|
||||
ir := aiwire.ChatRequest{
|
||||
Model: "cohere.command-r-plus",
|
||||
Messages: []aiwire.ChatMessage{
|
||||
{Role: "system", Content: aiwire.NewTextContent("你是助手")},
|
||||
{Role: "user", Content: aiwire.NewTextContent("第一问")},
|
||||
{Role: "assistant", Content: aiwire.NewTextContent("第一答")},
|
||||
{Role: "user", Content: aiwire.NewTextContent("第二问")},
|
||||
},
|
||||
}
|
||||
req, err := irToCohereSDK(ir, true)
|
||||
if err != nil {
|
||||
t.Fatalf("irToCohereSDK: %v", err)
|
||||
}
|
||||
if *req.Message != "第二问" || *req.PreambleOverride != "你是助手" || len(req.ChatHistory) != 2 {
|
||||
t.Errorf("拆装错误: msg=%q preamble=%v history=%d", *req.Message, req.PreambleOverride, len(req.ChatHistory))
|
||||
}
|
||||
if _, ok := req.ChatHistory[0].(generativeaiinference.CohereUserMessage); !ok {
|
||||
t.Errorf("history[0] 应为 user: %T", req.ChatHistory[0])
|
||||
}
|
||||
if _, ok := req.ChatHistory[1].(generativeaiinference.CohereChatBotMessage); !ok {
|
||||
t.Errorf("history[1] 应为 chatbot: %T", req.ChatHistory[1])
|
||||
}
|
||||
if req.IsStream == nil || !*req.IsStream {
|
||||
t.Error("IsStream 未设置")
|
||||
}
|
||||
if req.StreamOptions == nil || req.StreamOptions.IsIncludeUsage == nil || !*req.StreamOptions.IsIncludeUsage {
|
||||
t.Error("流式应默认开启 usage 回传(StreamOptions.IsIncludeUsage)")
|
||||
}
|
||||
// 工具定义降级为扁平参数表(仅取 JSON Schema 顶层 properties)
|
||||
ir.Tools = []aiwire.Tool{{Type: "function", Function: aiwire.FunctionDef{
|
||||
Name: "get_weather", Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string","description":"城市"}},"required":["city"]}`)}}}
|
||||
req2, err := irToCohereSDK(ir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("工具请求应支持: %v", err)
|
||||
}
|
||||
tool := req2.Tools[0]
|
||||
if *tool.Name != "get_weather" || *tool.Description != "get_weather" {
|
||||
t.Errorf("tool = %+v", tool)
|
||||
}
|
||||
if pd, ok := tool.ParameterDefinitions["city"]; !ok || *pd.Type != "string" || pd.IsRequired == nil || !*pd.IsRequired {
|
||||
t.Errorf("param city = %+v", pd)
|
||||
}
|
||||
// 图片输入仍拒绝
|
||||
ir.Tools = nil
|
||||
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "user", Content: aiwire.NewPartsContent([]aiwire.ContentPart{
|
||||
{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: "data:image/png;base64,xx"}}})})
|
||||
if _, err := irToCohereSDK(ir, false); err == nil {
|
||||
t.Error("图片输入应被拒绝")
|
||||
}
|
||||
// 无 user 消息被拒
|
||||
if _, err := irToCohereSDK(aiwire.ChatRequest{Model: "cohere.x", Messages: []aiwire.ChatMessage{{Role: "system", Content: aiwire.NewTextContent("s")}}}, false); err == nil {
|
||||
t.Error("无 user 消息应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCohereSDKToIR(t *testing.T) {
|
||||
text := "回答"
|
||||
resp := generativeaiinference.CohereChatResponse{
|
||||
Text: &text,
|
||||
FinishReason: generativeaiinference.CohereChatResponseFinishReasonComplete,
|
||||
}
|
||||
out := cohereSDKToIR(resp, "cohere.command-r-plus")
|
||||
if out.Choices[0].Message.Content.JoinText() != "回答" || out.Choices[0].FinishReason != "stop" {
|
||||
t.Errorf("cohereSDKToIR = %+v", out.Choices[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGenAiEventCohere(t *testing.T) {
|
||||
chunk, ok := parseGenAiEvent([]byte(`{"apiFormat":"COHERE","text":"你好"}`), "cohere.command-r")
|
||||
if !ok || chunk.Choices[0].Delta.Content != "你好" {
|
||||
t.Errorf("cohere text 事件解析 = %+v, %v", chunk, ok)
|
||||
}
|
||||
chunk, ok = parseGenAiEvent([]byte(`{"apiFormat":"COHERE","finishReason":"COMPLETE","usage":{"promptTokens":3,"completionTokens":5,"totalTokens":8}}`), "cohere.command-r")
|
||||
if !ok || *chunk.Choices[0].FinishReason != "stop" || chunk.Usage.TotalTokens != 8 {
|
||||
t.Errorf("cohere 终帧解析 = %+v, %v", chunk, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// Image 是一个可用的实例镜像。
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
OperatingSystem string `json:"operatingSystem"`
|
||||
OperatingSystemVersion string `json:"operatingSystemVersion"`
|
||||
SizeInMBs int64 `json:"sizeInMBs"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// ImagesQuery 是镜像查询条件。
|
||||
type ImagesQuery struct {
|
||||
Region string // 目标区域,空则用凭据默认区域
|
||||
OperatingSystem string // 如 Canonical Ubuntu、Oracle Linux
|
||||
Shape string // 只返回兼容该 shape 的镜像,如 VM.Standard.A1.Flex
|
||||
}
|
||||
|
||||
// ListImages 实现 Client:分页列出租户可用的平台镜像。
|
||||
func (c *RealClient) ListImages(ctx context.Context, cred Credentials, q ImagesQuery) ([]Image, error) {
|
||||
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new compute client: %w", err)
|
||||
}
|
||||
applyProxy(&cc.BaseClient, cred)
|
||||
if q.Region != "" {
|
||||
cc.SetRegion(normalizeRegion(q.Region))
|
||||
}
|
||||
var images []Image
|
||||
var page *string
|
||||
for {
|
||||
resp, err := cc.ListImages(ctx, buildImagesRequest(cred, q, page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list images: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
images = append(images, toImage(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(images), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// GetImage 实现 Client:查询单个镜像(按 imageId 补显示名等信息)。
|
||||
func (c *RealClient) GetImage(ctx context.Context, cred Credentials, region, imageID string) (Image, error) {
|
||||
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return Image{}, fmt.Errorf("new compute client: %w", err)
|
||||
}
|
||||
applyProxy(&cc.BaseClient, cred)
|
||||
if region != "" {
|
||||
cc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
resp, err := cc.GetImage(ctx, core.GetImageRequest{ImageId: &imageID})
|
||||
if err != nil {
|
||||
return Image{}, fmt.Errorf("get image: %w", err)
|
||||
}
|
||||
return toImage(resp.Image), nil
|
||||
}
|
||||
|
||||
// buildImagesRequest 组装镜像列表请求;过滤条件仅在非空时下发。
|
||||
func buildImagesRequest(cred Credentials, q ImagesQuery, page *string) core.ListImagesRequest {
|
||||
req := core.ListImagesRequest{
|
||||
CompartmentId: cred.EffectiveCompartment(),
|
||||
Page: page,
|
||||
LifecycleState: core.ImageLifecycleStateAvailable,
|
||||
}
|
||||
if q.OperatingSystem != "" {
|
||||
req.OperatingSystem = &q.OperatingSystem
|
||||
}
|
||||
if q.Shape != "" {
|
||||
req.Shape = &q.Shape
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func toImage(img core.Image) Image {
|
||||
out := Image{
|
||||
ID: deref(img.Id),
|
||||
DisplayName: deref(img.DisplayName),
|
||||
OperatingSystem: deref(img.OperatingSystem),
|
||||
OperatingSystemVersion: deref(img.OperatingSystemVersion),
|
||||
TimeCreated: sdkTime(img.TimeCreated),
|
||||
}
|
||||
if img.SizeInMBs != nil {
|
||||
out.SizeInMBs = *img.SizeInMBs
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
)
|
||||
|
||||
// Instance 是一个计算实例;IP 字段来自 VNIC 补全查询。
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
Shape string `json:"shape"`
|
||||
Ocpus float32 `json:"ocpus"`
|
||||
MemoryInGBs float32 `json:"memoryInGBs"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
Region string `json:"region"`
|
||||
CompartmentID string `json:"compartmentId"`
|
||||
ImageID string `json:"imageId"`
|
||||
SubnetID string `json:"subnetId,omitempty"`
|
||||
PrivateIP string `json:"privateIp,omitempty"`
|
||||
PublicIP string `json:"publicIp,omitempty"`
|
||||
Ipv6Addresses []string `json:"ipv6Addresses,omitempty"`
|
||||
FreeformTags map[string]string `json:"freeformTags,omitempty"`
|
||||
DefinedTags map[string]map[string]any `json:"definedTags,omitempty"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// CreateInstanceInput 是创建实例的输入:镜像与引导卷二选一作为启动源,
|
||||
// Flex shape 必须提供 ocpus 与 memoryInGBs。
|
||||
type CreateInstanceInput struct {
|
||||
Region string
|
||||
CompartmentID string // 目标 compartment,空为租户根
|
||||
AvailabilityDomain string
|
||||
DisplayName string
|
||||
Shape string
|
||||
Ocpus float32
|
||||
MemoryInGBs float32
|
||||
ImageID string
|
||||
BootVolumeID string
|
||||
BootVolumeSizeGBs int64
|
||||
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
||||
SubnetID string // 为空时自动创建 VCN 与子网
|
||||
AssignPublicIP bool
|
||||
AssignIpv6 bool
|
||||
SSHPublicKey string // 与 RootPassword 互斥
|
||||
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
|
||||
GenerateRootPassword bool // 由服务端生成随机 root 密码并写入 FreeformTags["RootPassword"]
|
||||
UserData string // 显式 cloud-init,base64 编码;RootPassword 非空时被其生成脚本覆盖
|
||||
FreeformTags map[string]string // 实例自由标签
|
||||
}
|
||||
|
||||
// UpdateInstanceInput 是更新实例的输入;零值字段不更新。
|
||||
type UpdateInstanceInput struct {
|
||||
Region string
|
||||
DisplayName string
|
||||
Shape string // 更换 shape;运行中实例 OCI 会自动重启,换 Flex 时须同时给 ocpus
|
||||
Ocpus float32
|
||||
MemoryInGBs float32
|
||||
}
|
||||
|
||||
// instanceActions 是允许的电源操作集合。
|
||||
var instanceActions = map[string]core.InstanceActionActionEnum{
|
||||
"START": core.InstanceActionActionStart,
|
||||
"STOP": core.InstanceActionActionStop,
|
||||
"RESET": core.InstanceActionActionReset,
|
||||
"SOFTSTOP": core.InstanceActionActionSoftstop,
|
||||
"SOFTRESET": core.InstanceActionActionSoftreset,
|
||||
}
|
||||
|
||||
func (c *RealClient) computeClient(cred Credentials, region string) (core.ComputeClient, error) {
|
||||
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return cc, fmt.Errorf("new compute client: %w", err)
|
||||
}
|
||||
applyProxy(&cc.BaseClient, cred)
|
||||
if region != "" {
|
||||
cc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
// ListAvailabilityDomains 实现 Client:列出区域内的可用域名称。
|
||||
func (c *RealClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
if region != "" {
|
||||
ic.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
resp, err := ic.ListAvailabilityDomains(ctx, identity.ListAvailabilityDomainsRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list availability domains: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(resp.Items))
|
||||
for _, ad := range resp.Items {
|
||||
names = append(names, deref(ad.Name))
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// defaultAvailabilityDomain 返回区域内第一个可用域(即 AD-1)。
|
||||
func (c *RealClient) defaultAvailabilityDomain(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
ads, err := c.ListAvailabilityDomains(ctx, cred, region)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve default availability domain: %w", err)
|
||||
}
|
||||
if len(ads) == 0 {
|
||||
return "", fmt.Errorf("resolve default availability domain: region has no availability domain")
|
||||
}
|
||||
return ads[0], nil
|
||||
}
|
||||
|
||||
// ListInstances 实现 Client:列出实例并补全网络 IP 信息。
|
||||
func (c *RealClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var instances []Instance
|
||||
var page *string
|
||||
for {
|
||||
resp, err := cc.ListInstances(ctx, core.ListInstancesRequest{
|
||||
CompartmentId: cred.EffectiveCompartment(),
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instances: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
// 已终止实例的 VNIC 已不存在,保留在列表中仅供展示
|
||||
instances = append(instances, toInstance(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
break
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
c.attachInstanceIPs(ctx, cred, region, instances)
|
||||
// 列表瘦身:标签体积大且列表页不消费(详情接口仍完整返回)
|
||||
for i := range instances {
|
||||
instances[i].FreeformTags = nil
|
||||
instances[i].DefinedTags = nil
|
||||
}
|
||||
return orEmpty(instances), nil
|
||||
}
|
||||
|
||||
// LaunchInstance 实现 Client;availabilityDomain 为空时默认使用第一个
|
||||
// 可用域(ad-1),subnetId 为空时自动准备默认网络。
|
||||
func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||
cc, err := c.computeClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
if in.AvailabilityDomain == "" {
|
||||
ad, err := c.defaultAvailabilityDomain(ctx, cred, in.Region)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
in.AvailabilityDomain = ad
|
||||
}
|
||||
if in.SubnetID == "" {
|
||||
subnetID, err := c.ensureDefaultSubnet(ctx, cred, in.Region)
|
||||
if err != nil {
|
||||
return Instance{}, fmt.Errorf("auto provision subnet: %w", err)
|
||||
}
|
||||
in.SubnetID = subnetID
|
||||
}
|
||||
details, err := buildLaunchDetails(*cred.EffectiveCompartment(), in)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
resp, err := cc.LaunchInstance(ctx, core.LaunchInstanceRequest{LaunchInstanceDetails: details})
|
||||
if err != nil {
|
||||
return Instance{}, fmt.Errorf("launch instance: %w", err)
|
||||
}
|
||||
return toInstance(resp.Instance), nil
|
||||
}
|
||||
|
||||
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
|
||||
details := core.LaunchInstanceDetails{
|
||||
CompartmentId: &compartmentID,
|
||||
AvailabilityDomain: &in.AvailabilityDomain,
|
||||
Shape: &in.Shape,
|
||||
DisplayName: &in.DisplayName,
|
||||
SourceDetails: buildInstanceSource(in),
|
||||
CreateVnicDetails: &core.CreateVnicDetails{
|
||||
SubnetId: &in.SubnetID,
|
||||
AssignPublicIp: &in.AssignPublicIP,
|
||||
},
|
||||
}
|
||||
if details.SourceDetails == nil {
|
||||
return details, fmt.Errorf("launch instance: imageId or bootVolumeId is required")
|
||||
}
|
||||
if in.AssignIpv6 {
|
||||
details.CreateVnicDetails.AssignIpv6Ip = &in.AssignIpv6
|
||||
}
|
||||
if in.Ocpus > 0 {
|
||||
details.ShapeConfig = &core.LaunchInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||||
if in.MemoryInGBs > 0 {
|
||||
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||||
}
|
||||
}
|
||||
if len(in.FreeformTags) > 0 {
|
||||
details.FreeformTags = in.FreeformTags
|
||||
}
|
||||
details.Metadata = buildInstanceMetadata(in)
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func buildInstanceSource(in CreateInstanceInput) core.InstanceSourceDetails {
|
||||
if in.BootVolumeID != "" {
|
||||
return core.InstanceSourceViaBootVolumeDetails{BootVolumeId: &in.BootVolumeID}
|
||||
}
|
||||
if in.ImageID != "" {
|
||||
src := core.InstanceSourceViaImageDetails{ImageId: &in.ImageID}
|
||||
if in.BootVolumeSizeGBs > 0 {
|
||||
src.BootVolumeSizeInGBs = &in.BootVolumeSizeGBs
|
||||
}
|
||||
if in.BootVolumeVpusPerGB > 0 {
|
||||
src.BootVolumeVpusPerGB = &in.BootVolumeVpusPerGB
|
||||
}
|
||||
return src
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildInstanceMetadata(in CreateInstanceInput) map[string]string {
|
||||
metadata := make(map[string]string, 2)
|
||||
if in.SSHPublicKey != "" {
|
||||
metadata["ssh_authorized_keys"] = in.SSHPublicKey
|
||||
}
|
||||
// RootPassword 生成的脚本优先作为 user_data,其次才是显式 UserData
|
||||
if in.RootPassword != "" {
|
||||
metadata["user_data"] = rootPasswordUserData(in.RootPassword)
|
||||
} else if in.UserData != "" {
|
||||
metadata["user_data"] = in.UserData
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// rootPasswordUserData 生成开启 root 密码登录的 cloud-init 脚本并 base64 编码。
|
||||
// 密码用单引号安全转义,避免 shell 注入。
|
||||
func rootPasswordUserData(password string) string {
|
||||
script := "#!/bin/bash\n" +
|
||||
"echo root:" + shellSingleQuote(password) + " | chpasswd\n" +
|
||||
"sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config\n" +
|
||||
"sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication yes/g' /etc/ssh/sshd_config\n" +
|
||||
"rm -f /etc/ssh/sshd_config.d/* /etc/ssh/ssh_config.d/*\n" +
|
||||
"systemctl restart sshd\n"
|
||||
return base64.StdEncoding.EncodeToString([]byte(script))
|
||||
}
|
||||
|
||||
// shellSingleQuote 用单引号包裹字符串,内部单引号转义为 '\”。
|
||||
func shellSingleQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// GetInstance 实现 Client:查询实例并补全网络 IP 信息。
|
||||
func (c *RealClient) GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return Instance{}, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
instances := []Instance{toInstance(resp.Instance)}
|
||||
c.attachInstanceIPs(ctx, cred, region, instances)
|
||||
return instances[0], nil
|
||||
}
|
||||
|
||||
// UpdateInstance 实现 Client:改名或调整 Flex 规格。
|
||||
func (c *RealClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) {
|
||||
cc, err := c.computeClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
details := core.UpdateInstanceDetails{}
|
||||
if in.DisplayName != "" {
|
||||
details.DisplayName = &in.DisplayName
|
||||
}
|
||||
if in.Shape != "" {
|
||||
details.Shape = &in.Shape
|
||||
}
|
||||
if in.Ocpus > 0 {
|
||||
details.ShapeConfig = &core.UpdateInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||||
if in.MemoryInGBs > 0 {
|
||||
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||||
}
|
||||
}
|
||||
resp, err := cc.UpdateInstance(ctx, core.UpdateInstanceRequest{
|
||||
InstanceId: &instanceID,
|
||||
UpdateInstanceDetails: details,
|
||||
})
|
||||
if err != nil {
|
||||
return Instance{}, fmt.Errorf("update instance %s: %w", instanceID, err)
|
||||
}
|
||||
return toInstance(resp.Instance), nil
|
||||
}
|
||||
|
||||
// TerminateInstance 实现 Client;preserveBootVolume 为 true 时保留引导卷。
|
||||
func (c *RealClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cc.TerminateInstance(ctx, core.TerminateInstanceRequest{
|
||||
InstanceId: &instanceID,
|
||||
PreserveBootVolume: &preserveBootVolume,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("terminate instance %s: %w", instanceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstanceAction 实现 Client:执行电源操作。
|
||||
func (c *RealClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) {
|
||||
actionEnum, ok := instanceActions[strings.ToUpper(strings.TrimSpace(action))]
|
||||
if !ok {
|
||||
return Instance{}, fmt.Errorf("instance action: unsupported action %q", action)
|
||||
}
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return Instance{}, err
|
||||
}
|
||||
resp, err := cc.InstanceAction(ctx, core.InstanceActionRequest{
|
||||
InstanceId: &instanceID,
|
||||
Action: actionEnum,
|
||||
})
|
||||
if err != nil {
|
||||
return Instance{}, fmt.Errorf("instance action %s on %s: %w", action, instanceID, err)
|
||||
}
|
||||
return toInstance(resp.Instance), nil
|
||||
}
|
||||
|
||||
// hostCompartment 返回宿主资源自身所在 compartment 的请求指针:OCI 的
|
||||
// 关联查询要求 compartmentId 与资源实际所在 compartment 一致,传租户根
|
||||
// 会漏掉子 compartment 里的资源;id 缺失时保底用生效 compartment。
|
||||
func hostCompartment(cred Credentials, id string) *string {
|
||||
if id != "" {
|
||||
return &id
|
||||
}
|
||||
return cred.EffectiveCompartment()
|
||||
}
|
||||
|
||||
// attachInstanceIPs 批量补全实例的子网与 IP:按实例所在 compartment 分组
|
||||
// 列出 VNIC 挂载,再逐 VNIC 查询地址;直接用实例自带的 compartment,
|
||||
// 避免为定位挂载再逐台调用 GetInstance。
|
||||
func (c *RealClient) attachInstanceIPs(ctx context.Context, cred Credentials, region string, instances []Instance) {
|
||||
active := make(map[string]*Instance, len(instances))
|
||||
compartments := make(map[string]struct{})
|
||||
for i := range instances {
|
||||
if instances[i].LifecycleState == string(core.InstanceLifecycleStateTerminated) {
|
||||
continue
|
||||
}
|
||||
active[instances[i].ID] = &instances[i]
|
||||
compartments[deref(hostCompartment(cred, instances[i].CompartmentID))] = struct{}{}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return
|
||||
}
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for compartmentID := range compartments {
|
||||
fillInstanceIPs(ctx, cc, vn, compartmentID, active)
|
||||
}
|
||||
}
|
||||
|
||||
// fillInstanceIPs 列出单个 compartment 内的 VNIC 挂载,为命中的实例补全地址。
|
||||
// 逐 VNIC 的 GetVnic 是列表接口的主要耗时点,限并发 8 路并行拉取。
|
||||
func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.VirtualNetworkClient, compartmentID string, active map[string]*Instance) {
|
||||
attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{CompartmentId: &compartmentID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
sem = make(chan struct{}, 8)
|
||||
)
|
||||
for _, att := range attResp.Items {
|
||||
inst, ok := active[deref(att.InstanceId)]
|
||||
if !ok || att.VnicId == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(vnicID *string, inst *Instance) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
vnicResp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: vnicID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
inst.SubnetID = deref(vnicResp.SubnetId)
|
||||
inst.PrivateIP = deref(vnicResp.PrivateIp)
|
||||
inst.PublicIP = deref(vnicResp.PublicIp)
|
||||
inst.Ipv6Addresses = vnicResp.Ipv6Addresses
|
||||
mu.Unlock()
|
||||
}(att.VnicId, inst)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func toInstance(inst core.Instance) Instance {
|
||||
out := Instance{
|
||||
ID: deref(inst.Id),
|
||||
DisplayName: deref(inst.DisplayName),
|
||||
LifecycleState: string(inst.LifecycleState),
|
||||
Shape: deref(inst.Shape),
|
||||
AvailabilityDomain: deref(inst.AvailabilityDomain),
|
||||
Region: deref(inst.Region),
|
||||
CompartmentID: deref(inst.CompartmentId),
|
||||
ImageID: deref(inst.ImageId),
|
||||
FreeformTags: inst.FreeformTags,
|
||||
DefinedTags: inst.DefinedTags,
|
||||
TimeCreated: sdkTime(inst.TimeCreated),
|
||||
}
|
||||
if inst.ShapeConfig != nil {
|
||||
if inst.ShapeConfig.Ocpus != nil {
|
||||
out.Ocpus = *inst.ShapeConfig.Ocpus
|
||||
}
|
||||
if inst.ShapeConfig.MemoryInGBs != nil {
|
||||
out.MemoryInGBs = *inst.ShapeConfig.MemoryInGBs
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
func TestShellSingleQuote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "普通", in: "Passw0rd", want: "'Passw0rd'"},
|
||||
{name: "含单引号", in: "a'b", want: `'a'\''b'`},
|
||||
{name: "含空格特殊符", in: "p @ss!", want: "'p @ss!'"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shellSingleQuote(tt.in); got != tt.want {
|
||||
t.Errorf("shellSingleQuote(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootPasswordUserData(t *testing.T) {
|
||||
encoded := rootPasswordUserData("Secret123")
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("result is not base64: %v", err)
|
||||
}
|
||||
script := string(raw)
|
||||
for _, want := range []string{
|
||||
"#!/bin/bash",
|
||||
"echo root:'Secret123' | chpasswd",
|
||||
"PermitRootLogin yes",
|
||||
"PasswordAuthentication yes",
|
||||
"systemctl restart sshd",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("script missing %q\n---\n%s", want, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstanceMetadataRootPasswordOverridesUserData(t *testing.T) {
|
||||
md := buildInstanceMetadata(CreateInstanceInput{
|
||||
RootPassword: "pw",
|
||||
UserData: "ZXhwbGljaXQ=", // "explicit"
|
||||
})
|
||||
decoded, _ := base64.StdEncoding.DecodeString(md["user_data"])
|
||||
if !strings.Contains(string(decoded), "chpasswd") {
|
||||
t.Errorf("user_data = %q, want root password script (not explicit userData)", md["user_data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstanceSourceVpusPerGB(t *testing.T) {
|
||||
src := buildInstanceSource(CreateInstanceInput{ImageID: "img", BootVolumeSizeGBs: 100, BootVolumeVpusPerGB: 20})
|
||||
img, ok := src.(core.InstanceSourceViaImageDetails)
|
||||
if !ok {
|
||||
t.Fatalf("source type = %T, want image", src)
|
||||
}
|
||||
if img.BootVolumeVpusPerGB == nil || *img.BootVolumeVpusPerGB != 20 {
|
||||
t.Errorf("BootVolumeVpusPerGB = %v, want 20", img.BootVolumeVpusPerGB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstanceSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in CreateInstanceInput
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "从镜像启动",
|
||||
in: CreateInstanceInput{ImageID: "ocid1.image..a", BootVolumeSizeGBs: 100},
|
||||
wantType: "image",
|
||||
},
|
||||
{
|
||||
name: "从引导卷启动",
|
||||
in: CreateInstanceInput{BootVolumeID: "ocid1.bootvolume..b"},
|
||||
wantType: "bootVolume",
|
||||
},
|
||||
{
|
||||
name: "引导卷优先于镜像",
|
||||
in: CreateInstanceInput{ImageID: "ocid1.image..a", BootVolumeID: "ocid1.bootvolume..b"},
|
||||
wantType: "bootVolume",
|
||||
},
|
||||
{
|
||||
name: "两者皆无返回 nil",
|
||||
in: CreateInstanceInput{},
|
||||
wantType: "nil",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
src := buildInstanceSource(tt.in)
|
||||
switch tt.wantType {
|
||||
case "image":
|
||||
img, ok := src.(core.InstanceSourceViaImageDetails)
|
||||
if !ok {
|
||||
t.Fatalf("source type = %T, want InstanceSourceViaImageDetails", src)
|
||||
}
|
||||
if got, want := *img.BootVolumeSizeInGBs, tt.in.BootVolumeSizeGBs; got != want {
|
||||
t.Errorf("BootVolumeSizeInGBs = %d, want %d", got, want)
|
||||
}
|
||||
case "bootVolume":
|
||||
if _, ok := src.(core.InstanceSourceViaBootVolumeDetails); !ok {
|
||||
t.Fatalf("source type = %T, want InstanceSourceViaBootVolumeDetails", src)
|
||||
}
|
||||
case "nil":
|
||||
if src != nil {
|
||||
t.Fatalf("source = %v, want nil", src)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLaunchDetails(t *testing.T) {
|
||||
in := CreateInstanceInput{
|
||||
AvailabilityDomain: "AD-1",
|
||||
DisplayName: "vm1",
|
||||
Shape: "VM.Standard.A1.Flex",
|
||||
Ocpus: 2,
|
||||
MemoryInGBs: 12,
|
||||
ImageID: "ocid1.image..a",
|
||||
SubnetID: "ocid1.subnet..s",
|
||||
AssignPublicIP: true,
|
||||
AssignIpv6: true,
|
||||
SSHPublicKey: "ssh-ed25519 AAA",
|
||||
}
|
||||
details, err := buildLaunchDetails("ocid1.tenancy..t", in)
|
||||
if err != nil {
|
||||
t.Fatalf("buildLaunchDetails: %v", err)
|
||||
}
|
||||
if got, want := *details.ShapeConfig.Ocpus, float32(2); got != want {
|
||||
t.Errorf("Ocpus = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := *details.ShapeConfig.MemoryInGBs, float32(12); got != want {
|
||||
t.Errorf("MemoryInGBs = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := *details.CreateVnicDetails.SubnetId, in.SubnetID; got != want {
|
||||
t.Errorf("SubnetId = %v, want %v", got, want)
|
||||
}
|
||||
if details.CreateVnicDetails.AssignIpv6Ip == nil || !*details.CreateVnicDetails.AssignIpv6Ip {
|
||||
t.Error("AssignIpv6Ip not set, want true")
|
||||
}
|
||||
if got, want := details.Metadata["ssh_authorized_keys"], in.SSHPublicKey; got != want {
|
||||
t.Errorf("ssh_authorized_keys = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLaunchDetailsRequiresSource(t *testing.T) {
|
||||
_, err := buildLaunchDetails("ocid1.tenancy..t", CreateInstanceInput{
|
||||
AvailabilityDomain: "AD-1", DisplayName: "vm1", Shape: "shape", SubnetID: "sub",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("buildLaunchDetails without source: got nil error, want failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLaunchDetailsNoShapeConfig(t *testing.T) {
|
||||
details, err := buildLaunchDetails("t", CreateInstanceInput{
|
||||
AvailabilityDomain: "AD-1", DisplayName: "vm1", Shape: "VM.Standard.E2.1.Micro",
|
||||
ImageID: "img", SubnetID: "sub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildLaunchDetails: %v", err)
|
||||
}
|
||||
if details.ShapeConfig != nil {
|
||||
t.Errorf("ShapeConfig = %+v, want nil for fixed shape", details.ShapeConfig)
|
||||
}
|
||||
if details.Metadata != nil {
|
||||
t.Errorf("Metadata = %v, want nil when no key and user data", details.Metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// ipv6AnyCIDR 是 IPv6 默认路由与放行目标。
|
||||
const ipv6AnyCIDR = "::/0"
|
||||
|
||||
// IPv6Step 是一键启用 IPv6 过程中的一个步骤结果。
|
||||
type IPv6Step struct {
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"` // done / skipped / failed
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// EnableVCNIPv6 实现 Client:为 VCN 一键启用 IPv6。依次执行:
|
||||
// 1. VCN 分配 Oracle GUA /56(已有则跳过);
|
||||
// 2. 每个无 IPv6 的子网从 /56 中分配一个 /64;
|
||||
// 3. 默认路由表补 ::/0 → Internet Gateway(无 IGW 时自动创建);
|
||||
// 4. 默认安全列表补 egress ::/0 放行。
|
||||
//
|
||||
// 步骤级失败记录在返回的步骤列表中,不中断后续无依赖的步骤。
|
||||
func (c *RealClient) EnableVCNIPv6(ctx context.Context, cred Credentials, region, vcnID string) ([]IPv6Step, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get vcn %s: %w", vcnID, err)
|
||||
}
|
||||
// 子网 / IGW 的关联查询须用 VCN 实际所在 compartment,传租户根会漏查
|
||||
vcnCompartment := *hostCompartment(cred, deref(vcnResp.CompartmentId))
|
||||
var steps []IPv6Step
|
||||
vcnCidr, step := ensureVCNIPv6Cidr(ctx, vn, vcnResp.Vcn)
|
||||
steps = append(steps, step)
|
||||
steps = append(steps, ensureSubnetsIPv6(ctx, vn, vcnCompartment, vcnID, vcnCidr)...)
|
||||
steps = append(steps, ensureIPv6Route(ctx, vn, vcnCompartment, vcnResp.Vcn))
|
||||
steps = append(steps, ensureIPv6Egress(ctx, vn, vcnResp.Vcn))
|
||||
return orEmpty(steps), nil
|
||||
}
|
||||
|
||||
// ensureVCNIPv6Cidr 确保 VCN 拥有 IPv6 GUA,返回其 /56 前缀。
|
||||
func ensureVCNIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcn core.Vcn) (string, IPv6Step) {
|
||||
if len(vcn.Ipv6CidrBlocks) > 0 {
|
||||
return vcn.Ipv6CidrBlocks[0], IPv6Step{Step: "vcn-ipv6-cidr", Status: "skipped", Detail: vcn.Ipv6CidrBlocks[0]}
|
||||
}
|
||||
enabled := true
|
||||
_, err := vn.AddIpv6VcnCidr(ctx, core.AddIpv6VcnCidrRequest{
|
||||
VcnId: vcn.Id,
|
||||
AddVcnIpv6CidrDetails: core.AddVcnIpv6CidrDetails{IsOracleGuaAllocationEnabled: &enabled},
|
||||
})
|
||||
if err != nil {
|
||||
return "", IPv6Step{Step: "vcn-ipv6-cidr", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
cidr, err := waitVCNIPv6Cidr(ctx, vn, *vcn.Id)
|
||||
if err != nil {
|
||||
return "", IPv6Step{Step: "vcn-ipv6-cidr", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
return cidr, IPv6Step{Step: "vcn-ipv6-cidr", Status: "done", Detail: cidr}
|
||||
}
|
||||
|
||||
// waitVCNIPv6Cidr 轮询等待 Oracle GUA 分配完成(异步操作)。
|
||||
func waitVCNIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) (string, error) {
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("poll vcn ipv6 cidr: %w", err)
|
||||
}
|
||||
if len(resp.Ipv6CidrBlocks) > 0 {
|
||||
return resp.Ipv6CidrBlocks[0], nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("poll vcn ipv6 cidr: %w", ctx.Err())
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("poll vcn ipv6 cidr: timed out")
|
||||
}
|
||||
|
||||
// ensureSubnetsIPv6 为每个还没有 IPv6 的子网从 VCN /56 中分配 /64;
|
||||
// compartmentID 须是 VCN 实际所在 compartment,否则列不到子网。
|
||||
func ensureSubnetsIPv6(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID, vcnCidr string) []IPv6Step {
|
||||
if vcnCidr == "" {
|
||||
return []IPv6Step{{Step: "subnet-ipv6-cidr", Status: "failed", Detail: "vcn has no ipv6 cidr"}}
|
||||
}
|
||||
subnets, err := listSubnets(ctx, vn, compartmentID, vcnID)
|
||||
if err != nil {
|
||||
return []IPv6Step{{Step: "subnet-ipv6-cidr", Status: "failed", Detail: err.Error()}}
|
||||
}
|
||||
used := make([]string, 0, len(subnets))
|
||||
for _, s := range subnets {
|
||||
if s.Ipv6CidrBlock != "" {
|
||||
used = append(used, s.Ipv6CidrBlock)
|
||||
}
|
||||
}
|
||||
var steps []IPv6Step
|
||||
for _, s := range subnets {
|
||||
if s.Ipv6CidrBlock != "" {
|
||||
steps = append(steps, IPv6Step{Step: "subnet-ipv6-cidr", Status: "skipped", Detail: s.DisplayName + " " + s.Ipv6CidrBlock})
|
||||
continue
|
||||
}
|
||||
steps = append(steps, addSubnetIPv6(ctx, vn, s, vcnCidr, &used))
|
||||
}
|
||||
return orEmpty(steps)
|
||||
}
|
||||
|
||||
func addSubnetIPv6(ctx context.Context, vn core.VirtualNetworkClient, s Subnet, vcnCidr string, used *[]string) IPv6Step {
|
||||
cidr, err := nextSubnetIPv6CIDR(vcnCidr, *used)
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "failed", Detail: s.DisplayName + ": " + err.Error()}
|
||||
}
|
||||
_, err = vn.AddIpv6SubnetCidr(ctx, core.AddIpv6SubnetCidrRequest{
|
||||
SubnetId: &s.ID,
|
||||
AddSubnetIpv6CidrDetails: core.AddSubnetIpv6CidrDetails{Ipv6CidrBlock: &cidr},
|
||||
})
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "failed", Detail: s.DisplayName + ": " + err.Error()}
|
||||
}
|
||||
*used = append(*used, cidr)
|
||||
return IPv6Step{Step: "subnet-ipv6-cidr", Status: "done", Detail: s.DisplayName + " " + cidr}
|
||||
}
|
||||
|
||||
// nextSubnetIPv6CIDR 从 VCN 的 /56 前缀中选出未被占用的 /64:
|
||||
// /56 的前 7 字节固定,第 8 字节 0~255 即 256 个可用 /64。
|
||||
func nextSubnetIPv6CIDR(vcnCidr string, used []string) (string, error) {
|
||||
prefix, err := netip.ParsePrefix(vcnCidr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse vcn ipv6 cidr %s: %w", vcnCidr, err)
|
||||
}
|
||||
base := prefix.Addr().As16()
|
||||
taken := make(map[byte]bool, len(used))
|
||||
for _, u := range used {
|
||||
p, err := netip.ParsePrefix(u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b := p.Addr().As16()
|
||||
if [7]byte(b[:7]) == [7]byte(base[:7]) {
|
||||
taken[b[7]] = true
|
||||
}
|
||||
}
|
||||
for i := 0; i <= 255; i++ {
|
||||
if taken[byte(i)] {
|
||||
continue
|
||||
}
|
||||
candidate := base
|
||||
candidate[7] = base[7] | byte(i)
|
||||
return netip.PrefixFrom(netip.AddrFrom16(candidate), 64).String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("no free /64 left in %s", vcnCidr)
|
||||
}
|
||||
|
||||
// ensureIPv6Route 在默认路由表补 ::/0 → IGW;没有 IGW 时自动创建。
|
||||
// compartmentID 须是 VCN 实际所在 compartment,IGW 查找与创建都在其中进行。
|
||||
func ensureIPv6Route(ctx context.Context, vn core.VirtualNetworkClient, compartmentID string, vcn core.Vcn) IPv6Step {
|
||||
rtResp, err := vn.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: vcn.DefaultRouteTableId})
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
for _, rule := range rtResp.RouteRules {
|
||||
if deref(rule.Destination) == ipv6AnyCIDR {
|
||||
return IPv6Step{Step: "route-ipv6-default", Status: "skipped", Detail: "::/0 route exists"}
|
||||
}
|
||||
}
|
||||
igwID, err := ensureInternetGateway(ctx, vn, compartmentID, *vcn.Id)
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
dest := ipv6AnyCIDR
|
||||
rules := append(rtResp.RouteRules, core.RouteRule{
|
||||
Destination: &dest,
|
||||
DestinationType: core.RouteRuleDestinationTypeCidrBlock,
|
||||
NetworkEntityId: &igwID,
|
||||
})
|
||||
_, err = vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||
RtId: vcn.DefaultRouteTableId,
|
||||
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: rules},
|
||||
})
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "route-ipv6-default", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
return IPv6Step{Step: "route-ipv6-default", Status: "done", Detail: "::/0 -> " + igwID}
|
||||
}
|
||||
|
||||
// ensureInternetGateway 返回 VCN 已有的 IGW,没有则创建一个;
|
||||
// compartmentID 须是 VCN 实际所在 compartment,否则查不到已有 IGW。
|
||||
func ensureInternetGateway(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) (string, error) {
|
||||
listResp, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{
|
||||
CompartmentId: &compartmentID,
|
||||
VcnId: &vcnID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list internet gateways: %w", err)
|
||||
}
|
||||
if len(listResp.Items) > 0 {
|
||||
return deref(listResp.Items[0].Id), nil
|
||||
}
|
||||
enabled := true
|
||||
name := "oci-portal-igw"
|
||||
createResp, err := vn.CreateInternetGateway(ctx, core.CreateInternetGatewayRequest{
|
||||
CreateInternetGatewayDetails: core.CreateInternetGatewayDetails{
|
||||
CompartmentId: &compartmentID,
|
||||
VcnId: &vcnID,
|
||||
IsEnabled: &enabled,
|
||||
DisplayName: &name,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create internet gateway: %w", err)
|
||||
}
|
||||
return deref(createResp.InternetGateway.Id), nil
|
||||
}
|
||||
|
||||
// AddInstanceIpv6 实现 Client:为实例主 VNIC 分配一个 IPv6 地址并返回。
|
||||
// address 为空时由子网 /64 自动分配;子网未启用 IPv6 时 OCI 返回错误。
|
||||
func (c *RealClient) AddInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) (string, error) {
|
||||
vnic, err := c.primaryVnic(ctx, cred, region, instanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.AddVnicIpv6(ctx, cred, region, deref(vnic.Id), address)
|
||||
}
|
||||
|
||||
// AddVnicIpv6 实现 Client:为指定 VNIC 分配一个 IPv6 地址并返回(多网卡场景)。
|
||||
func (c *RealClient) AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
details := core.CreateIpv6Details{VnicId: &vnicID}
|
||||
if address != "" {
|
||||
details.IpAddress = &address
|
||||
}
|
||||
resp, err := vn.CreateIpv6(ctx, core.CreateIpv6Request{CreateIpv6Details: details})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create ipv6 on vnic %s: %w", vnicID, err)
|
||||
}
|
||||
return deref(resp.IpAddress), nil
|
||||
}
|
||||
|
||||
// DeleteInstanceIpv6 实现 Client:取消分配实例上的指定 IPv6 地址。
|
||||
// 遍历实例 VNIC 找到地址对应的 Ipv6 对象后删除;地址不存在时报错。
|
||||
func (c *RealClient) DeleteInstanceIpv6(ctx context.Context, cred Credentials, region, instanceID, address string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vnics, _, err := c.instanceVnics(ctx, cred, region, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vnic := range vnics {
|
||||
resp, err := vn.ListIpv6s(ctx, core.ListIpv6sRequest{VnicId: vnic.Id})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ip := range resp.Items {
|
||||
if !sameIPv6(deref(ip.IpAddress), address) {
|
||||
continue
|
||||
}
|
||||
if _, err := vn.DeleteIpv6(ctx, core.DeleteIpv6Request{Ipv6Id: ip.Id}); err != nil {
|
||||
return fmt.Errorf("delete ipv6 %s: %w", address, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("delete ipv6: address %s not found on instance %s", address, instanceID)
|
||||
}
|
||||
|
||||
// sameIPv6 规范化比较两个 IPv6 地址(兼容大小写与缩写形式差异)。
|
||||
func sameIPv6(a, b string) bool {
|
||||
pa, errA := netip.ParseAddr(a)
|
||||
pb, errB := netip.ParseAddr(b)
|
||||
if errA != nil || errB != nil {
|
||||
return a == b
|
||||
}
|
||||
return pa == pb
|
||||
}
|
||||
|
||||
// ensureIPv6Egress 在默认安全列表补 egress ::/0 全协议放行。
|
||||
func ensureIPv6Egress(ctx context.Context, vn core.VirtualNetworkClient, vcn core.Vcn) IPv6Step {
|
||||
slResp, err := vn.GetSecurityList(ctx, core.GetSecurityListRequest{SecurityListId: vcn.DefaultSecurityListId})
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "seclist-ipv6-egress", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
for _, rule := range slResp.EgressSecurityRules {
|
||||
if deref(rule.Destination) == ipv6AnyCIDR {
|
||||
return IPv6Step{Step: "seclist-ipv6-egress", Status: "skipped", Detail: "::/0 egress exists"}
|
||||
}
|
||||
}
|
||||
dest := ipv6AnyCIDR
|
||||
protocol := "all"
|
||||
rules := append(slResp.EgressSecurityRules, core.EgressSecurityRule{
|
||||
Protocol: &protocol,
|
||||
Destination: &dest,
|
||||
DestinationType: core.EgressSecurityRuleDestinationTypeCidrBlock,
|
||||
})
|
||||
_, err = vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||
SecurityListId: vcn.DefaultSecurityListId,
|
||||
UpdateSecurityListDetails: core.UpdateSecurityListDetails{EgressSecurityRules: rules},
|
||||
})
|
||||
if err != nil {
|
||||
return IPv6Step{Step: "seclist-ipv6-egress", Status: "failed", Detail: err.Error()}
|
||||
}
|
||||
return IPv6Step{Step: "seclist-ipv6-egress", Status: "done", Detail: "egress ::/0 allowed"}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package oci
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNextSubnetIPv6CIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vcnCidr string
|
||||
used []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "无占用取首个",
|
||||
vcnCidr: "2603:c020:e:de00::/56",
|
||||
want: "2603:c020:e:de00::/64",
|
||||
},
|
||||
{
|
||||
name: "跳过已用索引",
|
||||
vcnCidr: "2603:c020:e:de00::/56",
|
||||
used: []string{"2603:c020:e:de00::/64", "2603:c020:e:de01::/64"},
|
||||
want: "2603:c020:e:de02::/64",
|
||||
},
|
||||
{
|
||||
name: "其他前缀的占用不影响",
|
||||
vcnCidr: "2603:c020:e:de00::/56",
|
||||
used: []string{"2603:c020:f:aa00::/64"},
|
||||
want: "2603:c020:e:de00::/64",
|
||||
},
|
||||
{
|
||||
name: "非法 VCN 前缀报错",
|
||||
vcnCidr: "not-a-cidr",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := nextSubnetIPv6CIDR(tt.vcnCidr, tt.used)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("nextSubnetIPv6CIDR() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("nextSubnetIPv6CIDR() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextSubnetIPv6CIDRExhausted(t *testing.T) {
|
||||
used := make([]string, 0, 256)
|
||||
base := "2603:c020:e:de00::/56"
|
||||
for i := 0; i <= 255; i++ {
|
||||
cidr, err := nextSubnetIPv6CIDR(base, used)
|
||||
if err != nil {
|
||||
t.Fatalf("allocation %d failed: %v", i, err)
|
||||
}
|
||||
used = append(used, cidr)
|
||||
}
|
||||
if _, err := nextSubnetIPv6CIDR(base, used); err == nil {
|
||||
t.Error("257th allocation: got nil error, want exhausted failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{name: "完全一致", a: "2603:c020:e:de00::1", b: "2603:c020:e:de00::1", want: true},
|
||||
{name: "大小写不敏感", a: "2603:C020:E:DE00::1", b: "2603:c020:e:de00::1", want: true},
|
||||
{name: "缩写与全写等价", a: "2603:c020:e:de00:0:0:0:1", b: "2603:c020:e:de00::1", want: true},
|
||||
{name: "不同地址", a: "2603:c020:e:de00::1", b: "2603:c020:e:de00::2", want: false},
|
||||
{name: "非法输入回退字符串比较", a: "not-an-ip", b: "not-an-ip", want: true},
|
||||
{name: "非法输入不相等", a: "not-an-ip", b: "2603:c020:e:de00::1", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sameIPv6(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("sameIPv6(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuleOptions(t *testing.T) {
|
||||
port := func(n int) *int { return &n }
|
||||
tests := []struct {
|
||||
name string
|
||||
rule SecurityRule
|
||||
wantTCP bool
|
||||
wantUDP bool
|
||||
wantICMP bool
|
||||
}{
|
||||
{name: "TCP 端口", rule: SecurityRule{Protocol: "6", PortMin: port(22)}, wantTCP: true},
|
||||
{name: "UDP 端口", rule: SecurityRule{Protocol: "17", PortMin: port(53), PortMax: port(53)}, wantUDP: true},
|
||||
{name: "ICMP 类型", rule: SecurityRule{Protocol: "1", IcmpType: port(8)}, wantICMP: true},
|
||||
{name: "ICMPv6 类型", rule: SecurityRule{Protocol: "58", IcmpType: port(128)}, wantICMP: true},
|
||||
{name: "all 无选项", rule: SecurityRule{Protocol: "all"}},
|
||||
{name: "TCP 无端口无选项", rule: SecurityRule{Protocol: "6"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tcp, udp, icmp := buildRuleOptions(tt.rule)
|
||||
if (tcp != nil) != tt.wantTCP {
|
||||
t.Errorf("tcp options = %v, want present=%v", tcp, tt.wantTCP)
|
||||
}
|
||||
if (udp != nil) != tt.wantUDP {
|
||||
t.Errorf("udp options = %v, want present=%v", udp, tt.wantUDP)
|
||||
}
|
||||
if (icmp != nil) != tt.wantICMP {
|
||||
t.Errorf("icmp options = %v, want present=%v", icmp, tt.wantICMP)
|
||||
}
|
||||
if tt.wantTCP && tt.rule.PortMax == nil {
|
||||
if got, want := *tcp.DestinationPortRange.Max, *tt.rule.PortMin; got != want {
|
||||
t.Errorf("port max = %d, want fallback to min %d", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
"github.com/oracle/oci-go-sdk/v65/ons"
|
||||
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||
)
|
||||
|
||||
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
|
||||
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
||||
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
||||
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
||||
const (
|
||||
relayTopicPrefix = "ociportal-logs"
|
||||
relayPolicyName = "ociportal-logs-sch"
|
||||
relayConnectorName = "ociportal-logs"
|
||||
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
||||
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
||||
)
|
||||
|
||||
// Connector 创建为异步 work request,轮询直至 ACTIVE。
|
||||
var (
|
||||
relayConnectorPollTick = 5 * time.Second
|
||||
relayConnectorPollLimit = 18
|
||||
)
|
||||
|
||||
// RelayResource 是链路单个云资源的状态;Created 标记本次调用新建,供失败回滚判据。
|
||||
type RelayResource struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// RelayState 是回传链路四资源现状;已删除的资源不出现(ID 为空)。
|
||||
type RelayState struct {
|
||||
Topic RelayResource `json:"topic"`
|
||||
Subscription RelayResource `json:"subscription"`
|
||||
Connector RelayResource `json:"connector"`
|
||||
Policy RelayResource `json:"policy"`
|
||||
}
|
||||
|
||||
// RelayEventCondition 生成 Connector Log Filter 条件:按 eventName 白名单收窄回传。
|
||||
func RelayEventCondition(events []string) string {
|
||||
terms := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
terms = append(terms, fmt.Sprintf("data.eventName='%s'", e))
|
||||
}
|
||||
return strings.Join(terms, " or ")
|
||||
}
|
||||
|
||||
func (c *RealClient) onsControlClient(cred Credentials) (ons.NotificationControlPlaneClient, error) {
|
||||
cp, err := ons.NewNotificationControlPlaneClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return cp, fmt.Errorf("new ons control client: %w", err)
|
||||
}
|
||||
applyProxy(&cp.BaseClient, cred)
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (c *RealClient) onsDataClient(cred Credentials) (ons.NotificationDataPlaneClient, error) {
|
||||
dp, err := ons.NewNotificationDataPlaneClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return dp, fmt.Errorf("new ons data client: %w", err)
|
||||
}
|
||||
applyProxy(&dp.BaseClient, cred)
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, error) {
|
||||
sc, err := sch.NewServiceConnectorClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return sc, fmt.Errorf("new sch client: %w", err)
|
||||
}
|
||||
applyProxy(&sc.BaseClient, cred)
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
|
||||
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
||||
cp, err := c.onsControlClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
|
||||
return res, err
|
||||
}
|
||||
name, err := relayTopicNewName()
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
||||
CreateTopicDetails: ons.CreateTopicDetails{
|
||||
Name: &name,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("create ons topic: %w", err)
|
||||
}
|
||||
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
||||
}
|
||||
|
||||
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。
|
||||
func relayTopicNewName() (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("topic name suffix: %w", err)
|
||||
}
|
||||
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
|
||||
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
|
||||
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
||||
for page := 0; page < relayTopicPages; page++ {
|
||||
list, err := cp.ListTopics(ctx, req)
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
||||
}
|
||||
for _, t := range list.Items {
|
||||
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
|
||||
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
|
||||
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
|
||||
}
|
||||
}
|
||||
if list.OpcNextPage == nil {
|
||||
break
|
||||
}
|
||||
req.Page = list.OpcNextPage
|
||||
}
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
||||
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
||||
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
||||
dp, err := c.onsDataClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if res, ok, err := findRelaySubscription(ctx, dp, cred.TenancyOCID, topicID, endpoint); err != nil || ok {
|
||||
return res, err
|
||||
}
|
||||
created, err := dp.CreateSubscription(ctx, ons.CreateSubscriptionRequest{
|
||||
CreateSubscriptionDetails: ons.CreateSubscriptionDetails{
|
||||
TopicId: &topicID,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Protocol: common.String("CUSTOM_HTTPS"),
|
||||
Endpoint: &endpoint,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("create ons subscription: %w", err)
|
||||
}
|
||||
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||
}
|
||||
|
||||
// findRelaySubscription 在 Topic 下按 endpoint 查找存活订阅。
|
||||
func findRelaySubscription(ctx context.Context, dp ons.NotificationDataPlaneClient, tenancy, topicID, endpoint string) (RelayResource, bool, error) {
|
||||
if endpoint == "" {
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
list, err := dp.ListSubscriptions(ctx, ons.ListSubscriptionsRequest{
|
||||
CompartmentId: &tenancy, TopicId: &topicID,
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list ons subscriptions: %w", err)
|
||||
}
|
||||
for _, s := range list.Items {
|
||||
if deref(s.Endpoint) == endpoint && s.LifecycleState != ons.SubscriptionSummaryLifecycleStateDeleted {
|
||||
return RelayResource{ID: deref(s.Id), State: string(s.LifecycleState)}, true, nil
|
||||
}
|
||||
}
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// GetRelaySubscription 实现 Client:查询订阅当前状态,供确认期轮询。
|
||||
func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) (RelayResource, error) {
|
||||
dp, err := c.onsDataClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
resp, err := dp.GetSubscription(ctx, ons.GetSubscriptionRequest{SubscriptionId: &subscriptionID})
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("get ons subscription: %w", err)
|
||||
}
|
||||
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
||||
}
|
||||
|
||||
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
|
||||
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("list policies: %w", err)
|
||||
}
|
||||
if len(list.Items) > 0 {
|
||||
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
|
||||
}
|
||||
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID)
|
||||
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
||||
CreatePolicyDetails: identity.CreatePolicyDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Name: common.String(relayPolicyName),
|
||||
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
|
||||
Statements: []string{stmt},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("create policy: %w", err)
|
||||
}
|
||||
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||
}
|
||||
|
||||
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
|
||||
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
|
||||
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
||||
sc, err := c.schClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil || ok {
|
||||
return res, err
|
||||
}
|
||||
details := sch.CreateServiceConnectorDetails{
|
||||
DisplayName: common.String(relayConnectorName),
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
LogGroupId: common.String(relayAuditLogGroup),
|
||||
}}},
|
||||
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
||||
}
|
||||
if condition != "" {
|
||||
details.Tasks = []sch.TaskDetails{sch.LogRuleTaskDetails{Condition: &condition}}
|
||||
}
|
||||
if _, err := sc.CreateServiceConnector(ctx, sch.CreateServiceConnectorRequest{
|
||||
CreateServiceConnectorDetails: details,
|
||||
}); err != nil {
|
||||
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
||||
}
|
||||
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||
}
|
||||
|
||||
// findRelayConnector 按名查找存活 Connector。
|
||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
||||
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
||||
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||
}
|
||||
}
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
||||
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) {
|
||||
last := RelayResource{Created: true}
|
||||
for i := 0; i < relayConnectorPollLimit; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return last, ctx.Err()
|
||||
case <-time.After(relayConnectorPollTick):
|
||||
}
|
||||
res, ok, err := findRelayConnector(ctx, sc, tenancy)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
if ok {
|
||||
last, last.Created = res, true
|
||||
if res.State == string(sch.LifecycleStateActive) {
|
||||
return last, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return last, fmt.Errorf("service connector not active after %s", time.Duration(relayConnectorPollLimit)*relayConnectorPollTick)
|
||||
}
|
||||
|
||||
// RelayState 实现 Client:聚合链路四资源现状;endpoint 为空时不匹配订阅。
|
||||
func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint string) (RelayState, error) {
|
||||
var st RelayState
|
||||
cp, err := c.onsControlClient(cred)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Topic.ID != "" {
|
||||
dp, err := c.onsDataClient(cred)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Subscription, _, err = findRelaySubscription(ctx, dp, cred.TenancyOCID, st.Topic.ID, endpoint); err != nil {
|
||||
return st, err
|
||||
}
|
||||
}
|
||||
return c.relayControlState(ctx, cred, st)
|
||||
}
|
||||
|
||||
// relayControlState 补齐 Connector 与 Policy 两项状态。
|
||||
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
||||
sc, err := c.schClient(cred)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil {
|
||||
return st, err
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, "")
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||
})
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("list policies: %w", err)
|
||||
}
|
||||
if len(list.Items) > 0 {
|
||||
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// DeleteRelayConnector 实现 Client。
|
||||
func (c *RealClient) DeleteRelayConnector(ctx context.Context, cred Credentials, connectorID string) error {
|
||||
sc, err := c.schClient(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sc.DeleteServiceConnector(ctx, sch.DeleteServiceConnectorRequest{ServiceConnectorId: &connectorID}); err != nil {
|
||||
return fmt.Errorf("delete service connector: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRelayPolicy 实现 Client;IAM 写操作发往 home region。
|
||||
func (c *RealClient) DeleteRelayPolicy(ctx context.Context, cred Credentials, homeRegion, policyID string) error {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ic.DeletePolicy(ctx, identity.DeletePolicyRequest{PolicyId: &policyID}); err != nil {
|
||||
return fmt.Errorf("delete policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRelaySubscription 实现 Client。
|
||||
func (c *RealClient) DeleteRelaySubscription(ctx context.Context, cred Credentials, subscriptionID string) error {
|
||||
dp, err := c.onsDataClient(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dp.DeleteSubscription(ctx, ons.DeleteSubscriptionRequest{SubscriptionId: &subscriptionID}); err != nil {
|
||||
return fmt.Errorf("delete ons subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRelayTopic 实现 Client。
|
||||
func (c *RealClient) DeleteRelayTopic(ctx context.Context, cred Credentials, topicID string) error {
|
||||
cp, err := c.onsControlClient(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cp.DeleteTopic(ctx, ons.DeleteTopicRequest{TopicId: &topicID}); err != nil {
|
||||
return fmt.Errorf("delete ons topic: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRelayEventCondition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
events []string
|
||||
want string
|
||||
}{
|
||||
{name: "单事件", events: []string{"LaunchInstance"}, want: "data.eventName='LaunchInstance'"},
|
||||
{
|
||||
name: "多事件 or 连接",
|
||||
events: []string{"CreateUser", "DeleteUser"},
|
||||
want: "data.eventName='CreateUser' or data.eventName='DeleteUser'",
|
||||
},
|
||||
{name: "空清单", events: nil, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := RelayEventCondition(tt.events); got != tt.want {
|
||||
t.Errorf("condition = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayEventConditionQuoting(t *testing.T) {
|
||||
// 清单值来自代码内常量,不受外部输入;此处只固化引号格式防手滑
|
||||
cond := RelayEventCondition([]string{"InstanceAction"})
|
||||
if strings.Count(cond, "'") != 2 {
|
||||
t.Errorf("单引号数 = %d, want 2 (%s)", strings.Count(cond, "'"), cond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// VCN 是一个虚拟云网络。
|
||||
type VCN struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
CompartmentID string `json:"compartmentId"`
|
||||
CidrBlocks []string `json:"cidrBlocks"`
|
||||
Ipv6CidrBlocks []string `json:"ipv6CidrBlocks"`
|
||||
DnsLabel string `json:"dnsLabel"`
|
||||
DefaultRouteTableID string `json:"defaultRouteTableId"`
|
||||
DefaultSecurityListID string `json:"defaultSecurityListId"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// CreateVCNInput 是创建 VCN 的输入。
|
||||
type CreateVCNInput struct {
|
||||
Region string
|
||||
CompartmentID string // 目标 compartment,空为租户根
|
||||
DisplayName string
|
||||
CidrBlock string
|
||||
DnsLabel string
|
||||
EnableIPv6 bool // 创建时同时分配 Oracle GUA IPv6 /56
|
||||
}
|
||||
|
||||
// Subnet 是 VCN 下的一个子网。
|
||||
type Subnet struct {
|
||||
ID string `json:"id"`
|
||||
VcnID string `json:"vcnId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
CidrBlock string `json:"cidrBlock"`
|
||||
Ipv6CidrBlock string `json:"ipv6CidrBlock"`
|
||||
DnsLabel string `json:"dnsLabel"`
|
||||
ProhibitPublicIP bool `json:"prohibitPublicIp"`
|
||||
AvailabilityDomain string `json:"availabilityDomain"`
|
||||
RouteTableID string `json:"routeTableId"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// CreateSubnetInput 是创建子网的输入。
|
||||
type CreateSubnetInput struct {
|
||||
Region string
|
||||
VcnID string
|
||||
DisplayName string
|
||||
CidrBlock string
|
||||
DnsLabel string
|
||||
Ipv6CidrBlock string
|
||||
ProhibitPublicIP bool
|
||||
}
|
||||
|
||||
// SecurityRule 是安全列表中的一条规则;TCP/UDP 只支持目标端口范围。
|
||||
type SecurityRule struct {
|
||||
Protocol string `json:"protocol"` // all、6(TCP)、17(UDP)、1(ICMP)、58(ICMPv6)
|
||||
Source string `json:"source,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
IsStateless bool `json:"isStateless"`
|
||||
PortMin *int `json:"portMin,omitempty"`
|
||||
PortMax *int `json:"portMax,omitempty"`
|
||||
IcmpType *int `json:"icmpType,omitempty"`
|
||||
IcmpCode *int `json:"icmpCode,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityList 是一个安全列表及其规则。
|
||||
type SecurityList struct {
|
||||
ID string `json:"id"`
|
||||
VcnID string `json:"vcnId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
IngressRules []SecurityRule `json:"ingressRules"`
|
||||
EgressRules []SecurityRule `json:"egressRules"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// CreateSecurityListInput 是创建安全列表的输入。
|
||||
type CreateSecurityListInput struct {
|
||||
Region string
|
||||
VcnID string
|
||||
DisplayName string
|
||||
IngressRules []SecurityRule
|
||||
EgressRules []SecurityRule
|
||||
}
|
||||
|
||||
// UpdateSecurityListInput 是更新安全列表的输入;nil 字段保持不变,
|
||||
// 规则字段一经提供即整体替换。
|
||||
type UpdateSecurityListInput struct {
|
||||
Region string
|
||||
DisplayName string
|
||||
IngressRules *[]SecurityRule
|
||||
EgressRules *[]SecurityRule
|
||||
}
|
||||
|
||||
// 自动建网使用的固定名称,便于复用而非每次新建 VCN。
|
||||
const (
|
||||
autoVcnName = "oci-portal-auto-vcn"
|
||||
autoSubnetName = "oci-portal-auto-subnet"
|
||||
autoVcnCidr = "10.0.0.0/16"
|
||||
autoSubnetCidr = "10.0.0.0/24"
|
||||
)
|
||||
|
||||
// ensureDefaultSubnet 在 subnetId 为空时准备一个可上公网的子网:
|
||||
// 复用已有的 oci-portal-auto-vcn,否则新建 VCN + IGW + 默认路由 + 子网。
|
||||
func (c *RealClient) ensureDefaultSubnet(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
vcns, err := c.ListVCNs(ctx, cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, v := range vcns {
|
||||
if v.DisplayName != autoVcnName {
|
||||
continue
|
||||
}
|
||||
// 列表项自带 VCN 所在 compartment,直接用它列子网,免去重复 GetVCN
|
||||
subnets, err := listSubnets(ctx, vn, *hostCompartment(cred, v.CompartmentID), v.ID)
|
||||
if err == nil && len(subnets) > 0 {
|
||||
return subnets[0].ID, nil
|
||||
}
|
||||
}
|
||||
return c.createDefaultNetwork(ctx, cred, region)
|
||||
}
|
||||
|
||||
func (c *RealClient) createDefaultNetwork(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
vcn, err := c.CreateVCN(ctx, cred, CreateVCNInput{
|
||||
Region: region, DisplayName: autoVcnName, CidrBlock: autoVcnCidr, DnsLabel: "ociportalauto",
|
||||
EnableIPv6: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// IGW 与子网都建在新 VCN 自己的 compartment,与其余关联资源保持同域
|
||||
vcnCompartment := *hostCompartment(cred, vcn.CompartmentID)
|
||||
igwID, err := ensureInternetGateway(ctx, vn, vcnCompartment, vcn.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := addInternetRoute(ctx, vn, vcn.DefaultRouteTableID, igwID, "0.0.0.0/0"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
subnet, err := c.createSubnetIn(ctx, cred, vcnCompartment, CreateSubnetInput{
|
||||
Region: region, VcnID: vcn.ID, DisplayName: autoSubnetName, CidrBlock: autoSubnetCidr, DnsLabel: "ociauto",
|
||||
Ipv6CidrBlock: autoSubnetIPv6Cidr(ctx, vn, vcn.ID, vcn.DefaultRouteTableID, igwID),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return subnet.ID, nil
|
||||
}
|
||||
|
||||
// autoSubnetIPv6Cidr 等待自动 VCN 的 IPv6 /56 分配就绪并补 ::/0 路由,
|
||||
// 返回子网可用的 /64;IPv6 尽力而为,失败返回空串降级纯 IPv4 不阻塞建网。
|
||||
func autoSubnetIPv6Cidr(ctx context.Context, vn core.VirtualNetworkClient, vcnID, routeTableID, igwID string) string {
|
||||
vcnCidr, err := waitVCNIPv6Cidr(ctx, vn, vcnID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if err := addInternetRoute(ctx, vn, routeTableID, igwID, ipv6AnyCIDR); err != nil {
|
||||
return ""
|
||||
}
|
||||
cidr, err := nextSubnetIPv6CIDR(vcnCidr, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cidr
|
||||
}
|
||||
|
||||
// addInternetRoute 在路由表补 dest → IGW,已存在则跳过。
|
||||
func addInternetRoute(ctx context.Context, vn core.VirtualNetworkClient, routeTableID, igwID, dest string) error {
|
||||
resp, err := vn.GetRouteTable(ctx, core.GetRouteTableRequest{RtId: &routeTableID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get route table: %w", err)
|
||||
}
|
||||
for _, rule := range resp.RouteRules {
|
||||
if deref(rule.Destination) == dest {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
rules := append(resp.RouteRules, core.RouteRule{
|
||||
Destination: &dest,
|
||||
DestinationType: core.RouteRuleDestinationTypeCidrBlock,
|
||||
NetworkEntityId: &igwID,
|
||||
})
|
||||
_, err = vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||
RtId: &routeTableID,
|
||||
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: rules},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add internet route %s: %w", dest, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RealClient) vcnClient(cred Credentials, region string) (core.VirtualNetworkClient, error) {
|
||||
vn, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return vn, fmt.Errorf("new virtual network client: %w", err)
|
||||
}
|
||||
applyProxy(&vn.BaseClient, cred)
|
||||
if region != "" {
|
||||
vn.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return vn, nil
|
||||
}
|
||||
|
||||
// ListVCNs 实现 Client。
|
||||
func (c *RealClient) ListVCNs(ctx context.Context, cred Credentials, region string) ([]VCN, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var vcns []VCN
|
||||
var page *string
|
||||
for {
|
||||
resp, err := vn.ListVcns(ctx, core.ListVcnsRequest{CompartmentId: cred.EffectiveCompartment(), Page: page})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vcns: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
vcns = append(vcns, toVCN(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(vcns), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// CreateVCN 实现 Client;创建后将默认安全列表放开为出入方向全放行。
|
||||
func (c *RealClient) CreateVCN(ctx context.Context, cred Credentials, in CreateVCNInput) (VCN, error) {
|
||||
vn, err := c.vcnClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return VCN{}, err
|
||||
}
|
||||
details := core.CreateVcnDetails{
|
||||
CompartmentId: cred.EffectiveCompartment(),
|
||||
CidrBlocks: []string{in.CidrBlock},
|
||||
DisplayName: &in.DisplayName,
|
||||
}
|
||||
if in.DnsLabel != "" {
|
||||
details.DnsLabel = &in.DnsLabel
|
||||
}
|
||||
if in.EnableIPv6 {
|
||||
details.IsIpv6Enabled = &in.EnableIPv6
|
||||
}
|
||||
resp, err := vn.CreateVcn(ctx, core.CreateVcnRequest{CreateVcnDetails: details})
|
||||
if err != nil {
|
||||
return VCN{}, fmt.Errorf("create vcn: %w", err)
|
||||
}
|
||||
vcn := toVCN(resp.Vcn)
|
||||
// VCN 初始化会异步写入默认安全列表的初始规则,未就绪时替换会被覆盖
|
||||
if err := waitVCNAvailable(ctx, vn, vcn.ID); err != nil {
|
||||
return vcn, err
|
||||
}
|
||||
if err := openDefaultSecurityList(ctx, vn, vcn.DefaultSecurityListID, in.EnableIPv6); err != nil {
|
||||
return vcn, err
|
||||
}
|
||||
return vcn, nil
|
||||
}
|
||||
|
||||
// waitVCNAvailable 轮询等待 VCN 进入 AVAILABLE,最长约 60 秒。
|
||||
func waitVCNAvailable(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll vcn state: %w", err)
|
||||
}
|
||||
if resp.LifecycleState == core.VcnLifecycleStateAvailable {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("poll vcn state: %w", ctx.Err())
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("poll vcn state: timed out waiting for AVAILABLE")
|
||||
}
|
||||
|
||||
// openDefaultSecurityList 将安全列表规则替换为出入方向全放行;
|
||||
// includeIPv6 时附带 ::/0 规则(OCI 拒绝在未启用 IPv6 的 VCN 上写入)。
|
||||
func openDefaultSecurityList(ctx context.Context, vn core.VirtualNetworkClient, securityListID string, includeIPv6 bool) error {
|
||||
protocol := "all"
|
||||
v4, v6 := "0.0.0.0/0", ipv6AnyCIDR
|
||||
ingress := []core.IngressSecurityRule{{Protocol: &protocol, Source: &v4}}
|
||||
egress := []core.EgressSecurityRule{{Protocol: &protocol, Destination: &v4}}
|
||||
if includeIPv6 {
|
||||
ingress = append(ingress, core.IngressSecurityRule{Protocol: &protocol, Source: &v6})
|
||||
egress = append(egress, core.EgressSecurityRule{Protocol: &protocol, Destination: &v6})
|
||||
}
|
||||
_, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||
SecurityListId: &securityListID,
|
||||
UpdateSecurityListDetails: core.UpdateSecurityListDetails{
|
||||
IngressSecurityRules: ingress,
|
||||
EgressSecurityRules: egress,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open default security list %s: %w", securityListID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVCN 实现 Client。
|
||||
func (c *RealClient) GetVCN(ctx context.Context, cred Credentials, region, vcnID string) (VCN, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return VCN{}, err
|
||||
}
|
||||
resp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return VCN{}, fmt.Errorf("get vcn %s: %w", vcnID, err)
|
||||
}
|
||||
return toVCN(resp.Vcn), nil
|
||||
}
|
||||
|
||||
// UpdateVCN 实现 Client:当前仅支持改名。
|
||||
func (c *RealClient) UpdateVCN(ctx context.Context, cred Credentials, region, vcnID, displayName string) (VCN, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return VCN{}, err
|
||||
}
|
||||
resp, err := vn.UpdateVcn(ctx, core.UpdateVcnRequest{
|
||||
VcnId: &vcnID,
|
||||
UpdateVcnDetails: core.UpdateVcnDetails{DisplayName: &displayName},
|
||||
})
|
||||
if err != nil {
|
||||
return VCN{}, fmt.Errorf("update vcn %s: %w", vcnID, err)
|
||||
}
|
||||
return toVCN(resp.Vcn), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
return deleteVcnCascade(ctx, vn, vcnID)
|
||||
}
|
||||
|
||||
// vcnScopedCompartment 返回按 VCN 定位的查询与创建应使用的 compartment:
|
||||
// OCI 要求 compartmentId 与资源实际所在 compartment 一致,vcnID 非空时取
|
||||
// VCN 自身所在 compartment,为空(全 compartment 列表)时用生效 compartment。
|
||||
func (c *RealClient) vcnScopedCompartment(ctx context.Context, cred Credentials, region, vcnID string) (string, error) {
|
||||
if vcnID == "" {
|
||||
return *cred.EffectiveCompartment(), nil
|
||||
}
|
||||
vcn, err := c.GetVCN(ctx, cred, region, vcnID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *hostCompartment(cred, vcn.CompartmentID), nil
|
||||
}
|
||||
|
||||
// ListSubnets 实现 Client;vcnID 为空时列出全部子网。
|
||||
func (c *RealClient) ListSubnets(ctx context.Context, cred Credentials, region, vcnID string) ([]Subnet, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listSubnets(ctx, vn, compartmentID, vcnID)
|
||||
}
|
||||
|
||||
func listSubnets(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) ([]Subnet, error) {
|
||||
var subnets []Subnet
|
||||
var page *string
|
||||
for {
|
||||
req := core.ListSubnetsRequest{CompartmentId: &compartmentID, Page: page}
|
||||
if vcnID != "" {
|
||||
req.VcnId = &vcnID
|
||||
}
|
||||
resp, err := vn.ListSubnets(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list subnets: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
subnets = append(subnets, toSubnet(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(subnets), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSubnet 实现 Client;不传 AD 时创建 regional 子网。
|
||||
// 子网跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。
|
||||
func (c *RealClient) CreateSubnet(ctx context.Context, cred Credentials, in CreateSubnetInput) (Subnet, error) {
|
||||
compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID)
|
||||
if err != nil {
|
||||
return Subnet{}, err
|
||||
}
|
||||
return c.createSubnetIn(ctx, cred, compartmentID, in)
|
||||
}
|
||||
|
||||
// createSubnetIn 在指定 compartment 创建子网,供已知 VCN compartment
|
||||
// 的调用方(如自动建网)复用,免去一次重复的 GetVCN。
|
||||
func (c *RealClient) createSubnetIn(ctx context.Context, cred Credentials, compartmentID string, in CreateSubnetInput) (Subnet, error) {
|
||||
vn, err := c.vcnClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return Subnet{}, err
|
||||
}
|
||||
resp, err := vn.CreateSubnet(ctx, core.CreateSubnetRequest{CreateSubnetDetails: buildSubnetDetails(compartmentID, in)})
|
||||
if err != nil {
|
||||
return Subnet{}, fmt.Errorf("create subnet: %w", err)
|
||||
}
|
||||
return toSubnet(resp.Subnet), nil
|
||||
}
|
||||
|
||||
// buildSubnetDetails 组装创建子网请求体;可选字段仅在非零值时下发。
|
||||
func buildSubnetDetails(compartmentID string, in CreateSubnetInput) core.CreateSubnetDetails {
|
||||
details := core.CreateSubnetDetails{
|
||||
CompartmentId: &compartmentID,
|
||||
VcnId: &in.VcnID,
|
||||
CidrBlock: &in.CidrBlock,
|
||||
DisplayName: &in.DisplayName,
|
||||
}
|
||||
if in.DnsLabel != "" {
|
||||
details.DnsLabel = &in.DnsLabel
|
||||
}
|
||||
if in.Ipv6CidrBlock != "" {
|
||||
details.Ipv6CidrBlock = &in.Ipv6CidrBlock
|
||||
}
|
||||
if in.ProhibitPublicIP {
|
||||
details.ProhibitPublicIpOnVnic = &in.ProhibitPublicIP
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// GetSubnet 实现 Client。
|
||||
func (c *RealClient) GetSubnet(ctx context.Context, cred Credentials, region, subnetID string) (Subnet, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return Subnet{}, err
|
||||
}
|
||||
resp, err := vn.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &subnetID})
|
||||
if err != nil {
|
||||
return Subnet{}, fmt.Errorf("get subnet %s: %w", subnetID, err)
|
||||
}
|
||||
return toSubnet(resp.Subnet), nil
|
||||
}
|
||||
|
||||
// UpdateSubnet 实现 Client:当前仅支持改名。
|
||||
func (c *RealClient) UpdateSubnet(ctx context.Context, cred Credentials, region, subnetID, displayName string) (Subnet, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return Subnet{}, err
|
||||
}
|
||||
resp, err := vn.UpdateSubnet(ctx, core.UpdateSubnetRequest{
|
||||
SubnetId: &subnetID,
|
||||
UpdateSubnetDetails: core.UpdateSubnetDetails{DisplayName: &displayName},
|
||||
})
|
||||
if err != nil {
|
||||
return Subnet{}, fmt.Errorf("update subnet %s: %w", subnetID, err)
|
||||
}
|
||||
return toSubnet(resp.Subnet), nil
|
||||
}
|
||||
|
||||
// DeleteSubnet 实现 Client。
|
||||
func (c *RealClient) DeleteSubnet(ctx context.Context, cred Credentials, region, subnetID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vn.DeleteSubnet(ctx, core.DeleteSubnetRequest{SubnetId: &subnetID}); err != nil {
|
||||
return fmt.Errorf("delete subnet %s: %w", subnetID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSecurityLists 实现 Client;vcnID 为空时列出全部安全列表。
|
||||
func (c *RealClient) ListSecurityLists(ctx context.Context, cred Credentials, region, vcnID string) ([]SecurityList, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compartmentID, err := c.vcnScopedCompartment(ctx, cred, region, vcnID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lists []SecurityList
|
||||
var page *string
|
||||
for {
|
||||
req := core.ListSecurityListsRequest{CompartmentId: &compartmentID, Page: page}
|
||||
if vcnID != "" {
|
||||
req.VcnId = &vcnID
|
||||
}
|
||||
resp, err := vn.ListSecurityLists(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list security lists: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
lists = append(lists, toSecurityList(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(lists), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSecurityList 实现 Client。
|
||||
// 安全列表跟随其 VCN 所在 compartment 创建,避免跨 compartment 悬挂资源。
|
||||
func (c *RealClient) CreateSecurityList(ctx context.Context, cred Credentials, in CreateSecurityListInput) (SecurityList, error) {
|
||||
vn, err := c.vcnClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return SecurityList{}, err
|
||||
}
|
||||
compartmentID, err := c.vcnScopedCompartment(ctx, cred, in.Region, in.VcnID)
|
||||
if err != nil {
|
||||
return SecurityList{}, err
|
||||
}
|
||||
resp, err := vn.CreateSecurityList(ctx, core.CreateSecurityListRequest{
|
||||
CreateSecurityListDetails: core.CreateSecurityListDetails{
|
||||
CompartmentId: &compartmentID,
|
||||
VcnId: &in.VcnID,
|
||||
DisplayName: &in.DisplayName,
|
||||
IngressSecurityRules: toIngressRules(in.IngressRules),
|
||||
EgressSecurityRules: toEgressRules(in.EgressRules),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return SecurityList{}, fmt.Errorf("create security list: %w", err)
|
||||
}
|
||||
return toSecurityList(resp.SecurityList), nil
|
||||
}
|
||||
|
||||
// GetSecurityList 实现 Client。
|
||||
func (c *RealClient) GetSecurityList(ctx context.Context, cred Credentials, region, securityListID string) (SecurityList, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return SecurityList{}, err
|
||||
}
|
||||
resp, err := vn.GetSecurityList(ctx, core.GetSecurityListRequest{SecurityListId: &securityListID})
|
||||
if err != nil {
|
||||
return SecurityList{}, fmt.Errorf("get security list %s: %w", securityListID, err)
|
||||
}
|
||||
return toSecurityList(resp.SecurityList), nil
|
||||
}
|
||||
|
||||
// UpdateSecurityList 实现 Client。
|
||||
func (c *RealClient) UpdateSecurityList(ctx context.Context, cred Credentials, securityListID string, in UpdateSecurityListInput) (SecurityList, error) {
|
||||
vn, err := c.vcnClient(cred, in.Region)
|
||||
if err != nil {
|
||||
return SecurityList{}, err
|
||||
}
|
||||
details := core.UpdateSecurityListDetails{}
|
||||
if in.DisplayName != "" {
|
||||
details.DisplayName = &in.DisplayName
|
||||
}
|
||||
if in.IngressRules != nil {
|
||||
details.IngressSecurityRules = toIngressRules(*in.IngressRules)
|
||||
}
|
||||
if in.EgressRules != nil {
|
||||
details.EgressSecurityRules = toEgressRules(*in.EgressRules)
|
||||
}
|
||||
resp, err := vn.UpdateSecurityList(ctx, core.UpdateSecurityListRequest{
|
||||
SecurityListId: &securityListID,
|
||||
UpdateSecurityListDetails: details,
|
||||
})
|
||||
if err != nil {
|
||||
return SecurityList{}, fmt.Errorf("update security list %s: %w", securityListID, err)
|
||||
}
|
||||
return toSecurityList(resp.SecurityList), nil
|
||||
}
|
||||
|
||||
// DeleteSecurityList 实现 Client。
|
||||
func (c *RealClient) DeleteSecurityList(ctx context.Context, cred Credentials, region, securityListID string) error {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vn.DeleteSecurityList(ctx, core.DeleteSecurityListRequest{SecurityListId: &securityListID}); err != nil {
|
||||
return fmt.Errorf("delete security list %s: %w", securityListID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toVCN(v core.Vcn) VCN {
|
||||
return VCN{
|
||||
ID: deref(v.Id),
|
||||
DisplayName: deref(v.DisplayName),
|
||||
LifecycleState: string(v.LifecycleState),
|
||||
CompartmentID: deref(v.CompartmentId),
|
||||
CidrBlocks: orEmpty(v.CidrBlocks),
|
||||
Ipv6CidrBlocks: orEmpty(v.Ipv6CidrBlocks),
|
||||
DnsLabel: deref(v.DnsLabel),
|
||||
DefaultRouteTableID: deref(v.DefaultRouteTableId),
|
||||
DefaultSecurityListID: deref(v.DefaultSecurityListId),
|
||||
TimeCreated: sdkTime(v.TimeCreated),
|
||||
}
|
||||
}
|
||||
|
||||
func toSubnet(s core.Subnet) Subnet {
|
||||
return Subnet{
|
||||
ID: deref(s.Id),
|
||||
VcnID: deref(s.VcnId),
|
||||
DisplayName: deref(s.DisplayName),
|
||||
LifecycleState: string(s.LifecycleState),
|
||||
CidrBlock: deref(s.CidrBlock),
|
||||
Ipv6CidrBlock: deref(s.Ipv6CidrBlock),
|
||||
DnsLabel: deref(s.DnsLabel),
|
||||
ProhibitPublicIP: s.ProhibitPublicIpOnVnic != nil && *s.ProhibitPublicIpOnVnic,
|
||||
AvailabilityDomain: deref(s.AvailabilityDomain),
|
||||
RouteTableID: deref(s.RouteTableId),
|
||||
TimeCreated: sdkTime(s.TimeCreated),
|
||||
}
|
||||
}
|
||||
|
||||
func toSecurityList(sl core.SecurityList) SecurityList {
|
||||
out := SecurityList{
|
||||
ID: deref(sl.Id),
|
||||
VcnID: deref(sl.VcnId),
|
||||
DisplayName: deref(sl.DisplayName),
|
||||
LifecycleState: string(sl.LifecycleState),
|
||||
TimeCreated: sdkTime(sl.TimeCreated),
|
||||
IngressRules: make([]SecurityRule, 0, len(sl.IngressSecurityRules)),
|
||||
EgressRules: make([]SecurityRule, 0, len(sl.EgressSecurityRules)),
|
||||
}
|
||||
for _, r := range sl.IngressSecurityRules {
|
||||
rule := SecurityRule{
|
||||
Protocol: deref(r.Protocol),
|
||||
Source: deref(r.Source),
|
||||
IsStateless: r.IsStateless != nil && *r.IsStateless,
|
||||
Description: deref(r.Description),
|
||||
}
|
||||
fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions)
|
||||
out.IngressRules = append(out.IngressRules, rule)
|
||||
}
|
||||
for _, r := range sl.EgressSecurityRules {
|
||||
rule := SecurityRule{
|
||||
Protocol: deref(r.Protocol),
|
||||
Destination: deref(r.Destination),
|
||||
IsStateless: r.IsStateless != nil && *r.IsStateless,
|
||||
Description: deref(r.Description),
|
||||
}
|
||||
fillRuleOptions(&rule, r.TcpOptions, r.UdpOptions, r.IcmpOptions)
|
||||
out.EgressRules = append(out.EgressRules, rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fillRuleOptions(rule *SecurityRule, tcp *core.TcpOptions, udp *core.UdpOptions, icmp *core.IcmpOptions) {
|
||||
switch {
|
||||
case tcp != nil && tcp.DestinationPortRange != nil:
|
||||
rule.PortMin = tcp.DestinationPortRange.Min
|
||||
rule.PortMax = tcp.DestinationPortRange.Max
|
||||
case udp != nil && udp.DestinationPortRange != nil:
|
||||
rule.PortMin = udp.DestinationPortRange.Min
|
||||
rule.PortMax = udp.DestinationPortRange.Max
|
||||
case icmp != nil:
|
||||
rule.IcmpType = icmp.Type
|
||||
rule.IcmpCode = icmp.Code
|
||||
}
|
||||
}
|
||||
|
||||
func toIngressRules(rules []SecurityRule) []core.IngressSecurityRule {
|
||||
out := make([]core.IngressSecurityRule, 0, len(rules))
|
||||
for i := range rules {
|
||||
r := rules[i]
|
||||
sdk := core.IngressSecurityRule{
|
||||
Protocol: &r.Protocol,
|
||||
Source: &r.Source,
|
||||
IsStateless: &rules[i].IsStateless,
|
||||
}
|
||||
if r.Description != "" {
|
||||
sdk.Description = &rules[i].Description
|
||||
}
|
||||
sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r)
|
||||
out = append(out, sdk)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toEgressRules(rules []SecurityRule) []core.EgressSecurityRule {
|
||||
out := make([]core.EgressSecurityRule, 0, len(rules))
|
||||
for i := range rules {
|
||||
r := rules[i]
|
||||
sdk := core.EgressSecurityRule{
|
||||
Protocol: &r.Protocol,
|
||||
Destination: &r.Destination,
|
||||
IsStateless: &rules[i].IsStateless,
|
||||
}
|
||||
if r.Description != "" {
|
||||
sdk.Description = &rules[i].Description
|
||||
}
|
||||
sdk.TcpOptions, sdk.UdpOptions, sdk.IcmpOptions = buildRuleOptions(r)
|
||||
out = append(out, sdk)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildRuleOptions 按协议号构造端口或 ICMP 选项:6=TCP、17=UDP、1/58=ICMP(v6)。
|
||||
func buildRuleOptions(r SecurityRule) (*core.TcpOptions, *core.UdpOptions, *core.IcmpOptions) {
|
||||
if r.PortMin != nil {
|
||||
portMax := r.PortMax
|
||||
if portMax == nil {
|
||||
portMax = r.PortMin
|
||||
}
|
||||
pr := &core.PortRange{Min: r.PortMin, Max: portMax}
|
||||
switch r.Protocol {
|
||||
case "6":
|
||||
return &core.TcpOptions{DestinationPortRange: pr}, nil, nil
|
||||
case "17":
|
||||
return nil, &core.UdpOptions{DestinationPortRange: pr}, nil
|
||||
}
|
||||
}
|
||||
if r.IcmpType != nil && (r.Protocol == "1" || r.Protocol == "58") {
|
||||
return nil, nil, &core.IcmpOptions{Type: r.IcmpType, Code: r.IcmpCode}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
)
|
||||
|
||||
// ProxySpec 是租户关联的出站代理;密码已解密,仅在进程内传递。
|
||||
type ProxySpec struct {
|
||||
Type string // "socks5" / "http" / "https"
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// proxyClientTimeout 与 SDK 默认 HTTPClient 超时保持一致。
|
||||
const proxyClientTimeout = 60 * time.Second
|
||||
|
||||
// applyProxy 在 SDK client 构造后统一挂出站代理;未关联代理时不动默认配置。
|
||||
// 所有 New*ClientWithConfigurationProvider 调用点构造成功后都必须经过这里。
|
||||
func applyProxy(base *common.BaseClient, cred Credentials) {
|
||||
if hc := proxyHTTPClient(cred.Proxy); hc != nil {
|
||||
base.HTTPClient = hc
|
||||
}
|
||||
}
|
||||
|
||||
// proxyHTTPClient 按代理配置构造 http.Client;nil 或非法配置返回 nil(走直连)。
|
||||
func proxyHTTPClient(p *ProxySpec) *http.Client {
|
||||
if p == nil || p.Host == "" || p.Port <= 0 {
|
||||
return nil
|
||||
}
|
||||
tr := transportFor(p)
|
||||
if tr == nil {
|
||||
return nil
|
||||
}
|
||||
return &http.Client{Transport: tr, Timeout: proxyClientTimeout}
|
||||
}
|
||||
|
||||
// HTTPClientFor 供面板自身请求(如代理出口地理探测)复用与租户 SDK
|
||||
// 完全一致的代理链路;nil 或非法配置返回 nil。
|
||||
func HTTPClientFor(p *ProxySpec) *http.Client {
|
||||
return proxyHTTPClient(p)
|
||||
}
|
||||
|
||||
// transportFor 构造代理 Transport:http / https 走 CONNECT,socks5 走拨号器。
|
||||
func transportFor(p *ProxySpec) *http.Transport {
|
||||
addr := fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||
if p.Type == "http" || p.Type == "https" {
|
||||
u := &url.URL{Scheme: p.Type, Host: addr}
|
||||
if p.Username != "" {
|
||||
u.User = url.UserPassword(p.Username, p.Password)
|
||||
}
|
||||
return &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
}
|
||||
var auth *proxy.Auth
|
||||
if p.Username != "" {
|
||||
auth = &proxy.Auth{User: p.Username, Password: p.Password}
|
||||
}
|
||||
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cd, ok := d.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &http.Transport{DialContext: cd.DialContext}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// instanceVnics 列出实例的全部 VNIC,并一并返回实例所在 compartment,
|
||||
// 供调用方复用为后续按实例定位查询的 compartmentId,避免重复 GetInstance。
|
||||
func (c *RealClient) instanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]core.Vnic, string, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// VNIC 挂载记录在实例所在 compartment,先取实例定位,传租户根会漏查
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
compartmentID := hostCompartment(cred, deref(instResp.CompartmentId))
|
||||
resp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
|
||||
CompartmentId: compartmentID,
|
||||
InstanceId: &instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list vnic attachments: %w", err)
|
||||
}
|
||||
return attachedVnics(ctx, vn, resp.Items), *compartmentID, nil
|
||||
}
|
||||
|
||||
// attachedVnics 逐挂载记录查询 VNIC 详情,跳过查询失败的记录。
|
||||
func attachedVnics(ctx context.Context, vn core.VirtualNetworkClient, atts []core.VnicAttachment) []core.Vnic {
|
||||
vnics := make([]core.Vnic, 0, len(atts))
|
||||
for _, att := range atts {
|
||||
if att.VnicId == nil {
|
||||
continue
|
||||
}
|
||||
vnicResp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: att.VnicId})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vnics = append(vnics, vnicResp.Vnic)
|
||||
}
|
||||
return vnics
|
||||
}
|
||||
|
||||
// lookupPublicIP 查询私有 IP 绑定的公网 IP,未绑定返回 nil。
|
||||
func lookupPublicIP(ctx context.Context, vn core.VirtualNetworkClient, privateIPID *string) *core.PublicIp {
|
||||
resp, err := vn.GetPublicIpByPrivateIpId(ctx, core.GetPublicIpByPrivateIpIdRequest{
|
||||
GetPublicIpByPrivateIpIdDetails: core.GetPublicIpByPrivateIpIdDetails{PrivateIpId: privateIPID},
|
||||
})
|
||||
if err != nil || resp.Id == nil {
|
||||
return nil
|
||||
}
|
||||
return &resp.PublicIp
|
||||
}
|
||||
|
||||
// ChangeInstancePublicIP 实现 Client:为实例主 VNIC 主私有 IP 更换临时公网 IP。
|
||||
// 删除旧的临时公网 IP 再分配新的;若旧地址是保留 IP(RESERVED),拒绝操作。
|
||||
func (c *RealClient) ChangeInstancePublicIP(ctx context.Context, cred Credentials, region, instanceID string) (string, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, err := c.primaryPrivateIP(ctx, cred, region, instanceID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
// OCI 要求临时公网 IP 与其私有 IP 同 compartment,故不能用生效 compartment
|
||||
resp, err := vn.CreatePublicIp(ctx, core.CreatePublicIpRequest{
|
||||
CreatePublicIpDetails: core.CreatePublicIpDetails{
|
||||
CompartmentId: hostCompartment(cred, deref(priv.CompartmentId)),
|
||||
Lifetime: core.CreatePublicIpDetailsLifetimeEphemeral,
|
||||
PrivateIpId: priv.Id,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create public ip: %w", err)
|
||||
}
|
||||
return deref(resp.IpAddress), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return core.Vnic{}, err
|
||||
}
|
||||
for i := range vnics {
|
||||
if vnics[i].IsPrimary != nil && *vnics[i].IsPrimary {
|
||||
return vnics[i], nil
|
||||
}
|
||||
}
|
||||
return core.Vnic{}, fmt.Errorf("instance %s has no primary vnic", instanceID)
|
||||
}
|
||||
|
||||
// primaryPrivateIP 返回实例主 VNIC 上的主私有 IP 对象;
|
||||
// 保留完整对象是为了让调用方能拿到私有 IP 所在的 compartment。
|
||||
func (c *RealClient) primaryPrivateIP(ctx context.Context, cred Credentials, region, instanceID string) (core.PrivateIp, error) {
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return core.PrivateIp{}, err
|
||||
}
|
||||
vnic, err := c.primaryVnic(ctx, cred, region, instanceID)
|
||||
if err != nil {
|
||||
return core.PrivateIp{}, err
|
||||
}
|
||||
privResp, err := vn.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: vnic.Id})
|
||||
if err != nil {
|
||||
return core.PrivateIp{}, fmt.Errorf("list private ips: %w", err)
|
||||
}
|
||||
for _, priv := range privResp.Items {
|
||||
if priv.IsPrimary != nil && *priv.IsPrimary {
|
||||
return priv, nil
|
||||
}
|
||||
}
|
||||
return core.PrivateIp{}, fmt.Errorf("change public ip: primary vnic has no primary private ip")
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// TestRealOCIFederation 验证 Federation 全链路:下载域元数据 → 创建 IdP
|
||||
// (默认 JIT)→ 激活并上登录页 → 免 MFA 规则置顶 → 逐级回滚清理。
|
||||
// 只操作本测试创建的资源,现有 IdP 与规则只做顺延断言。
|
||||
func TestRealOCIFederation(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
region := cred.Region
|
||||
|
||||
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(metadata), "EntityDescriptor") {
|
||||
t.Fatalf("metadata is not SAML XML: %s", string(metadata[:min(len(metadata), 120)]))
|
||||
}
|
||||
t.Logf("domain saml metadata: %d bytes", len(metadata))
|
||||
|
||||
before, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules: %v", err)
|
||||
}
|
||||
logRules(t, "before", before)
|
||||
|
||||
idp := createTestIdp(ctx, t, client, cred, region)
|
||||
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
|
||||
|
||||
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("activate idp: %v", err)
|
||||
}
|
||||
if !activated.Enabled {
|
||||
t.Error("idp not enabled after activate")
|
||||
}
|
||||
assertLoginPage(ctx, t, client, cred, region, idp.ID, true)
|
||||
|
||||
exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before))
|
||||
|
||||
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("deactivate idp: %v", err)
|
||||
}
|
||||
if deactivated.Enabled {
|
||||
t.Error("idp still enabled after deactivate")
|
||||
}
|
||||
assertLoginPage(ctx, t, client, cred, region, idp.ID, false)
|
||||
}
|
||||
|
||||
func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region string) IdentityProviderInfo {
|
||||
t.Helper()
|
||||
metadata, err := os.ReadFile("../../test-idp.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read test-idp.xml: %v", err)
|
||||
}
|
||||
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
|
||||
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, CreateIdpInput{
|
||||
Name: name,
|
||||
Metadata: string(metadata),
|
||||
Description: "oci-portal e2e temporary idp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSamlIdentityProvider: %v", err)
|
||||
}
|
||||
t.Logf("idp created: %s (%s) partner=%s", idp.Name, idp.ID, idp.PartnerProviderID)
|
||||
if idp.Enabled {
|
||||
t.Error("new idp should be disabled")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer ccancel()
|
||||
if err := client.DeleteIdentityProvider(cctx, cred, region, idp.ID); err != nil {
|
||||
t.Errorf("cleanup: delete idp: %v", err)
|
||||
} else {
|
||||
t.Log("cleanup: idp deleted")
|
||||
}
|
||||
})
|
||||
return idp
|
||||
}
|
||||
|
||||
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
|
||||
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
|
||||
t.Helper()
|
||||
dc, err := client.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("domainsClient: %v", err)
|
||||
}
|
||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: &idpID})
|
||||
if err != nil {
|
||||
t.Fatalf("get idp: %v", err)
|
||||
}
|
||||
if deref(got.NameIdFormat) != "saml-none" {
|
||||
t.Errorf("nameIdFormat = %q, want saml-none", deref(got.NameIdFormat))
|
||||
}
|
||||
if got.UserMappingMethod != identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute || deref(got.UserMappingStoreAttribute) != "userName" {
|
||||
t.Errorf("user mapping = %s/%s, want NameIDToUserAttribute/userName", got.UserMappingMethod, deref(got.UserMappingStoreAttribute))
|
||||
}
|
||||
if got.JitUserProvEnabled == nil || !*got.JitUserProvEnabled || got.JitUserProvCreateUserEnabled == nil || !*got.JitUserProvCreateUserEnabled {
|
||||
t.Error("jit enable/create should be true")
|
||||
}
|
||||
if got.JitUserProvAttributeUpdateEnabled != nil && *got.JitUserProvAttributeUpdateEnabled {
|
||||
t.Error("jit attribute update should be false")
|
||||
}
|
||||
if len(got.JitUserProvAssignedGroups) != 1 || deref(got.JitUserProvAssignedGroups[0].Display) != "Administrators" {
|
||||
t.Errorf("jit assigned groups = %+v, want [Administrators]", got.JitUserProvAssignedGroups)
|
||||
}
|
||||
verifyJitMappings(ctx, t, dc, got.JitUserProvAttributes)
|
||||
}
|
||||
|
||||
func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.IdentityDomainsClient, ref *identitydomains.IdentityProviderJitUserProvAttributes) {
|
||||
t.Helper()
|
||||
if ref == nil || ref.Value == nil {
|
||||
t.Error("idp has no jit attribute mapping resource")
|
||||
return
|
||||
}
|
||||
ma, err := dc.GetMappedAttribute(ctx, identitydomains.GetMappedAttributeRequest{MappedAttributeId: ref.Value})
|
||||
if err != nil {
|
||||
t.Errorf("get mapped attribute: %v", err)
|
||||
return
|
||||
}
|
||||
want := map[string]bool{"userName": false, "name.familyName": false}
|
||||
for _, m := range ma.AttributeMappings {
|
||||
if deref(m.ManagedObjectAttributeName) == "$(assertion.fed.nameidvalue)" {
|
||||
want[deref(m.IdcsAttributeName)] = true
|
||||
}
|
||||
}
|
||||
for attr, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("jit mapping NameID value → %s missing; got %+v", attr, ma.AttributeMappings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
|
||||
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
|
||||
t.Helper()
|
||||
dc, err := client.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("domainsClient: %v", err)
|
||||
}
|
||||
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)})
|
||||
if err != nil {
|
||||
t.Fatalf("get default idp rule: %v", err)
|
||||
}
|
||||
for _, r := range rule.Return {
|
||||
if deref(r.Name) != "SamlIDPs" {
|
||||
continue
|
||||
}
|
||||
has := strings.Contains(deref(r.Value), idpID)
|
||||
if has != want {
|
||||
t.Errorf("login page SamlIDPs contains idp = %v, want %v (value=%s)", has, want, deref(r.Value))
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("DefaultIDPRule has no SamlIDPs return")
|
||||
}
|
||||
|
||||
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
|
||||
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
|
||||
t.Helper()
|
||||
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, idpID, "oci-portal-e2e-skip-mfa")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMfaExemptionRule: %v", err)
|
||||
}
|
||||
t.Logf("exemption rule created: %s (%s)", rule.Name, rule.ID)
|
||||
deleted := false
|
||||
defer func() {
|
||||
if deleted {
|
||||
return
|
||||
}
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||
t.Errorf("cleanup: delete exemption rule: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
after, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules after create: %v", err)
|
||||
}
|
||||
logRules(t, "after-create", after)
|
||||
if len(after) != beforeCount+1 {
|
||||
t.Errorf("rule count = %d, want %d", len(after), beforeCount+1)
|
||||
}
|
||||
if after[0].ID != rule.ID || after[0].Sequence != 1 {
|
||||
t.Errorf("top rule = %s seq=%d, want %s seq=1", after[0].ID, after[0].Sequence, rule.ID)
|
||||
}
|
||||
if after[0].AuthenticationFactor != "IDP" {
|
||||
t.Errorf("authenticationFactor = %s, want IDP", after[0].AuthenticationFactor)
|
||||
}
|
||||
if !strings.Contains(after[0].ConditionValue, idpID) {
|
||||
t.Errorf("condition value %q does not reference idp", after[0].ConditionValue)
|
||||
}
|
||||
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||
t.Fatalf("DeleteMfaExemptionRule: %v", err)
|
||||
}
|
||||
deleted = true
|
||||
restored, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
|
||||
}
|
||||
logRules(t, "after-delete", restored)
|
||||
if len(restored) != beforeCount {
|
||||
t.Errorf("rule count after delete = %d, want %d", len(restored), beforeCount)
|
||||
}
|
||||
for _, r := range restored {
|
||||
if r.ID == rule.ID {
|
||||
t.Error("exemption rule still present after delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logRules(t *testing.T, tag string, rules []SignOnRuleInfo) {
|
||||
t.Helper()
|
||||
for _, r := range rules {
|
||||
t.Logf("%s: seq=%d %s (%s) factor=%s", tag, r.Sequence, r.Name, r.ID, r.AuthenticationFactor)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRealOCIInstanceLifecycle 走真实实例完整生命周期:
|
||||
// 建网络 → 查 AD/镜像 → 创建 A1 实例 → 等待 RUNNING → 校验 IP 补全 →
|
||||
// 改名 → 引导卷查改 → STOP → 终止(不保留卷)→ 清理网络。
|
||||
// A1 容量不足时跳过(试用区域常见)。
|
||||
func TestRealOCIInstanceLifecycle(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||
|
||||
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{
|
||||
DisplayName: prefix + "-vcn", CidrBlock: "10.98.0.0/16", DnsLabel: "e2einst",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateVCN: %v", err)
|
||||
}
|
||||
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{
|
||||
VcnID: vcn.ID, DisplayName: prefix + "-subnet", CidrBlock: "10.98.1.0/24", DnsLabel: "e2esub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubnet: %v", err)
|
||||
}
|
||||
|
||||
ads, err := client.ListAvailabilityDomains(ctx, cred, "")
|
||||
if err != nil || len(ads) == 0 {
|
||||
t.Fatalf("ListAvailabilityDomains: %v (got %d)", err, len(ads))
|
||||
}
|
||||
images, err := client.ListImages(ctx, cred, ImagesQuery{
|
||||
OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex",
|
||||
})
|
||||
if err != nil || len(images) == 0 {
|
||||
t.Fatalf("ListImages: %v (got %d)", err, len(images))
|
||||
}
|
||||
t.Logf("using ad=%s image=%s", ads[0], images[0].DisplayName)
|
||||
|
||||
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||
AvailabilityDomain: ads[0],
|
||||
DisplayName: prefix + "-vm",
|
||||
Shape: "VM.Standard.A1.Flex",
|
||||
Ocpus: 1,
|
||||
MemoryInGBs: 6,
|
||||
ImageID: images[0].ID,
|
||||
BootVolumeSizeGBs: 50,
|
||||
SubnetID: subnet.ID,
|
||||
AssignPublicIP: true,
|
||||
SSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEfake+e2e+key oci-portal-e2e",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Out of host capacity") || strings.Contains(err.Error(), "OutOfCapacity") {
|
||||
t.Skipf("A1 capacity unavailable in this region: %v", err)
|
||||
}
|
||||
t.Fatalf("LaunchInstance: %v", err)
|
||||
}
|
||||
t.Logf("launched instance %s (%s)", instance.DisplayName, instance.ID)
|
||||
defer terminateAndWait(t, client, cred, instance.ID)
|
||||
|
||||
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||
t.Fatalf("wait RUNNING: %v", err)
|
||||
}
|
||||
got, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstance: %v", err)
|
||||
}
|
||||
t.Logf("instance running: shape=%s %gocpu/%gGB privateIp=%s publicIp=%s",
|
||||
got.Shape, got.Ocpus, got.MemoryInGBs, got.PrivateIP, got.PublicIP)
|
||||
if got.PrivateIP == "" {
|
||||
t.Error("PrivateIP empty, want enriched from VNIC")
|
||||
}
|
||||
if got.PublicIP == "" {
|
||||
t.Error("PublicIP empty, want assigned")
|
||||
}
|
||||
if got.SubnetID != subnet.ID {
|
||||
t.Errorf("SubnetID = %s, want %s", got.SubnetID, subnet.ID)
|
||||
}
|
||||
|
||||
if _, err := client.UpdateInstance(ctx, cred, instance.ID, UpdateInstanceInput{DisplayName: prefix + "-vm-renamed"}); err != nil {
|
||||
t.Errorf("UpdateInstance: %v", err)
|
||||
}
|
||||
|
||||
verifyBootVolume(ctx, t, client, cred, ads[0], instance.ID, prefix)
|
||||
|
||||
if _, err := client.InstanceAction(ctx, cred, "", instance.ID, "STOP"); err != nil {
|
||||
t.Fatalf("InstanceAction STOP: %v", err)
|
||||
}
|
||||
if err := waitInstanceState(ctx, client, cred, instance.ID, "STOPPED"); err != nil {
|
||||
t.Fatalf("wait STOPPED: %v", err)
|
||||
}
|
||||
t.Log("instance stopped, terminating")
|
||||
}
|
||||
|
||||
func verifyBootVolume(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, ad, instanceID, prefix string) {
|
||||
t.Helper()
|
||||
volumes, err := client.ListBootVolumes(ctx, cred, "", ad)
|
||||
if err != nil {
|
||||
t.Errorf("ListBootVolumes: %v", err)
|
||||
return
|
||||
}
|
||||
for _, v := range volumes {
|
||||
detail, err := client.GetBootVolume(ctx, cred, "", v.ID)
|
||||
if err != nil {
|
||||
t.Errorf("GetBootVolume: %v", err)
|
||||
continue
|
||||
}
|
||||
if detail.AttachedInstanceID != instanceID {
|
||||
continue
|
||||
}
|
||||
t.Logf("boot volume %s size=%dGB vpus=%d attached=%s",
|
||||
detail.DisplayName, detail.SizeInGBs, detail.VpusPerGB, detail.AttachedInstanceID)
|
||||
if detail.SizeInGBs != 50 {
|
||||
t.Errorf("boot volume size = %d, want 50", detail.SizeInGBs)
|
||||
}
|
||||
if _, err := client.UpdateBootVolume(ctx, cred, v.ID, UpdateBootVolumeInput{DisplayName: prefix + "-bv"}); err != nil {
|
||||
t.Errorf("UpdateBootVolume: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Errorf("no boot volume attached to instance %s found in %s", instanceID, ad)
|
||||
}
|
||||
|
||||
// waitInstanceState 轮询实例直到到达目标状态。
|
||||
func waitInstanceState(ctx context.Context, client *RealClient, cred Credentials, instanceID, want string) error {
|
||||
for i := 0; i < 60; i++ {
|
||||
inst, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll instance: %w", err)
|
||||
}
|
||||
if inst.LifecycleState == want {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("poll instance state %s: %w", want, ctx.Err())
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("poll instance state %s: timed out", want)
|
||||
}
|
||||
|
||||
// terminateAndWait 终止实例(不保留引导卷)并等待 TERMINATED,
|
||||
// 保证后续网络清理不被占用的 VNIC 阻塞。
|
||||
func terminateAndWait(t *testing.T, client *RealClient, cred Credentials, instanceID string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
inst, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||
if err == nil && inst.LifecycleState == "TERMINATED" {
|
||||
return
|
||||
}
|
||||
if err := client.TerminateInstance(ctx, cred, "", instanceID, false); err != nil {
|
||||
t.Errorf("cleanup: terminate instance: %v", err)
|
||||
return
|
||||
}
|
||||
if err := waitInstanceState(ctx, client, cred, instanceID, "TERMINATED"); err != nil {
|
||||
t.Errorf("cleanup: wait TERMINATED: %v (manual cleanup may be required)", err)
|
||||
return
|
||||
}
|
||||
t.Logf("cleanup: instance %s terminated", instanceID)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
func createVolumeRequest(tenancyOCID, ad, name string, size int64) core.CreateVolumeRequest {
|
||||
return core.CreateVolumeRequest{CreateVolumeDetails: core.CreateVolumeDetails{
|
||||
CompartmentId: &tenancyOCID,
|
||||
AvailabilityDomain: &ad,
|
||||
DisplayName: &name,
|
||||
SizeInGBs: &size,
|
||||
}}
|
||||
}
|
||||
|
||||
func getVolumeRequest(volID string) core.GetVolumeRequest {
|
||||
return core.GetVolumeRequest{VolumeId: &volID}
|
||||
}
|
||||
|
||||
func deleteVolumeRequest(volID string) core.DeleteVolumeRequest {
|
||||
return core.DeleteVolumeRequest{VolumeId: &volID}
|
||||
}
|
||||
|
||||
// e2eConsoleSSHKey 是控制台连接 e2e 用的一次性 RSA 公钥(私钥已丢弃,
|
||||
// 仅验证连接串生成);OCI 控制台连接只接受 RSA 公钥。
|
||||
const e2eConsoleSSHKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDXrOBK+eL3JPB3t470k7x+au3IcGbCy3Za7HVcMqTD2XNjar+Te8IND7zjDlrU1DuGL0qa8MFGf8PX84SqK9fQ3kbIuz5KHS+Jk8RYyFSZ1iUs27n3Vkql0tbMj8raCqCiX9nWeu5GSxVu/7UTZG5Tk4mrJXcVe6s+65baQrd6D8V/WKSDBblI8Y0jQ7g2XGsHj5eBV49nCwEtwCWPRzkdNqI8Ac0heH6OcqreuH3GowNSsV9RIe/bHKWpu3Ap6DQHMexTHc5GMmcLu4F43Pij0OaoAA83QVCXLvTMZqyLNQOXxPUEsRWATRKKMKRsvitcWx7+v8WGh06iOHfeE59z oci-portal-e2e"
|
||||
|
||||
// TestRealOCIInstanceAutoNetworkAndPublicIP 验证默认可用域 + 自动建网(含
|
||||
// IPv6 与安全列表全开放)+ 换公网 IP + 添加/取消分配 IPv6 + 控制台连接:
|
||||
// 不传 availabilityDomain 与 subnetId 创建实例(默认 ad-1、自动建 VCN、分配
|
||||
// IPv6)→ 等 RUNNING → 换公网 IP → 追加 IPv6 → 控制台连接创建/删除 →
|
||||
// 删除全部 IPv6 → 清理。A1 容量不足时跳过。
|
||||
func TestRealOCIInstanceAutoNetworkAndPublicIP(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||
|
||||
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex"})
|
||||
if err != nil || len(images) == 0 {
|
||||
t.Fatalf("ListImages: %v", err)
|
||||
}
|
||||
|
||||
// 不传 availabilityDomain 与 subnetId:应默认 ad-1 并自动建 oci-portal-auto-vcn 与子网
|
||||
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||
DisplayName: prefix + "-vm",
|
||||
Shape: "VM.Standard.A1.Flex",
|
||||
Ocpus: 1,
|
||||
MemoryInGBs: 6,
|
||||
ImageID: images[0].ID,
|
||||
BootVolumeSizeGBs: 50,
|
||||
AssignPublicIP: true,
|
||||
AssignIpv6: true,
|
||||
RootPassword: "OciPortalE2E!" + fmt.Sprint(len(prefix)),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "capacity") {
|
||||
t.Skipf("A1 capacity unavailable: %v", err)
|
||||
}
|
||||
t.Fatalf("LaunchInstance (auto network): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(instance.AvailabilityDomain, "AD-1") {
|
||||
t.Errorf("availability domain = %q, want default AD-1", instance.AvailabilityDomain)
|
||||
}
|
||||
t.Logf("launched %s in %s with auto network", instance.ID, instance.AvailabilityDomain)
|
||||
|
||||
// 确认自动建网产生了 oci-portal-auto-vcn,且 IPv6 与安全列表全开放生效
|
||||
vcns, err := client.ListVCNs(ctx, cred, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVCNs: %v", err)
|
||||
}
|
||||
autoVcnID := ""
|
||||
for _, v := range vcns {
|
||||
if v.DisplayName == autoVcnName {
|
||||
autoVcnID = v.ID
|
||||
checkAutoVCNDefaults(ctx, t, client, cred, v)
|
||||
}
|
||||
}
|
||||
if autoVcnID == "" {
|
||||
t.Error("auto vcn not created")
|
||||
} else {
|
||||
// 先注册 VCN 清理(LIFO 后执行),确保实例先终止再删网络
|
||||
defer cleanupVCN(t, client, cred, autoVcnID)
|
||||
}
|
||||
defer terminateAndWait(t, client, cred, instance.ID)
|
||||
|
||||
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||
t.Fatalf("wait RUNNING: %v", err)
|
||||
}
|
||||
|
||||
got, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstance: %v", err)
|
||||
}
|
||||
oldIP := got.PublicIP
|
||||
if oldIP == "" {
|
||||
t.Fatal("no public ip on primary vnic, want one")
|
||||
}
|
||||
if len(got.Ipv6Addresses) == 0 {
|
||||
t.Error("no ipv6 address on instance, want auto network ipv6")
|
||||
}
|
||||
t.Logf("public=%s ipv6=%v", oldIP, got.Ipv6Addresses)
|
||||
|
||||
newIP, err := client.ChangeInstancePublicIP(ctx, cred, "", instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ChangeInstancePublicIP: %v", err)
|
||||
}
|
||||
t.Logf("public ip changed: %s -> %s", oldIP, newIP)
|
||||
if newIP == "" || newIP == oldIP {
|
||||
t.Errorf("new public ip = %q, want a different non-empty address", newIP)
|
||||
}
|
||||
|
||||
// 追加一个自动分配的 IPv6,应与 launch 分配的共存
|
||||
added, err := client.AddInstanceIpv6(ctx, cred, "", instance.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddInstanceIpv6: %v", err)
|
||||
}
|
||||
t.Logf("ipv6 added: %s", added)
|
||||
withAdded, err := client.GetInstance(ctx, cred, "", instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstance after ipv6 add: %v", err)
|
||||
}
|
||||
if len(withAdded.Ipv6Addresses) != 2 {
|
||||
t.Errorf("ipv6 addresses = %v, want 2 after add", withAdded.Ipv6Addresses)
|
||||
}
|
||||
|
||||
verifyConsoleConnection(ctx, t, client, cred, instance.ID)
|
||||
deleteInstanceIpv6AndVerify(ctx, t, client, cred, instance.ID, withAdded.Ipv6Addresses)
|
||||
}
|
||||
|
||||
// verifyConsoleConnection 创建控制台连接、校验 VNC 连接串并删除。
|
||||
func verifyConsoleConnection(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, instanceID string) {
|
||||
t.Helper()
|
||||
conn, err := client.CreateConsoleConnection(ctx, cred, "", instanceID, e2eConsoleSSHKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConsoleConnection: %v", err)
|
||||
}
|
||||
if conn.LifecycleState != "ACTIVE" || conn.VncConnectionString == "" {
|
||||
t.Errorf("console connection state=%s vnc=%q, want ACTIVE with vnc string", conn.LifecycleState, conn.VncConnectionString)
|
||||
}
|
||||
t.Logf("console connection %s state=%s", conn.ID, conn.LifecycleState)
|
||||
list, err := client.ListConsoleConnections(ctx, cred, "", instanceID)
|
||||
if err != nil || len(list) == 0 {
|
||||
t.Errorf("ListConsoleConnections: %v (got %d)", err, len(list))
|
||||
}
|
||||
if err := client.DeleteConsoleConnection(ctx, cred, "", conn.ID); err != nil {
|
||||
t.Errorf("DeleteConsoleConnection: %v", err)
|
||||
} else {
|
||||
t.Log("console connection deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// checkAutoVCNDefaults 校验自动 VCN 已分配 IPv6 /56 且默认安全列表全开放。
|
||||
// OCI 在异步分配 IPv6 时可能追加缺省 ICMPv6 规则,因此按语义而非数量校验。
|
||||
func checkAutoVCNDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, vcn VCN) {
|
||||
t.Helper()
|
||||
if len(vcn.Ipv6CidrBlocks) == 0 {
|
||||
t.Error("auto vcn has no ipv6 cidr, want one")
|
||||
}
|
||||
sl, err := client.GetSecurityList(ctx, cred, "", vcn.DefaultSecurityListID)
|
||||
if err != nil {
|
||||
t.Errorf("GetSecurityList: %v", err)
|
||||
return
|
||||
}
|
||||
if !hasAllOpenRules(sl) {
|
||||
t.Errorf("default seclist not all-open: ingress=%+v egress=%+v", sl.IngressRules, sl.EgressRules)
|
||||
}
|
||||
}
|
||||
|
||||
// hasAllOpenRules 判断安全列表出入方向是否均放行 IPv4 与 IPv6 全部流量。
|
||||
func hasAllOpenRules(sl SecurityList) bool {
|
||||
in, out := map[string]bool{}, map[string]bool{}
|
||||
for _, r := range sl.IngressRules {
|
||||
if r.Protocol == "all" {
|
||||
in[r.Source] = true
|
||||
}
|
||||
}
|
||||
for _, r := range sl.EgressRules {
|
||||
if r.Protocol == "all" {
|
||||
out[r.Destination] = true
|
||||
}
|
||||
}
|
||||
return in["0.0.0.0/0"] && in["::/0"] && out["0.0.0.0/0"] && out["::/0"]
|
||||
}
|
||||
|
||||
// deleteInstanceIpv6AndVerify 取消分配实例的全部 IPv6 地址并确认清空。
|
||||
func deleteInstanceIpv6AndVerify(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, instanceID string, addrs []string) {
|
||||
t.Helper()
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if err := client.DeleteInstanceIpv6(ctx, cred, "", instanceID, addr); err != nil {
|
||||
t.Fatalf("DeleteInstanceIpv6 %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
after, err := client.GetInstance(ctx, cred, "", instanceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstance after ipv6 delete: %v", err)
|
||||
}
|
||||
if len(after.Ipv6Addresses) != 0 {
|
||||
t.Errorf("ipv6 addresses after delete = %v, want none", after.Ipv6Addresses)
|
||||
}
|
||||
t.Logf("ipv6 deleted: %v", addrs)
|
||||
// 删除 IPv6 后立即终止实例,偶发 OCI 侧 VNIC 级联清理失败回滚
|
||||
// (VNIC 残留 AVAILABLE 挡住删子网),等待片刻让状态收敛
|
||||
time.Sleep(15 * time.Second)
|
||||
}
|
||||
|
||||
// TestRealOCIVolumeAttachment 验证块卷附加/分离与引导卷挂载查询。
|
||||
// 依赖已有实例:需先运行上面的测试留存实例,或有其他运行中实例;否则跳过。
|
||||
// 这里独立创建一个实例做完整链路,A1 容量不足时跳过。
|
||||
func TestRealOCIVolumeAttachment(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||
|
||||
ads, err := client.ListAvailabilityDomains(ctx, cred, "")
|
||||
if err != nil || len(ads) == 0 {
|
||||
t.Fatalf("ListAvailabilityDomains: %v", err)
|
||||
}
|
||||
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.A1.Flex"})
|
||||
if err != nil || len(images) == 0 {
|
||||
t.Fatalf("ListImages: %v", err)
|
||||
}
|
||||
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{DisplayName: prefix + "-vcn", CidrBlock: "10.97.0.0/16", DnsLabel: "e2evol"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateVCN: %v", err)
|
||||
}
|
||||
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{VcnID: vcn.ID, DisplayName: prefix + "-subnet", CidrBlock: "10.97.1.0/24", DnsLabel: "e2sub"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubnet: %v", err)
|
||||
}
|
||||
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||
AvailabilityDomain: ads[0], DisplayName: prefix + "-vm", Shape: "VM.Standard.A1.Flex",
|
||||
Ocpus: 1, MemoryInGBs: 6, ImageID: images[0].ID, BootVolumeSizeGBs: 50, SubnetID: subnet.ID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "capacity") {
|
||||
t.Skipf("A1 capacity unavailable: %v", err)
|
||||
}
|
||||
t.Fatalf("LaunchInstance: %v", err)
|
||||
}
|
||||
defer terminateAndWait(t, client, cred, instance.ID)
|
||||
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||
t.Fatalf("wait RUNNING: %v", err)
|
||||
}
|
||||
|
||||
// 引导卷挂载查询
|
||||
bvAtts, err := client.ListBootVolumeAttachments(ctx, cred, "", instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBootVolumeAttachments: %v", err)
|
||||
}
|
||||
if len(bvAtts) == 0 {
|
||||
t.Error("no boot volume attachment, want one")
|
||||
} else {
|
||||
t.Logf("boot volume attachment: %s state=%s", bvAtts[0].ID, bvAtts[0].LifecycleState)
|
||||
}
|
||||
|
||||
// 创建块卷并附加、分离
|
||||
volID := createTestBlockVolume(ctx, t, client, cred, ads[0], prefix)
|
||||
if volID == "" {
|
||||
return
|
||||
}
|
||||
defer deleteTestBlockVolume(t, client, cred, volID)
|
||||
|
||||
att, err := client.AttachVolume(ctx, cred, "", instance.ID, volID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachVolume: %v", err)
|
||||
}
|
||||
t.Logf("attached volume: %s device=%s", att.ID, att.Device)
|
||||
if err := waitVolumeAttachmentState(ctx, client, cred, instance.ID, att.ID, "ATTACHED"); err != nil {
|
||||
t.Fatalf("wait volume ATTACHED: %v", err)
|
||||
}
|
||||
atts, err := client.ListVolumeAttachments(ctx, cred, "", instance.ID)
|
||||
if err != nil || len(atts) == 0 {
|
||||
t.Fatalf("ListVolumeAttachments: %v (got %d)", err, len(atts))
|
||||
}
|
||||
if err := client.DetachVolume(ctx, cred, "", att.ID); err != nil {
|
||||
t.Fatalf("DetachVolume: %v", err)
|
||||
}
|
||||
if err := waitVolumeAttachmentState(ctx, client, cred, instance.ID, att.ID, "DETACHED"); err != nil {
|
||||
t.Errorf("wait volume DETACHED: %v", err)
|
||||
}
|
||||
t.Log("volume detached")
|
||||
}
|
||||
|
||||
func createTestBlockVolume(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, ad, prefix string) string {
|
||||
t.Helper()
|
||||
bc, err := client.blockstorageClient(cred, "")
|
||||
if err != nil {
|
||||
t.Errorf("blockstorage client: %v", err)
|
||||
return ""
|
||||
}
|
||||
size := int64(50)
|
||||
name := prefix + "-vol"
|
||||
resp, err := bc.CreateVolume(ctx, createVolumeRequest(cred.TenancyOCID, ad, name, size))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateVolume: %v", err)
|
||||
}
|
||||
volID := deref(resp.Id)
|
||||
for i := 0; i < 60; i++ {
|
||||
got, err := bc.GetVolume(ctx, getVolumeRequest(volID))
|
||||
if err == nil && string(got.LifecycleState) == "AVAILABLE" {
|
||||
return volID
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return volID
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return volID
|
||||
}
|
||||
|
||||
func waitVolumeAttachmentState(ctx context.Context, client *RealClient, cred Credentials, instanceID, attachmentID, want string) error {
|
||||
for i := 0; i < 60; i++ {
|
||||
atts, err := client.ListVolumeAttachments(ctx, cred, "", instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll volume attachment: %w", err)
|
||||
}
|
||||
for _, a := range atts {
|
||||
if a.ID == attachmentID && a.LifecycleState == want {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if want == "DETACHED" {
|
||||
// 分离后可能不再出现在列表中,视为完成
|
||||
found := false
|
||||
for _, a := range atts {
|
||||
if a.ID == attachmentID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("poll volume attachment %s: %w", want, ctx.Err())
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("poll volume attachment %s: timed out", want)
|
||||
}
|
||||
|
||||
func deleteTestBlockVolume(t *testing.T, client *RealClient, cred Credentials, volID string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
bc, err := client.blockstorageClient(cred, "")
|
||||
if err != nil {
|
||||
t.Errorf("cleanup: blockstorage client: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := bc.DeleteVolume(ctx, deleteVolumeRequest(volID)); err != nil {
|
||||
t.Errorf("cleanup: delete volume: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf("cleanup: volume %s deleted", volID)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// testKeyDir 指向仓库根的真实 API Key 测试资料目录。
|
||||
const testKeyDir = "../../../test-api-key"
|
||||
|
||||
// keyCase 把测试 key 文件名映射到期望账户类别;expect 为空表示只打印不断言。
|
||||
type keyCase struct {
|
||||
name string
|
||||
expect string
|
||||
}
|
||||
|
||||
func integrationKeyCases(t *testing.T) []keyCase {
|
||||
t.Helper()
|
||||
all := []keyCase{
|
||||
{name: "试用期", expect: model.AccountTypeTrial},
|
||||
{name: "付费", expect: model.AccountTypePaid},
|
||||
{name: "免费01", expect: model.AccountTypeFree},
|
||||
{name: "免费02", expect: model.AccountTypeFree},
|
||||
{name: "未知01"},
|
||||
{name: "未知02"},
|
||||
}
|
||||
if name := os.Getenv("OCI_TEST_KEY"); name != "" {
|
||||
for _, k := range all {
|
||||
if k.name == name {
|
||||
return []keyCase{k}
|
||||
}
|
||||
}
|
||||
t.Fatalf("OCI_TEST_KEY=%q not found in test-api-key", name)
|
||||
}
|
||||
if os.Getenv("OCI_TEST_ALL_KEYS") == "1" {
|
||||
return all
|
||||
}
|
||||
// 默认只使用试用期 key,避免不必要地触碰其他真实账号
|
||||
return all[:1]
|
||||
}
|
||||
|
||||
func loadTestCredentials(t *testing.T, name string) Credentials {
|
||||
t.Helper()
|
||||
iniPath := filepath.Join(testKeyDir, name+"-api-key.ini")
|
||||
pemPath := filepath.Join(testKeyDir, name+"-api-key.pem")
|
||||
iniText, err := os.ReadFile(iniPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", iniPath, err)
|
||||
}
|
||||
pem, err := os.ReadFile(pemPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", pemPath, err)
|
||||
}
|
||||
cred, err := ParseConfigINI(string(iniText))
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", iniPath, err)
|
||||
}
|
||||
cred.PrivateKey = string(pem)
|
||||
if err := cred.Validate(); err != nil {
|
||||
t.Fatalf("validate credentials of %s: %v", name, err)
|
||||
}
|
||||
return cred
|
||||
}
|
||||
|
||||
// TestRealOCITenantRegionsAndLimits 用试用期 key 验证区域订阅列表和
|
||||
// 配额查询(均为只读调用)。订阅新区域不可撤销,不做真实测试。
|
||||
func TestRealOCITenantRegionsAndLimits(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
|
||||
subs, err := client.ListRegionSubscriptions(ctx, cred)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegionSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
t.Fatal("ListRegionSubscriptions returned empty list, want at least home region")
|
||||
}
|
||||
hasHome := false
|
||||
for _, sub := range subs {
|
||||
t.Logf("region key=%s name=%s status=%s home=%v", sub.Key, sub.Name, sub.Status, sub.IsHomeRegion)
|
||||
hasHome = hasHome || sub.IsHomeRegion
|
||||
}
|
||||
if !hasHome {
|
||||
t.Error("no home region in subscriptions")
|
||||
}
|
||||
|
||||
values, err := client.ListLimits(ctx, cred, LimitsQuery{
|
||||
Service: "compute",
|
||||
Name: "standard-a1-core-count",
|
||||
WithAvailability: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListLimits: %v", err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
t.Fatal("ListLimits returned empty list for standard-a1-core-count")
|
||||
}
|
||||
for _, v := range values {
|
||||
used, available := int64(-1), int64(-1)
|
||||
if v.Used != nil {
|
||||
used = *v.Used
|
||||
}
|
||||
if v.Available != nil {
|
||||
available = *v.Available
|
||||
}
|
||||
t.Logf("limit name=%s scope=%s ad=%s value=%d used=%d available=%d",
|
||||
v.Name, v.ScopeType, v.AvailabilityDomain, v.Value, used, available)
|
||||
}
|
||||
|
||||
// 模糊过滤应比精确名命中更多配额(a1 与 e2 等系列都含 core-count)
|
||||
fuzzy, err := client.ListLimits(ctx, cred, LimitsQuery{Service: "compute", Name: "core-count"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListLimits fuzzy: %v", err)
|
||||
}
|
||||
t.Logf("fuzzy core-count matched %d entries", len(fuzzy))
|
||||
if len(fuzzy) <= len(values) {
|
||||
t.Errorf("fuzzy matches = %d, want more than exact matches %d", len(fuzzy), len(values))
|
||||
}
|
||||
|
||||
services, err := client.ListLimitServices(ctx, cred, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListLimitServices: %v", err)
|
||||
}
|
||||
if len(services) == 0 {
|
||||
t.Fatal("ListLimitServices returned empty list")
|
||||
}
|
||||
hasCompute := false
|
||||
for _, s := range services {
|
||||
hasCompute = hasCompute || s.Name == "compute"
|
||||
}
|
||||
if !hasCompute {
|
||||
t.Errorf("services (%d) missing compute", len(services))
|
||||
}
|
||||
t.Logf("limit services: %d entries, first=%+v", len(services), services[0])
|
||||
}
|
||||
|
||||
// TestRealOCISubscriptions 用试用期 key 验证订阅列表与订阅详情(只读)。
|
||||
func TestRealOCISubscriptions(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
|
||||
subs, err := client.ListSubscriptions(ctx, cred)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
t.Fatal("ListSubscriptions returned empty list")
|
||||
}
|
||||
cloudcmID := ""
|
||||
for _, sub := range subs {
|
||||
t.Logf("subscription id=%s service=%s created=%v", sub.ID, sub.ServiceName, sub.TimeCreated)
|
||||
if strings.EqualFold(sub.ServiceName, cloudcmServiceName) {
|
||||
cloudcmID = sub.ID
|
||||
}
|
||||
}
|
||||
if cloudcmID == "" {
|
||||
t.Fatal("no CLOUDCM subscription in list")
|
||||
}
|
||||
|
||||
detail, err := client.GetSubscription(ctx, cred, cloudcmID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubscription: %v", err)
|
||||
}
|
||||
if detail.ID != cloudcmID {
|
||||
t.Errorf("detail.ID = %q, want %q", detail.ID, cloudcmID)
|
||||
}
|
||||
t.Logf("detail service=%s paymentModel=%q tier=%q state=%s currency=%s region=%s promotions=%d",
|
||||
detail.ServiceName, detail.PaymentModel, detail.SubscriptionTier,
|
||||
detail.LifecycleState, detail.CloudAmountCurrency, detail.RegionAssignment, len(detail.Promotions))
|
||||
for _, p := range detail.Promotions {
|
||||
t.Logf("promotion status=%s amount=%.2f %s expires=%v intentToPay=%v",
|
||||
p.Status, p.Amount, p.CurrencyUnit, p.TimeExpired, p.IsIntentToPay)
|
||||
}
|
||||
if len(detail.Promotions) == 0 {
|
||||
t.Error("trial subscription has no promotions, want at least one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealOCIVerifyAndClassify 走真实 OCI 验证测活与账户类别判定,
|
||||
// 必须显式设置 OCI_INTEGRATION_TEST=1 才会执行。
|
||||
func TestRealOCIVerifyAndClassify(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
client := NewClient()
|
||||
for _, tc := range integrationKeyCases(t) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, tc.name)
|
||||
|
||||
info, err := client.ValidateKey(ctx, cred)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateKey: %v", err)
|
||||
}
|
||||
t.Logf("tenancy=%s homeRegion=%s", info.Name, info.HomeRegionKey)
|
||||
|
||||
profile, err := client.FetchAccountProfile(ctx, cred)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAccountProfile: %v", err)
|
||||
}
|
||||
t.Logf("type=%s paymentModel=%q tier=%q promo=%q amount=%.2f service=%s",
|
||||
profile.AccountType, profile.PaymentModel, profile.SubscriptionTier,
|
||||
profile.PromotionStatus, profile.PromotionAmount, profile.ServiceName)
|
||||
if !strings.EqualFold(profile.ServiceName, cloudcmServiceName) {
|
||||
t.Errorf("ServiceName = %q, want %q", profile.ServiceName, cloudcmServiceName)
|
||||
}
|
||||
if tc.expect != "" && profile.AccountType != tc.expect {
|
||||
t.Errorf("AccountType = %q, want %q", profile.AccountType, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// TestRealOCIImages 用试用期 key 验证镜像列表查询(只读)。
|
||||
func TestRealOCIImages(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
|
||||
images, err := client.ListImages(ctx, cred, ImagesQuery{
|
||||
OperatingSystem: "Canonical Ubuntu",
|
||||
Shape: "VM.Standard.A1.Flex",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages: %v", err)
|
||||
}
|
||||
if len(images) == 0 {
|
||||
t.Fatal("ListImages returned empty list for Ubuntu on A1")
|
||||
}
|
||||
for _, img := range images[:min(3, len(images))] {
|
||||
t.Logf("image %s os=%s %s size=%dMB", img.DisplayName, img.OperatingSystem, img.OperatingSystemVersion, img.SizeInMBs)
|
||||
if img.ID == "" || img.OperatingSystem == "" {
|
||||
t.Errorf("image %+v has empty mandatory field", img)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealOCINetworkCRUDAndIPv6 走全链路:建 VCN → 建子网 → 建安全列表 →
|
||||
// 一键 IPv6 → 校验,结束后尽力清理全部资源。
|
||||
// 会创建真实云资源,资源名前缀 oci-portal-e2e-<timestamp>。
|
||||
func TestRealOCINetworkCRUDAndIPv6(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
prefix := fmt.Sprintf("oci-portal-e2e-%d", time.Now().Unix())
|
||||
|
||||
vcn, err := client.CreateVCN(ctx, cred, CreateVCNInput{
|
||||
DisplayName: prefix + "-vcn",
|
||||
CidrBlock: "10.99.0.0/16",
|
||||
DnsLabel: "ociportale2e",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateVCN: %v", err)
|
||||
}
|
||||
t.Logf("created vcn %s (%s)", vcn.DisplayName, vcn.ID)
|
||||
defer cleanupVCN(t, client, cred, vcn.ID)
|
||||
|
||||
if _, err := client.UpdateVCN(ctx, cred, "", vcn.ID, prefix+"-vcn-renamed"); err != nil {
|
||||
t.Errorf("UpdateVCN: %v", err)
|
||||
}
|
||||
|
||||
subnet, err := client.CreateSubnet(ctx, cred, CreateSubnetInput{
|
||||
VcnID: vcn.ID,
|
||||
DisplayName: prefix + "-subnet",
|
||||
CidrBlock: "10.99.1.0/24",
|
||||
DnsLabel: "e2esub",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubnet: %v", err)
|
||||
}
|
||||
t.Logf("created subnet %s (%s)", subnet.DisplayName, subnet.ID)
|
||||
|
||||
secList, err := client.CreateSecurityList(ctx, cred, CreateSecurityListInput{
|
||||
VcnID: vcn.ID,
|
||||
DisplayName: prefix + "-seclist",
|
||||
IngressRules: []SecurityRule{
|
||||
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(22), PortMax: intPtr(22), Description: "ssh"},
|
||||
},
|
||||
EgressRules: []SecurityRule{{Protocol: "all", Destination: "0.0.0.0/0"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSecurityList: %v", err)
|
||||
}
|
||||
t.Logf("created security list %s (%s)", secList.DisplayName, secList.ID)
|
||||
if len(secList.IngressRules) != 1 || secList.IngressRules[0].PortMin == nil || *secList.IngressRules[0].PortMin != 22 {
|
||||
t.Errorf("ingress rules = %+v, want one ssh rule with port 22", secList.IngressRules)
|
||||
}
|
||||
|
||||
updated, err := client.UpdateSecurityList(ctx, cred, secList.ID, UpdateSecurityListInput{
|
||||
IngressRules: &[]SecurityRule{
|
||||
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(22), PortMax: intPtr(22), Description: "ssh"},
|
||||
{Protocol: "6", Source: "0.0.0.0/0", PortMin: intPtr(443), PortMax: intPtr(443), Description: "https"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSecurityList: %v", err)
|
||||
}
|
||||
if len(updated.IngressRules) != 2 {
|
||||
t.Errorf("ingress rules after update = %d, want 2", len(updated.IngressRules))
|
||||
}
|
||||
|
||||
steps, err := client.EnableVCNIPv6(ctx, cred, "", vcn.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("EnableVCNIPv6: %v", err)
|
||||
}
|
||||
for _, s := range steps {
|
||||
t.Logf("ipv6 step=%s status=%s %s", s.Step, s.Status, s.Detail)
|
||||
if s.Status == "failed" {
|
||||
t.Errorf("ipv6 step %s failed: %s", s.Step, s.Detail)
|
||||
}
|
||||
}
|
||||
vcnAfter, err := client.GetVCN(ctx, cred, "", vcn.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVCN after ipv6: %v", err)
|
||||
}
|
||||
if len(vcnAfter.Ipv6CidrBlocks) == 0 {
|
||||
t.Error("vcn has no ipv6 cidr after enable-ipv6")
|
||||
}
|
||||
subnetAfter, err := client.GetSubnet(ctx, cred, "", subnet.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubnet after ipv6: %v", err)
|
||||
}
|
||||
if subnetAfter.Ipv6CidrBlock == "" {
|
||||
t.Error("subnet has no ipv6 cidr after enable-ipv6")
|
||||
}
|
||||
t.Logf("vcn ipv6=%v subnet ipv6=%s", vcnAfter.Ipv6CidrBlocks, subnetAfter.Ipv6CidrBlock)
|
||||
|
||||
// 幂等:重复执行应全部 skipped
|
||||
steps2, err := client.EnableVCNIPv6(ctx, cred, "", vcn.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("EnableVCNIPv6 twice: %v", err)
|
||||
}
|
||||
for _, s := range steps2 {
|
||||
if s.Status != "skipped" {
|
||||
t.Errorf("rerun step %s status = %s, want skipped", s.Step, s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.DeleteSecurityList(ctx, cred, "", secList.ID); err != nil {
|
||||
t.Errorf("DeleteSecurityList: %v", err)
|
||||
}
|
||||
if err := client.DeleteSubnet(ctx, cred, "", subnet.ID); err != nil {
|
||||
t.Errorf("DeleteSubnet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupVCN 尽力清理 VCN 及其关联资源:清空默认路由表、删 IGW、
|
||||
// 删残留子网与自建安全列表,最后删 VCN。
|
||||
func cleanupVCN(t *testing.T, client *RealClient, cred Credentials, vcnID string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
vn, err := client.vcnClient(cred, "")
|
||||
if err != nil {
|
||||
t.Errorf("cleanup: vcn client: %v", err)
|
||||
return
|
||||
}
|
||||
vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
t.Errorf("cleanup: get vcn: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||
RtId: vcnResp.DefaultRouteTableId,
|
||||
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: []core.RouteRule{}},
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: clear route table: %v", err)
|
||||
}
|
||||
igws, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{
|
||||
CompartmentId: &cred.TenancyOCID, VcnId: &vcnID,
|
||||
})
|
||||
if err == nil {
|
||||
for _, igw := range igws.Items {
|
||||
if _, err := vn.DeleteInternetGateway(ctx, core.DeleteInternetGatewayRequest{IgId: igw.Id}); err != nil {
|
||||
t.Logf("cleanup: delete igw: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if subnets, err := listSubnets(ctx, vn, cred.TenancyOCID, vcnID); err == nil {
|
||||
for _, s := range subnets {
|
||||
if err := deleteSubnetWithRetry(ctx, client, cred, s.ID); err != nil {
|
||||
t.Logf("cleanup: delete subnet %s: %v", s.DisplayName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if lists, err := client.ListSecurityLists(ctx, cred, "", vcnID); err == nil {
|
||||
for _, sl := range lists {
|
||||
// 默认安全列表的名字包含 VCN 名,须用前缀匹配排除,它随 VCN 一起删除
|
||||
if strings.HasPrefix(sl.DisplayName, "oci-portal-e2e-") {
|
||||
if err := client.DeleteSecurityList(ctx, cred, "", sl.ID); err != nil {
|
||||
t.Logf("cleanup: delete security list %s: %v", sl.DisplayName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.DeleteVCN(ctx, cred, "", vcnID); err != nil {
|
||||
t.Errorf("cleanup: delete vcn: %v (manual cleanup may be required)", err)
|
||||
return
|
||||
}
|
||||
t.Logf("cleanup: vcn %s deleted", vcnID)
|
||||
}
|
||||
|
||||
// deleteSubnetWithRetry 删除子网;实例终止后 VNIC 异步释放,子网短暂
|
||||
// 保持被引用状态,409 Conflict 时等待重试,最长约 60 秒。
|
||||
func deleteSubnetWithRetry(ctx context.Context, client *RealClient, cred Credentials, subnetID string) error {
|
||||
var err error
|
||||
for i := 0; i < 12; i++ {
|
||||
if err = client.DeleteSubnet(ctx, cred, "", subnetID); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Conflict") {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func intPtr(n int) *int { return &n }
|
||||
@@ -0,0 +1,265 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRealOCITenantUsers 验证用户管理全链路:新增 → 重置密码 →
|
||||
// 清 MFA → 清 API Key → 删除,全部只操作新建的测试用户。
|
||||
func TestRealOCITenantUsers(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
homeRegion := cred.Region
|
||||
|
||||
users, err := client.ListTenantUsers(ctx, cred)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTenantUsers: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
t.Fatal("no tenant users, want at least the current one")
|
||||
}
|
||||
currentSeen := false
|
||||
for _, u := range users {
|
||||
if u.IsCurrentUser {
|
||||
currentSeen = true
|
||||
}
|
||||
}
|
||||
if !currentSeen {
|
||||
t.Error("current user not flagged in list")
|
||||
}
|
||||
t.Logf("tenant users: %d", len(users))
|
||||
|
||||
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
|
||||
user, err := client.CreateTenantUser(ctx, cred, homeRegion, CreateTenantUserInput{
|
||||
Name: name, Description: "e2e temp user", Email: name + "@example.com",
|
||||
GivenName: "E2E", FamilyName: "Temp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTenantUser: %v", err)
|
||||
}
|
||||
t.Logf("created user %s (%s)", user.Name, user.ID)
|
||||
defer func() {
|
||||
if err := client.DeleteTenantUser(ctx, cred, homeRegion, user.ID); err != nil {
|
||||
t.Errorf("cleanup: DeleteTenantUser: %v", err)
|
||||
} else {
|
||||
t.Log("cleanup: user deleted")
|
||||
}
|
||||
}()
|
||||
|
||||
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
|
||||
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, user.ID, UpdateTenantUserInput{
|
||||
Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateTenantUser: %v", err)
|
||||
}
|
||||
if updated.Description != newDesc {
|
||||
t.Errorf("updated description = %q, want %q", updated.Description, newDesc)
|
||||
}
|
||||
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
|
||||
|
||||
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetTenantUserPassword: %v", err)
|
||||
}
|
||||
if password == "" {
|
||||
t.Error("reset password returned empty password")
|
||||
}
|
||||
t.Logf("password reset ok (len=%d)", len(password))
|
||||
|
||||
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
|
||||
}
|
||||
t.Logf("mfa devices deleted: %d", mfaDeleted)
|
||||
|
||||
keysDeleted, err := client.DeleteTenantUserApiKeys(ctx, cred, homeRegion, user.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteTenantUserApiKeys: %v", err)
|
||||
}
|
||||
t.Logf("api keys deleted: %d", keysDeleted)
|
||||
}
|
||||
|
||||
// TestRealOCIDomainSettings 验证通知收件人与密码策略的改后恢复。
|
||||
func TestRealOCIDomainSettings(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
region := cred.Region
|
||||
|
||||
original, err := client.GetNotificationRecipients(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNotificationRecipients: %v", err)
|
||||
}
|
||||
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
|
||||
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, []string{"e2e@example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateNotificationRecipients: %v", err)
|
||||
}
|
||||
if !updated.TestModeEnabled || len(updated.Recipients) != 1 {
|
||||
t.Errorf("updated = %+v, want test mode with 1 recipient", updated)
|
||||
}
|
||||
restoreRecipients := original.Recipients
|
||||
if !original.TestModeEnabled {
|
||||
restoreRecipients = nil
|
||||
}
|
||||
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, restoreRecipients); err != nil {
|
||||
t.Errorf("restore recipients: %v", err)
|
||||
} else {
|
||||
t.Log("recipients restored")
|
||||
}
|
||||
|
||||
policies, err := client.ListPasswordPolicies(ctx, cred, region)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPasswordPolicies: %v", err)
|
||||
}
|
||||
target, ok := pickCustomPolicy(policies)
|
||||
if !ok {
|
||||
t.Fatalf("no Custom password policy among %d policies (built-ins are read-only)", len(policies))
|
||||
}
|
||||
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
|
||||
days := 350
|
||||
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePasswordPolicy: %v", err)
|
||||
}
|
||||
if patched.PasswordExpiresAfter == nil || *patched.PasswordExpiresAfter != days {
|
||||
t.Errorf("passwordExpiresAfter = %v, want %d", patched.PasswordExpiresAfter, days)
|
||||
}
|
||||
restore := 0
|
||||
if target.PasswordExpiresAfter != nil {
|
||||
restore = *target.PasswordExpiresAfter
|
||||
}
|
||||
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
|
||||
t.Errorf("restore password policy: %v", err)
|
||||
} else {
|
||||
t.Logf("policy restored to %d", restore)
|
||||
}
|
||||
}
|
||||
|
||||
// pickCustomPolicy 返回 Custom 强度的密码策略;Simple/Standard 内置策略只读不可改。
|
||||
func pickCustomPolicy(policies []PasswordPolicyInfo) (PasswordPolicyInfo, bool) {
|
||||
for _, p := range policies {
|
||||
if strings.EqualFold(p.PasswordStrength, "custom") {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return PasswordPolicyInfo{}, false
|
||||
}
|
||||
|
||||
// TestRealOCIUsageAndTraffic 验证成本分析与实例流量统计:
|
||||
// 成本查最近 7 天;流量需要真实 VNIC,无实例时创建一台临时 Micro 实例。
|
||||
func TestRealOCIUsageAndTraffic(t *testing.T) {
|
||||
if os.Getenv("OCI_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set OCI_INTEGRATION_TEST=1 to run real OCI tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
cred := loadTestCredentials(t, "试用期")
|
||||
client := NewClient()
|
||||
|
||||
end := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, 1)
|
||||
items, err := client.SummarizeCosts(ctx, cred, CostQuery{
|
||||
StartTime: end.AddDate(0, 0, -7),
|
||||
EndTime: end,
|
||||
Granularity: "DAILY",
|
||||
QueryType: "COST",
|
||||
GroupBy: "service",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SummarizeCosts: %v", err)
|
||||
}
|
||||
t.Logf("cost items: %d", len(items))
|
||||
for i, item := range items {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
t.Logf(" %s %s %.4f %s", item.TimeStart, item.GroupValue, item.ComputedAmount, item.Currency)
|
||||
}
|
||||
|
||||
instanceID := ensureTrafficInstance(ctx, t, client, cred)
|
||||
traffic, err := client.SummarizeInstanceTraffic(ctx, cred, TrafficQuery{
|
||||
InstanceID: instanceID,
|
||||
StartTime: end.AddDate(0, 0, -7),
|
||||
EndTime: end,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SummarizeInstanceTraffic: %v", err)
|
||||
}
|
||||
t.Logf("traffic vnics=%d in=%.0fB out=%.0fB", len(traffic.Vnics), traffic.InboundBytes, traffic.OutboundBytes)
|
||||
if len(traffic.Vnics) == 0 {
|
||||
t.Error("no vnic in traffic result, want at least one")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTrafficInstance 返回一个可查流量的实例:已有实例直接用,
|
||||
// 否则创建一台临时 E2 Micro(测试结束删除)。
|
||||
func ensureTrafficInstance(ctx context.Context, t *testing.T, client *RealClient, cred Credentials) string {
|
||||
t.Helper()
|
||||
instances, err := client.ListInstances(ctx, cred, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInstances: %v", err)
|
||||
}
|
||||
for _, in := range instances {
|
||||
if in.LifecycleState == "RUNNING" || in.LifecycleState == "STOPPED" {
|
||||
t.Logf("using existing instance %s", in.ID)
|
||||
return in.ID
|
||||
}
|
||||
}
|
||||
images, err := client.ListImages(ctx, cred, ImagesQuery{OperatingSystem: "Canonical Ubuntu", Shape: "VM.Standard.E2.1.Micro"})
|
||||
if err != nil || len(images) == 0 {
|
||||
t.Skipf("no micro image available: %v", err)
|
||||
}
|
||||
instance, err := client.LaunchInstance(ctx, cred, CreateInstanceInput{
|
||||
DisplayName: fmt.Sprintf("oci-portal-e2e-traffic-%d", time.Now().Unix()),
|
||||
Shape: "VM.Standard.E2.1.Micro",
|
||||
ImageID: images[0].ID,
|
||||
AssignPublicIP: false,
|
||||
SSHPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEfake+e2e+key oci-portal-e2e",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "capacity") || strings.Contains(err.Error(), "LimitExceeded") {
|
||||
t.Skipf("micro capacity unavailable: %v", err)
|
||||
}
|
||||
t.Fatalf("LaunchInstance: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
terminateAndWait(t, client, cred, instance.ID)
|
||||
cleanupAutoVCNIfAny(t, client, cred)
|
||||
})
|
||||
// VNIC attachment 在实例 RUNNING 后才就绪,否则流量查询列不到 VNIC
|
||||
if err := waitInstanceState(ctx, client, cred, instance.ID, "RUNNING"); err != nil {
|
||||
t.Fatalf("wait instance running: %v", err)
|
||||
}
|
||||
return instance.ID
|
||||
}
|
||||
|
||||
// cleanupAutoVCNIfAny 清理临时实例触发自动建网留下的 auto-vcn。
|
||||
func cleanupAutoVCNIfAny(t *testing.T, client *RealClient, cred Credentials) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
vcns, err := client.ListVCNs(ctx, cred, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, v := range vcns {
|
||||
if v.DisplayName == autoVcnName {
|
||||
cleanupVCN(t, client, cred, v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// regionsJSON 是本地维护的 OCI 区域表。官方 API(ListRegions、
|
||||
// ListRegionSubscriptions 等)只返回 key 和 name,控制台风格的
|
||||
// 友好名称(alias)官方不提供查询接口,因此在此文件旁的
|
||||
// regions.json 中单独维护,新增区域时直接编辑该文件。
|
||||
//
|
||||
//go:embed regions.json
|
||||
var regionsJSON []byte
|
||||
|
||||
// RegionInfo 描述一个 OCI 区域。
|
||||
type RegionInfo struct {
|
||||
Alias string `json:"alias"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var loadRegions = sync.OnceValues(func() ([]RegionInfo, error) {
|
||||
var regions []RegionInfo
|
||||
if err := json.Unmarshal(regionsJSON, ®ions); err != nil {
|
||||
return nil, fmt.Errorf("parse embedded regions.json: %w", err)
|
||||
}
|
||||
// 全局统一按友好名排序,维护 regions.json 时无需关心条目顺序
|
||||
sort.SliceStable(regions, func(i, j int) bool { return regions[i].Alias < regions[j].Alias })
|
||||
return regions, nil
|
||||
})
|
||||
|
||||
// AllRegions 返回本地维护的完整区域表。
|
||||
func AllRegions() ([]RegionInfo, error) {
|
||||
return loadRegions()
|
||||
}
|
||||
|
||||
// RegionByKey 按三字码(如 FRA)查找区域,大小写不敏感。
|
||||
func RegionByKey(key string) (RegionInfo, bool) {
|
||||
regions, err := loadRegions()
|
||||
if err != nil {
|
||||
return RegionInfo{}, false
|
||||
}
|
||||
for _, r := range regions {
|
||||
if strings.EqualFold(r.Key, key) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return RegionInfo{}, false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{ "alias": "Netherlands Northwest (Amsterdam)", "key": "AMS", "name": "eu-amsterdam-1" },
|
||||
{ "alias": "Sweden Central (Stockholm)", "key": "ARN", "name": "eu-stockholm-1" },
|
||||
{ "alias": "UAE Central (Abu Dhabi)", "key": "AUH", "name": "me-abudhabi-1" },
|
||||
{ "alias": "Colombia Central (Bogota)", "key": "BOG", "name": "sa-bogota-1" },
|
||||
{ "alias": "India West (Mumbai)", "key": "BOM", "name": "ap-mumbai-1" },
|
||||
{ "alias": "France Central (Paris)", "key": "CDG", "name": "eu-paris-1" },
|
||||
{ "alias": "UK West (Newport)", "key": "CWL", "name": "uk-cardiff-1" },
|
||||
{ "alias": "UAE East (Dubai)", "key": "DXB", "name": "me-dubai-1" },
|
||||
{ "alias": "Germany Central (Frankfurt)", "key": "FRA", "name": "eu-frankfurt-1" },
|
||||
{ "alias": "Brazil East (Sao Paulo)", "key": "GRU", "name": "sa-saopaulo-1" },
|
||||
{ "alias": "Indonesia North (Batam)", "key": "HSG", "name": "ap-batam-1" },
|
||||
{ "alias": "India South (Hyderabad)", "key": "HYD", "name": "ap-hyderabad-1" },
|
||||
{ "alias": "US East (Ashburn)", "key": "IAD", "name": "us-ashburn-1" },
|
||||
{ "alias": "South Korea Central (Seoul)", "key": "ICN", "name": "ap-seoul-1" },
|
||||
{ "alias": "Malaysia West 2 (Kulai)", "key": "JBP", "name": "ap-kulai-2" },
|
||||
{ "alias": "Saudi Arabia West (Jeddah)", "key": "JED", "name": "me-jeddah-1" },
|
||||
{ "alias": "South Africa Central (Johannesburg)", "key": "JNB", "name": "af-johannesburg-1" },
|
||||
{ "alias": "Japan Central (Osaka)", "key": "KIX", "name": "ap-osaka-1" },
|
||||
{ "alias": "Morocco West (Casablanca)", "key": "LEJ", "name": "af-casablanca-1" },
|
||||
{ "alias": "UK South (London)", "key": "LHR", "name": "uk-london-1" },
|
||||
{ "alias": "Italy Northwest (Milan)", "key": "LIN", "name": "eu-milan-1" },
|
||||
{ "alias": "Spain Central (Madrid)", "key": "MAD", "name": "eu-madrid-1" },
|
||||
{ "alias": "Australia Southeast (Melbourne)", "key": "MEL", "name": "ap-melbourne-1" },
|
||||
{ "alias": "France South (Marseille)", "key": "MRS", "name": "eu-marseille-1" },
|
||||
{ "alias": "Mexico Northeast (Monterrey)", "key": "MTY", "name": "mx-monterrey-1" },
|
||||
{ "alias": "Israel Central (Jerusalem)", "key": "MTZ", "name": "il-jerusalem-1" },
|
||||
{ "alias": "Italy North (Turin)", "key": "NRQ", "name": "eu-turin-1" },
|
||||
{ "alias": "Japan East (Tokyo)", "key": "NRT", "name": "ap-tokyo-1" },
|
||||
{ "alias": "US Midwest (Chicago)", "key": "ORD", "name": "us-chicago-1" },
|
||||
{ "alias": "Spain Central (Madrid 3)", "key": "ORF", "name": "eu-madrid-3" },
|
||||
{ "alias": "US West (Phoenix)", "key": "PHX", "name": "us-phoenix-1" },
|
||||
{ "alias": "Mexico Central (Queretaro)", "key": "QRO", "name": "mx-queretaro-1" },
|
||||
{ "alias": "Saudi Arabia Central (Riyadh)", "key": "RUH", "name": "me-riyadh-1" },
|
||||
{ "alias": "Chile Central (Santiago)", "key": "SCL", "name": "sa-santiago-1" },
|
||||
{ "alias": "Singapore (Singapore)", "key": "SIN", "name": "ap-singapore-1" },
|
||||
{ "alias": "US West (San Jose)", "key": "SJC", "name": "us-sanjose-1" },
|
||||
{ "alias": "Australia East (Sydney)", "key": "SYD", "name": "ap-sydney-1" },
|
||||
{ "alias": "Chile West (Valparaiso)", "key": "VAP", "name": "sa-valparaiso-1" },
|
||||
{ "alias": "Brazil Southeast (Vinhedo)", "key": "VCP", "name": "sa-vinhedo-1" },
|
||||
{ "alias": "Singapore West (Singapore)", "key": "XSP", "name": "ap-singapore-2" },
|
||||
{ "alias": "South Korea North (Chuncheon)", "key": "YNY", "name": "ap-chuncheon-1" },
|
||||
{ "alias": "Canada Southeast (Montreal)", "key": "YUL", "name": "ca-montreal-1" },
|
||||
{ "alias": "Canada Southeast (Toronto)", "key": "YYZ", "name": "ca-toronto-1" },
|
||||
{ "alias": "Switzerland North (Zurich)", "key": "ZRH", "name": "eu-zurich-1" }
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
package oci
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllRegionsParsesAndComplete(t *testing.T) {
|
||||
regions, err := AllRegions()
|
||||
if err != nil {
|
||||
t.Fatalf("AllRegions: %v", err)
|
||||
}
|
||||
if len(regions) == 0 {
|
||||
t.Fatal("AllRegions returned empty table")
|
||||
}
|
||||
seen := make(map[string]bool, len(regions))
|
||||
for _, r := range regions {
|
||||
if r.Alias == "" || r.Key == "" || r.Name == "" {
|
||||
t.Errorf("region %+v has empty field", r)
|
||||
}
|
||||
if seen[r.Key] {
|
||||
t.Errorf("duplicate region key %s", r.Key)
|
||||
}
|
||||
seen[r.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionByKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
wantName string
|
||||
wantFound bool
|
||||
}{
|
||||
{name: "精确命中", key: "FRA", wantName: "eu-frankfurt-1", wantFound: true},
|
||||
{name: "小写命中", key: "fra", wantName: "eu-frankfurt-1", wantFound: true},
|
||||
{name: "不存在", key: "XXX", wantFound: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info, found := RegionByKey(tt.key)
|
||||
if found != tt.wantFound {
|
||||
t.Fatalf("RegionByKey(%q) found = %v, want %v", tt.key, found, tt.wantFound)
|
||||
}
|
||||
if found && info.Name != tt.wantName {
|
||||
t.Errorf("RegionByKey(%q).Name = %q, want %q", tt.key, info.Name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
)
|
||||
|
||||
// scimErrorDispatcher 包装 Identity Domains 客户端的 HTTP 派发器。
|
||||
// IDCS 的错误体是 SCIM 格式(detail 字段),而 SDK 只解析 code/message,
|
||||
// 直接透传会得到消息为空的 BadErrorResponse;这里把 4xx/5xx 的 SCIM
|
||||
// 错误体改写成 SDK 可解析的标准形状,让真实原因透出到面板。
|
||||
type scimErrorDispatcher struct {
|
||||
base common.HTTPRequestDispatcher
|
||||
}
|
||||
|
||||
func (d scimErrorDispatcher) Do(req *http.Request) (*http.Response, error) {
|
||||
resp, err := d.base.Do(req)
|
||||
if err != nil || resp == nil || resp.StatusCode < 400 || resp.Body == nil {
|
||||
return resp, err
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
return resp, nil
|
||||
}
|
||||
rewritten := rewriteScimError(body)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(rewritten))
|
||||
resp.ContentLength = int64(len(rewritten))
|
||||
resp.Header.Del("Content-Length")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// rewriteScimError 提取 SCIM 错误的 detail 与 messageId 转成
|
||||
// {code,message};不是 SCIM 错误体时原样返回。
|
||||
func rewriteScimError(body []byte) []byte {
|
||||
var scim struct {
|
||||
Detail string `json:"detail"`
|
||||
Ext struct {
|
||||
MessageID string `json:"messageId"`
|
||||
} `json:"urn:ietf:params:scim:api:oracle:idcs:extension:messages:Error"`
|
||||
}
|
||||
if json.Unmarshal(body, &scim) != nil || scim.Detail == "" {
|
||||
return body
|
||||
}
|
||||
code := scim.Ext.MessageID
|
||||
if code == "" {
|
||||
code = "IdcsError"
|
||||
}
|
||||
std, err := json.Marshal(map[string]string{"code": code, "message": scim.Detail})
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return std
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRewriteScimError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode string
|
||||
wantMsg string
|
||||
passThru bool
|
||||
}{
|
||||
{
|
||||
name: "scim error with messageId",
|
||||
body: `{"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],` +
|
||||
`"detail":"Icon URL is too long.","status":"400",` +
|
||||
`"urn:ietf:params:scim:api:oracle:idcs:extension:messages:Error":{"messageId":"error.common.validation"}}`,
|
||||
wantCode: "error.common.validation",
|
||||
wantMsg: "Icon URL is too long.",
|
||||
},
|
||||
{
|
||||
name: "scim error without messageId",
|
||||
body: `{"detail":"Missing required attribute.","status":"400"}`,
|
||||
wantCode: "IdcsError",
|
||||
wantMsg: "Missing required attribute.",
|
||||
},
|
||||
{
|
||||
name: "standard oci error passes through",
|
||||
body: `{"code":"NotAuthorizedOrNotFound","message":"resource not found"}`,
|
||||
passThru: true,
|
||||
},
|
||||
{
|
||||
name: "non-json passes through",
|
||||
body: `<html>bad gateway</html>`,
|
||||
passThru: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := rewriteScimError([]byte(tt.body))
|
||||
if tt.passThru {
|
||||
if string(out) != tt.body {
|
||||
t.Fatalf("body rewritten unexpectedly: %s", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
var got struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("unmarshal rewritten body: %v", err)
|
||||
}
|
||||
if got.Code != tt.wantCode || got.Message != tt.wantMsg {
|
||||
t.Errorf("got code=%q msg=%q, want code=%q msg=%q", got.Code, got.Message, tt.wantCode, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// shapeCacheTTL 是 shape 清单缓存时长:区域提供的 shape 极少变化。
|
||||
const shapeCacheTTL = 6 * time.Hour
|
||||
|
||||
// ComputeShape 是租户在目标区域实际可用的一种实例规格。
|
||||
// Flex 规格带 Ocpus/Memory 的可选范围;固定规格带其固定值。
|
||||
type ComputeShape struct {
|
||||
Name string `json:"name"`
|
||||
BillingType string `json:"billingType"` // ALWAYS_FREE / LIMITED_FREE / PAID
|
||||
IsFlexible bool `json:"isFlexible"`
|
||||
Ocpus float32 `json:"ocpus,omitempty"`
|
||||
MemoryInGBs float32 `json:"memoryInGBs,omitempty"`
|
||||
OcpusMin float32 `json:"ocpusMin,omitempty"`
|
||||
OcpusMax float32 `json:"ocpusMax,omitempty"`
|
||||
MemoryMinGBs float32 `json:"memoryMinGBs,omitempty"`
|
||||
MemoryMaxGBs float32 `json:"memoryMaxGBs,omitempty"`
|
||||
Gpus int `json:"gpus,omitempty"`
|
||||
ProcessorDescription string `json:"processorDescription,omitempty"`
|
||||
}
|
||||
|
||||
// shapeCacheEntry 是一份 shape 清单的缓存条目。
|
||||
type shapeCacheEntry struct {
|
||||
expires time.Time
|
||||
shapes []ComputeShape
|
||||
}
|
||||
|
||||
// ListShapes 实现 Client:列出租户在目标区域可用的实例规格(带 TTL 缓存)。
|
||||
// availabilityDomain 为空时返回区域级去重清单。
|
||||
func (c *RealClient) ListShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) {
|
||||
key := cred.TenancyOCID + "|" + region + "|" + availabilityDomain
|
||||
if v, ok := c.shapes.Load(key); ok {
|
||||
if e := v.(shapeCacheEntry); time.Now().Before(e.expires) {
|
||||
return e.shapes, nil
|
||||
}
|
||||
}
|
||||
shapes, err := c.fetchShapes(ctx, cred, region, availabilityDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.shapes.Store(key, shapeCacheEntry{expires: time.Now().Add(shapeCacheTTL), shapes: shapes})
|
||||
return shapes, nil
|
||||
}
|
||||
|
||||
// fetchShapes 分页拉取 shape 清单并按名称去重(不带 AD 时 OCI 每个可用域各返回一份)。
|
||||
func (c *RealClient) fetchShapes(ctx context.Context, cred Credentials, region, availabilityDomain string) ([]ComputeShape, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shapes := []ComputeShape{}
|
||||
seen := map[string]bool{}
|
||||
var page *string
|
||||
for {
|
||||
req := core.ListShapesRequest{
|
||||
CompartmentId: cred.EffectiveCompartment(),
|
||||
Limit: common.Int(500),
|
||||
Page: page,
|
||||
}
|
||||
if availabilityDomain != "" {
|
||||
req.AvailabilityDomain = &availabilityDomain
|
||||
}
|
||||
resp, err := cc.ListShapes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shapes: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if name := deref(item.Shape); !seen[name] {
|
||||
seen[name] = true
|
||||
shapes = append(shapes, toComputeShape(item))
|
||||
}
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return shapes, nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func toComputeShape(s core.Shape) ComputeShape {
|
||||
out := ComputeShape{
|
||||
Name: deref(s.Shape),
|
||||
BillingType: string(s.BillingType),
|
||||
IsFlexible: s.OcpuOptions != nil,
|
||||
ProcessorDescription: deref(s.ProcessorDescription),
|
||||
}
|
||||
out.Ocpus, out.MemoryInGBs = deref32(s.Ocpus), deref32(s.MemoryInGBs)
|
||||
if s.Gpus != nil {
|
||||
out.Gpus = *s.Gpus
|
||||
}
|
||||
if o := s.OcpuOptions; o != nil {
|
||||
out.OcpusMin, out.OcpusMax = deref32(o.Min), deref32(o.Max)
|
||||
}
|
||||
if m := s.MemoryOptions; m != nil {
|
||||
out.MemoryMinGBs, out.MemoryMaxGBs = deref32(m.MinInGBs), deref32(m.MaxInGBs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deref32 解引用 *float32,nil 时返回零值。
|
||||
func deref32(p *float32) float32 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// ociConsolePolicyID 是预置 sign-on 策略「Security Policy for OCI Console」的固定 ID。
|
||||
const ociConsolePolicyID = "OciConsolePolicy"
|
||||
|
||||
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
||||
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
|
||||
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
||||
type SignOnRuleInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Sequence int `json:"sequence"`
|
||||
AuthenticationFactor string `json:"authenticationFactor"`
|
||||
ConditionAttribute string `json:"conditionAttribute,omitempty"`
|
||||
ConditionValue string `json:"conditionValue,omitempty"`
|
||||
BuiltIn bool `json:"builtIn"`
|
||||
}
|
||||
|
||||
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
|
||||
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs, err := consolePolicyRules(ctx, dc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := make([]SignOnRuleInfo, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
info, err := loadSignOnRule(ctx, dc, deref(ref.Value), ref.Sequence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, info)
|
||||
}
|
||||
sort.Slice(rules, func(i, j int) bool { return rules[i].Sequence < rules[j].Sequence })
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func consolePolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient) ([]identitydomains.PolicyRules, error) {
|
||||
resp, err := dc.GetPolicy(ctx, identitydomains.GetPolicyRequest{
|
||||
PolicyId: common.String(ociConsolePolicyID),
|
||||
Attributes: common.String("id,name,rules"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get console sign-on policy: %w", err)
|
||||
}
|
||||
return resp.Rules, nil
|
||||
}
|
||||
|
||||
func loadSignOnRule(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string, seq *int) (SignOnRuleInfo, error) {
|
||||
resp, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: &ruleID})
|
||||
if err != nil {
|
||||
return SignOnRuleInfo{}, fmt.Errorf("get rule %s: %w", ruleID, err)
|
||||
}
|
||||
info := SignOnRuleInfo{
|
||||
ID: ruleID,
|
||||
Name: deref(resp.Name),
|
||||
BuiltIn: strings.HasPrefix(ruleID, "OciConsole"),
|
||||
}
|
||||
if seq != nil {
|
||||
info.Sequence = *seq
|
||||
}
|
||||
for _, r := range resp.Return {
|
||||
if deref(r.Name) == "authenticationFactor" {
|
||||
info.AuthenticationFactor = deref(r.Value)
|
||||
}
|
||||
}
|
||||
fillRuleCondition(ctx, dc, resp.Rule, &info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// fillRuleCondition 尽力回填规则条件(仅解析直接引用 Condition 的规则)。
|
||||
func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsClient, rule identitydomains.Rule, info *SignOnRuleInfo) {
|
||||
cg := rule.ConditionGroup
|
||||
if cg == nil || cg.Type != identitydomains.RuleConditionGroupTypeCondition || cg.Value == nil {
|
||||
return
|
||||
}
|
||||
resp, err := dc.GetCondition(ctx, identitydomains.GetConditionRequest{ConditionId: cg.Value})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
info.ConditionAttribute = deref(resp.AttributeName)
|
||||
info.ConditionValue = deref(resp.AttributeValue)
|
||||
}
|
||||
|
||||
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
|
||||
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
|
||||
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return SignOnRuleInfo{}, err
|
||||
}
|
||||
condID, err := createIdpCondition(ctx, dc, idpID, ruleName)
|
||||
if err != nil {
|
||||
return SignOnRuleInfo{}, err
|
||||
}
|
||||
ruleID, err := createExemptionRule(ctx, dc, condID, ruleName)
|
||||
if err != nil {
|
||||
deleteConditionQuiet(ctx, dc, condID)
|
||||
return SignOnRuleInfo{}, err
|
||||
}
|
||||
if err := prependRuleToPolicy(ctx, dc, ruleID); err != nil {
|
||||
deleteRuleQuiet(ctx, dc, ruleID)
|
||||
deleteConditionQuiet(ctx, dc, condID)
|
||||
return SignOnRuleInfo{}, err
|
||||
}
|
||||
return SignOnRuleInfo{
|
||||
ID: ruleID, Name: ruleName, Sequence: 1,
|
||||
AuthenticationFactor: "IDP",
|
||||
ConditionAttribute: "subject.authenticatedBy",
|
||||
ConditionValue: jsonList(idpID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonList(items ...string) string {
|
||||
b, _ := json.Marshal(items)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func createIdpCondition(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID, name string) (string, error) {
|
||||
value := jsonList(idpID)
|
||||
resp, err := dc.CreateCondition(ctx, identitydomains.CreateConditionRequest{
|
||||
Condition: identitydomains.Condition{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Condition"},
|
||||
Name: common.String(name + "-condition"),
|
||||
AttributeName: common.String("subject.authenticatedBy"),
|
||||
Operator: identitydomains.ConditionOperatorIn,
|
||||
AttributeValue: &value,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create idp condition: %w", err)
|
||||
}
|
||||
return deref(resp.Id), nil
|
||||
}
|
||||
|
||||
func createExemptionRule(ctx context.Context, dc identitydomains.IdentityDomainsClient, condID, name string) (string, error) {
|
||||
resp, err := dc.CreateRule(ctx, identitydomains.CreateRuleRequest{
|
||||
Rule: identitydomains.Rule{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Rule"},
|
||||
Name: &name,
|
||||
PolicyType: &identitydomains.RulePolicyType{Value: common.String("SignOn")},
|
||||
ConditionGroup: &identitydomains.RuleConditionGroup{
|
||||
Type: identitydomains.RuleConditionGroupTypeCondition,
|
||||
Value: &condID,
|
||||
},
|
||||
Return: []identitydomains.RuleReturn{
|
||||
{Name: common.String("effect"), Value: common.String("ALLOW")},
|
||||
{Name: common.String("reAuthenticate"), Value: common.String("false")},
|
||||
{Name: common.String("authenticationFactor"), Value: common.String("IDP")},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create exemption rule: %w", err)
|
||||
}
|
||||
return deref(resp.Id), nil
|
||||
}
|
||||
|
||||
// prependRuleToPolicy 把规则插到策略首位,其余规则优先级顺延。
|
||||
func prependRuleToPolicy(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) error {
|
||||
existing, err := consolePolicyRules(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := []interface{}{map[string]interface{}{"value": ruleID, "sequence": 1}}
|
||||
for i, r := range sortedRules(existing) {
|
||||
rules = append(rules, map[string]interface{}{"value": deref(r.Value), "sequence": i + 2})
|
||||
}
|
||||
return patchPolicyRules(ctx, dc, rules)
|
||||
}
|
||||
|
||||
// removeRuleFromPolicy 从策略移除规则,其余规则优先级从 1 重排。
|
||||
func removeRuleFromPolicy(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) error {
|
||||
existing, err := consolePolicyRules(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]interface{}, 0, len(existing))
|
||||
for _, r := range sortedRules(existing) {
|
||||
if deref(r.Value) == ruleID {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, map[string]interface{}{"value": deref(r.Value), "sequence": len(rules) + 1})
|
||||
}
|
||||
if len(rules) == len(existing) {
|
||||
return nil
|
||||
}
|
||||
return patchPolicyRules(ctx, dc, rules)
|
||||
}
|
||||
|
||||
func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRules {
|
||||
out := append([]identitydomains.PolicyRules(nil), rules...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
si, sj := 0, 0
|
||||
if out[i].Sequence != nil {
|
||||
si = *out[i].Sequence
|
||||
}
|
||||
if out[j].Sequence != nil {
|
||||
sj = *out[j].Sequence
|
||||
}
|
||||
return si < sj
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
||||
var value interface{} = rules
|
||||
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
||||
PolicyId: common.String(ociConsolePolicyID),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("rules"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch console sign-on policy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
|
||||
// 拒绝删除 Oracle 预置规则。
|
||||
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error {
|
||||
if strings.HasPrefix(ruleID, "OciConsole") {
|
||||
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: &ruleID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get rule %s: %w", ruleID, err)
|
||||
}
|
||||
if err := removeRuleFromPolicy(ctx, dc, ruleID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dc.DeleteRule(ctx, identitydomains.DeleteRuleRequest{RuleId: &ruleID}); err != nil {
|
||||
return fmt.Errorf("delete rule %s: %w", ruleID, err)
|
||||
}
|
||||
cg := rule.ConditionGroup
|
||||
if cg != nil && cg.Type == identitydomains.RuleConditionGroupTypeCondition && cg.Value != nil {
|
||||
deleteConditionQuiet(ctx, dc, *cg.Value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteRuleQuiet(ctx context.Context, dc identitydomains.IdentityDomainsClient, ruleID string) {
|
||||
_, _ = dc.DeleteRule(ctx, identitydomains.DeleteRuleRequest{RuleId: &ruleID})
|
||||
}
|
||||
|
||||
func deleteConditionQuiet(ctx context.Context, dc identitydomains.IdentityDomainsClient, condID string) {
|
||||
_, _ = dc.DeleteCondition(ctx, identitydomains.DeleteConditionRequest{ConditionId: &condID})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/tenantmanagercontrolplane"
|
||||
)
|
||||
|
||||
// SubscriptionInfo 是租户订阅的摘要信息。
|
||||
type SubscriptionInfo struct {
|
||||
ID string `json:"id"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
TimeUpdated *time.Time `json:"timeUpdated"`
|
||||
}
|
||||
|
||||
// PromotionInfo 是订阅下的一条促销记录。
|
||||
type PromotionInfo struct {
|
||||
Duration int `json:"duration"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Amount float32 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
IsIntentToPay bool `json:"isIntentToPay"`
|
||||
CurrencyUnit string `json:"currencyUnit"`
|
||||
TimeStarted *time.Time `json:"timeStarted"`
|
||||
TimeExpired *time.Time `json:"timeExpired"`
|
||||
}
|
||||
|
||||
// SubscriptionDetail 是单个订阅的完整信息(Classic v1 订阅)。
|
||||
type SubscriptionDetail struct {
|
||||
ID string `json:"id"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
ClassicSubscriptionID string `json:"classicSubscriptionId"`
|
||||
PaymentModel string `json:"paymentModel"`
|
||||
SubscriptionTier string `json:"subscriptionTier"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
ProgramType string `json:"programType"`
|
||||
CustomerCountryCode string `json:"customerCountryCode"`
|
||||
CloudAmountCurrency string `json:"cloudAmountCurrency"`
|
||||
CsiNumber string `json:"csiNumber"`
|
||||
RegionAssignment string `json:"regionAssignment"`
|
||||
IsGovernmentSubscription bool `json:"isGovernmentSubscription"`
|
||||
Promotions []PromotionInfo `json:"promotions"`
|
||||
StartDate *time.Time `json:"startDate"`
|
||||
EndDate *time.Time `json:"endDate"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
TimeUpdated *time.Time `json:"timeUpdated"`
|
||||
}
|
||||
|
||||
// ListSubscriptions 实现 Client:分页列出租户全部订阅摘要。
|
||||
func (c *RealClient) ListSubscriptions(ctx context.Context, cred Credentials) ([]SubscriptionInfo, error) {
|
||||
sc, err := tenantmanagercontrolplane.NewSubscriptionClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new subscription client: %w", err)
|
||||
}
|
||||
applyProxy(&sc.BaseClient, cred)
|
||||
var subs []SubscriptionInfo
|
||||
var page *string
|
||||
for {
|
||||
resp, err := sc.ListSubscriptions(ctx, tenantmanagercontrolplane.ListSubscriptionsRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list subscriptions: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
subs = append(subs, SubscriptionInfo{
|
||||
ID: deref(item.GetId()),
|
||||
ServiceName: deref(item.GetServiceName()),
|
||||
TimeCreated: sdkTime(item.GetTimeCreated()),
|
||||
TimeUpdated: sdkTime(item.GetTimeUpdated()),
|
||||
})
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(subs), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscription 实现 Client:查询单个订阅的完整信息。
|
||||
func (c *RealClient) GetSubscription(ctx context.Context, cred Credentials, subscriptionID string) (SubscriptionDetail, error) {
|
||||
sc, err := tenantmanagercontrolplane.NewSubscriptionClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return SubscriptionDetail{}, fmt.Errorf("new subscription client: %w", err)
|
||||
}
|
||||
applyProxy(&sc.BaseClient, cred)
|
||||
resp, err := sc.GetSubscription(ctx, tenantmanagercontrolplane.GetSubscriptionRequest{
|
||||
SubscriptionId: &subscriptionID,
|
||||
})
|
||||
if err != nil {
|
||||
return SubscriptionDetail{}, fmt.Errorf("get subscription %s: %w", subscriptionID, err)
|
||||
}
|
||||
switch sub := resp.Subscription.(type) {
|
||||
case tenantmanagercontrolplane.ClassicSubscription:
|
||||
return toSubscriptionDetail(sub), nil
|
||||
case *tenantmanagercontrolplane.ClassicSubscription:
|
||||
return toSubscriptionDetail(*sub), nil
|
||||
default:
|
||||
return SubscriptionDetail{}, fmt.Errorf("get subscription %s: unexpected type %T", subscriptionID, resp.Subscription)
|
||||
}
|
||||
}
|
||||
|
||||
func toSubscriptionDetail(sub tenantmanagercontrolplane.ClassicSubscription) SubscriptionDetail {
|
||||
detail := SubscriptionDetail{
|
||||
ID: deref(sub.Id),
|
||||
ServiceName: deref(sub.ServiceName),
|
||||
ClassicSubscriptionID: deref(sub.ClassicSubscriptionId),
|
||||
PaymentModel: deref(sub.PaymentModel),
|
||||
SubscriptionTier: deref(sub.SubscriptionTier),
|
||||
LifecycleState: string(sub.LifecycleState),
|
||||
ProgramType: deref(sub.ProgramType),
|
||||
CustomerCountryCode: deref(sub.CustomerCountryCode),
|
||||
CloudAmountCurrency: deref(sub.CloudAmountCurrency),
|
||||
CsiNumber: deref(sub.CsiNumber),
|
||||
RegionAssignment: deref(sub.RegionAssignment),
|
||||
IsGovernmentSubscription: sub.IsGovernmentSubscription != nil && *sub.IsGovernmentSubscription,
|
||||
StartDate: sdkTime(sub.StartDate),
|
||||
EndDate: sdkTime(sub.EndDate),
|
||||
TimeCreated: sdkTime(sub.TimeCreated),
|
||||
TimeUpdated: sdkTime(sub.TimeUpdated),
|
||||
}
|
||||
detail.Promotions = make([]PromotionInfo, 0, len(sub.Promotion))
|
||||
for _, p := range sub.Promotion {
|
||||
detail.Promotions = append(detail.Promotions, toPromotionInfo(p))
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func toPromotionInfo(p tenantmanagercontrolplane.Promotion) PromotionInfo {
|
||||
info := PromotionInfo{
|
||||
Status: string(p.Status),
|
||||
DurationUnit: deref(p.DurationUnit),
|
||||
CurrencyUnit: deref(p.CurrencyUnit),
|
||||
TimeStarted: sdkTime(p.TimeStarted),
|
||||
TimeExpired: sdkTime(p.TimeExpired),
|
||||
}
|
||||
if p.Duration != nil {
|
||||
info.Duration = *p.Duration
|
||||
}
|
||||
if p.Amount != nil {
|
||||
info.Amount = *p.Amount
|
||||
}
|
||||
if p.IsIntentToPay != nil {
|
||||
info.IsIntentToPay = *p.IsIntentToPay
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func sdkTime(t *common.SDKTime) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return &t.Time
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
"github.com/oracle/oci-go-sdk/v65/limits"
|
||||
)
|
||||
|
||||
// maxAvailabilityLookups 限制附带用量查询的条数:
|
||||
// GetResourceAvailability 每条配额一次调用(OCI 无批量接口),
|
||||
// 定义预筛 + 并发查询后 100 条约需 1~3 秒。
|
||||
const maxAvailabilityLookups = 100
|
||||
|
||||
// limitsPageSize 是 limits 系列 List 接口的显式分页大小(OCI 通用上限 1000);
|
||||
// 不传时服务端默认约百条一页,全量拉取要反复翻页。
|
||||
const limitsPageSize = 1000
|
||||
|
||||
// availabilityConcurrency 是用量查询并发度;Limits 端点 TPS 不高,并发过高易撞限流。
|
||||
const availabilityConcurrency = 8
|
||||
|
||||
// limitDefTTL 是配额定义缓存时长:定义(是否支持用量查询)几乎不随时间变化。
|
||||
const limitDefTTL = 6 * time.Hour
|
||||
|
||||
// RegionSubscription 是租户对一个区域的订阅状态。
|
||||
type RegionSubscription struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
IsHomeRegion bool `json:"isHomeRegion"`
|
||||
}
|
||||
|
||||
// LimitValue 是一条服务配额;Used/Available 仅在请求用量时填充,
|
||||
// 且 OCI 对部分配额不提供用量数据,此时保持为 nil。
|
||||
type LimitValue struct {
|
||||
Name string `json:"name"`
|
||||
ScopeType string `json:"scopeType"`
|
||||
AvailabilityDomain string `json:"availabilityDomain,omitempty"`
|
||||
Value int64 `json:"value"`
|
||||
Used *int64 `json:"used,omitempty"`
|
||||
Available *int64 `json:"available,omitempty"`
|
||||
}
|
||||
|
||||
// LimitsQuery 是一次配额查询的条件。
|
||||
type LimitsQuery struct {
|
||||
Region string // 目标区域,空则用凭据默认区域
|
||||
Service string // 服务名,如 compute
|
||||
Name string // 可选,配额名模糊过滤(大小写不敏感子串,本地过滤)
|
||||
ScopeType string // 可选 GLOBAL / REGION / AD,下推给 OCI 过滤
|
||||
AvailabilityDomain string // 可选,AD 全名,下推给 OCI 过滤
|
||||
NonZero bool // 过滤掉上限为 0 的配额项(在用量查询前生效,不占并发名额)
|
||||
WithAvailability bool // 并发附带 used/available
|
||||
}
|
||||
|
||||
// LimitService 是配额体系支持的一个服务。
|
||||
type LimitService struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListRegionSubscriptions 实现 Client。
|
||||
func (c *RealClient) ListRegionSubscriptions(ctx context.Context, cred Credentials) ([]RegionSubscription, error) {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
resp, err := ic.ListRegionSubscriptions(ctx, identity.ListRegionSubscriptionsRequest{
|
||||
TenancyId: &cred.TenancyOCID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list region subscriptions: %w", err)
|
||||
}
|
||||
subs := make([]RegionSubscription, 0, len(resp.Items))
|
||||
for _, item := range resp.Items {
|
||||
subs = append(subs, RegionSubscription{
|
||||
Key: deref(item.RegionKey),
|
||||
Name: deref(item.RegionName),
|
||||
Status: string(item.Status),
|
||||
IsHomeRegion: item.IsHomeRegion != nil && *item.IsHomeRegion,
|
||||
})
|
||||
}
|
||||
SortRegionSubscriptions(subs)
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// SortRegionSubscriptions 主区域置顶,其余按友好名(alias)字典序,
|
||||
// 本地表查不到 alias 时回退区域名;实时接口与缓存读取共用,全局统一展示顺序。
|
||||
func SortRegionSubscriptions(subs []RegionSubscription) {
|
||||
sortKey := func(sub RegionSubscription) string {
|
||||
if info, ok := RegionByKey(sub.Key); ok {
|
||||
return info.Alias
|
||||
}
|
||||
return sub.Name
|
||||
}
|
||||
sort.SliceStable(subs, func(i, j int) bool {
|
||||
if subs[i].IsHomeRegion != subs[j].IsHomeRegion {
|
||||
return subs[i].IsHomeRegion
|
||||
}
|
||||
return sortKey(subs[i]) < sortKey(subs[j])
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribeRegion 实现 Client:订阅新区域。OCI 要求该请求发往
|
||||
// home region,且区域订阅一经创建不可取消。
|
||||
func (c *RealClient) SubscribeRegion(ctx context.Context, cred Credentials, homeRegion, regionKey string) error {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
if homeRegion != "" {
|
||||
ic.SetRegion(normalizeRegion(homeRegion))
|
||||
}
|
||||
_, err = ic.CreateRegionSubscription(ctx, identity.CreateRegionSubscriptionRequest{
|
||||
TenancyId: &cred.TenancyOCID,
|
||||
CreateRegionSubscriptionDetails: identity.CreateRegionSubscriptionDetails{
|
||||
RegionKey: ®ionKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create region subscription %s: %w", regionKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// limitsClient 构造 limits 服务客户端;region 为空时用凭据默认区域。
|
||||
func limitsClient(cred Credentials, region string) (limits.LimitsClient, error) {
|
||||
lc, err := limits.NewLimitsClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return lc, fmt.Errorf("new limits client: %w", err)
|
||||
}
|
||||
applyProxy(&lc.BaseClient, cred)
|
||||
if region != "" {
|
||||
lc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return lc, nil
|
||||
}
|
||||
|
||||
// ListLimits 实现 Client:全量拉取配额(scopeType / AD 条件下推),
|
||||
// 名称本地子串过滤,按需并发附带用量。
|
||||
func (c *RealClient) ListLimits(ctx context.Context, cred Credentials, q LimitsQuery) ([]LimitValue, error) {
|
||||
lc, err := limitsClient(cred, q.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := listLimitValues(ctx, lc, cred.TenancyOCID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = filterLimitsByName(values, q.Name)
|
||||
if q.NonZero {
|
||||
values = filterNonZero(values)
|
||||
}
|
||||
if !q.WithAvailability {
|
||||
return values, nil
|
||||
}
|
||||
if len(values) > maxAvailabilityLookups {
|
||||
return nil, fmt.Errorf("list limits: %d entries exceed availability lookup cap %d, narrow with name filter",
|
||||
len(values), maxAvailabilityLookups)
|
||||
}
|
||||
supported := c.availabilitySupport(ctx, lc, cred.TenancyOCID, q.Region, q.Service)
|
||||
attachAvailability(ctx, lc, cred.TenancyOCID, q.Service, values, supported)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// listLimitValues 分页取回一个服务的配额;scopeType / availabilityDomain 下推给 OCI。
|
||||
// OCI 的 name 参数只支持精确匹配,子串过滤由调用方本地完成。
|
||||
func listLimitValues(ctx context.Context, lc limits.LimitsClient, tenancyOCID string, q LimitsQuery) ([]LimitValue, error) {
|
||||
var values []LimitValue
|
||||
var page *string
|
||||
for {
|
||||
req := limits.ListLimitValuesRequest{
|
||||
CompartmentId: &tenancyOCID,
|
||||
ServiceName: &q.Service,
|
||||
Limit: common.Int(limitsPageSize),
|
||||
Page: page,
|
||||
}
|
||||
if q.ScopeType != "" {
|
||||
req.ScopeType = limits.ListLimitValuesScopeTypeEnum(q.ScopeType)
|
||||
}
|
||||
if q.AvailabilityDomain != "" {
|
||||
req.AvailabilityDomain = &q.AvailabilityDomain
|
||||
}
|
||||
resp, err := lc.ListLimitValues(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list limit values of %s: %w", q.Service, err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
values = append(values, toLimitValue(item))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(values), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
func toLimitValue(item limits.LimitValueSummary) LimitValue {
|
||||
var value int64
|
||||
if item.Value != nil {
|
||||
value = *item.Value
|
||||
}
|
||||
return LimitValue{
|
||||
Name: deref(item.Name),
|
||||
ScopeType: string(item.ScopeType),
|
||||
AvailabilityDomain: deref(item.AvailabilityDomain),
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// filterLimitsByName 按大小写不敏感的子串匹配过滤配额名。
|
||||
func filterLimitsByName(values []LimitValue, name string) []LimitValue {
|
||||
if name == "" {
|
||||
return values
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(name))
|
||||
filtered := make([]LimitValue, 0, len(values))
|
||||
for _, v := range values {
|
||||
if strings.Contains(strings.ToLower(v.Name), needle) {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// filterNonZero 过滤掉上限为 0 的配额项。
|
||||
func filterNonZero(values []LimitValue) []LimitValue {
|
||||
filtered := make([]LimitValue, 0, len(values))
|
||||
for _, v := range values {
|
||||
if v.Value > 0 {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ListLimitServices 实现 Client:分页列出配额体系支持的全部服务。
|
||||
func (c *RealClient) ListLimitServices(ctx context.Context, cred Credentials, region string) ([]LimitService, error) {
|
||||
lc, err := limitsClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var services []LimitService
|
||||
var page *string
|
||||
for {
|
||||
resp, err := lc.ListServices(ctx, limits.ListServicesRequest{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Limit: common.Int(limitsPageSize),
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list limit services: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
services = append(services, LimitService{
|
||||
Name: deref(item.Name),
|
||||
Description: deref(item.Description),
|
||||
})
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(services), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// limitDefEntry 是一份配额定义预筛结果的缓存条目。
|
||||
type limitDefEntry struct {
|
||||
expires time.Time
|
||||
supported map[string]bool
|
||||
}
|
||||
|
||||
// availabilitySupport 返回配额名 → 是否支持用量查询的映射(带 TTL 缓存);
|
||||
// 拉取失败时返回 nil,调用方视为全部照查(保守降级)。
|
||||
func (c *RealClient) availabilitySupport(ctx context.Context, lc limits.LimitsClient, tenancyOCID, region, service string) map[string]bool {
|
||||
key := tenancyOCID + "|" + region + "|" + service
|
||||
if v, ok := c.limitDefs.Load(key); ok {
|
||||
if e := v.(limitDefEntry); time.Now().Before(e.expires) {
|
||||
return e.supported
|
||||
}
|
||||
}
|
||||
supported, err := listLimitDefinitions(ctx, lc, tenancyOCID, service)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.limitDefs.Store(key, limitDefEntry{expires: time.Now().Add(limitDefTTL), supported: supported})
|
||||
return supported
|
||||
}
|
||||
|
||||
// listLimitDefinitions 分页取回一个服务的配额定义,产出配额名 → 是否支持用量查询的映射;
|
||||
// 字段缺失(nil)视为支持,仅明确 false 的会被预筛跳过。
|
||||
func listLimitDefinitions(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string) (map[string]bool, error) {
|
||||
supported := map[string]bool{}
|
||||
var page *string
|
||||
for {
|
||||
resp, err := lc.ListLimitDefinitions(ctx, limits.ListLimitDefinitionsRequest{
|
||||
CompartmentId: &tenancyOCID,
|
||||
ServiceName: &service,
|
||||
Limit: common.Int(limitsPageSize),
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list limit definitions of %s: %w", service, err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.Name == nil {
|
||||
continue
|
||||
}
|
||||
supported[*item.Name] = item.IsResourceAvailabilitySupported == nil || *item.IsResourceAvailabilitySupported
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return supported, nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// attachAvailability 并发查询用量:预筛掉明确不支持用量查询的配额,
|
||||
// 单条失败不影响整体,字段保持 nil。
|
||||
func attachAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, values []LimitValue, supported map[string]bool) {
|
||||
sem := make(chan struct{}, availabilityConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i := range values {
|
||||
if s, ok := supported[values[i].Name]; ok && !s {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(v *LimitValue) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
fetchAvailability(ctx, lc, tenancyOCID, service, v)
|
||||
}(&values[i])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// fetchAvailability 查询单条配额的用量;AD 级配额必须带 availabilityDomain。
|
||||
func fetchAvailability(ctx context.Context, lc limits.LimitsClient, tenancyOCID, service string, v *LimitValue) {
|
||||
req := limits.GetResourceAvailabilityRequest{
|
||||
ServiceName: &service,
|
||||
LimitName: &v.Name,
|
||||
CompartmentId: &tenancyOCID,
|
||||
}
|
||||
if strings.EqualFold(v.ScopeType, "AD") {
|
||||
req.AvailabilityDomain = &v.AvailabilityDomain
|
||||
}
|
||||
resp, err := lc.GetResourceAvailability(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
v.Used = resp.Used
|
||||
v.Available = resp.Available
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package oci
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterLimitsByName(t *testing.T) {
|
||||
values := []LimitValue{
|
||||
{Name: "standard-a1-core-count"},
|
||||
{Name: "standard-e2-core-count"},
|
||||
{Name: "standard-a1-memory-count"},
|
||||
{Name: "vm-standard2-1-count"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
wantNames []string
|
||||
}{
|
||||
{
|
||||
name: "空过滤返回全部",
|
||||
filter: "",
|
||||
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count", "standard-a1-memory-count", "vm-standard2-1-count"},
|
||||
},
|
||||
{
|
||||
name: "模糊匹配 core-count",
|
||||
filter: "core-count",
|
||||
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count"},
|
||||
},
|
||||
{
|
||||
name: "大小写不敏感",
|
||||
filter: "CORE-Count",
|
||||
wantNames: []string{"standard-a1-core-count", "standard-e2-core-count"},
|
||||
},
|
||||
{
|
||||
name: "前后空白被忽略",
|
||||
filter: " a1 ",
|
||||
wantNames: []string{"standard-a1-core-count", "standard-a1-memory-count"},
|
||||
},
|
||||
{
|
||||
name: "精确名仍可命中",
|
||||
filter: "standard-a1-core-count",
|
||||
wantNames: []string{"standard-a1-core-count"},
|
||||
},
|
||||
{
|
||||
name: "无匹配返回空",
|
||||
filter: "gpu",
|
||||
wantNames: []string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := filterLimitsByName(values, tt.filter)
|
||||
gotNames := make([]string, 0, len(got))
|
||||
for _, v := range got {
|
||||
gotNames = append(gotNames, v.Name)
|
||||
}
|
||||
if len(gotNames) != len(tt.wantNames) {
|
||||
t.Fatalf("filterLimitsByName(%q) = %v, want %v", tt.filter, gotNames, tt.wantNames)
|
||||
}
|
||||
for i := range gotNames {
|
||||
if gotNames[i] != tt.wantNames[i] {
|
||||
t.Errorf("filterLimitsByName(%q)[%d] = %q, want %q", tt.filter, i, gotNames[i], tt.wantNames[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// TenantUser 是租户下的一个 IAM 用户。
|
||||
type TenantUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
MfaActivated bool `json:"mfaActivated"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
IsCurrentUser bool `json:"isCurrentUser"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
||||
}
|
||||
|
||||
// Oracle 预置的管理员角色与管理员组显示名。
|
||||
const (
|
||||
domainAdminRoleName = "Identity Domain Administrator"
|
||||
administratorsGroupName = "Administrators"
|
||||
)
|
||||
|
||||
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
|
||||
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
|
||||
|
||||
// TenantUserDetail 是编辑表单需要的域档案与管理员状态;
|
||||
// InDomain 为 false 表示经典 IAM 用户,其余字段无意义。
|
||||
type TenantUserDetail struct {
|
||||
InDomain bool `json:"inDomain"`
|
||||
GivenName string `json:"givenName"`
|
||||
FamilyName string `json:"familyName"`
|
||||
IsDomainAdmin bool `json:"isDomainAdmin"`
|
||||
InAdminGroup bool `json:"inAdminGroup"`
|
||||
}
|
||||
|
||||
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
||||
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
if err != nil {
|
||||
return TenantUserDetail{}, err
|
||||
}
|
||||
u, found, err := domainUserByOcid(ctx, dc, userID, "groups", scimAppRolesAttr)
|
||||
if err != nil || !found {
|
||||
return TenantUserDetail{}, err
|
||||
}
|
||||
return toTenantUserDetail(u), nil
|
||||
}
|
||||
|
||||
// toTenantUserDetail 提取姓名、管理员角色与管理员组成员状态。
|
||||
func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
|
||||
d := TenantUserDetail{InDomain: true}
|
||||
if u.Name != nil {
|
||||
d.GivenName = deref(u.Name.GivenName)
|
||||
d.FamilyName = deref(u.Name.FamilyName)
|
||||
}
|
||||
for _, g := range u.Groups {
|
||||
if deref(g.Display) == administratorsGroupName {
|
||||
d.InAdminGroup = true
|
||||
}
|
||||
}
|
||||
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser; ext != nil {
|
||||
for _, r := range ext.AppRoles {
|
||||
if deref(r.Display) == domainAdminRoleName {
|
||||
d.IsDomainAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (c *RealClient) identityClientAt(cred Credentials, region string) (identity.IdentityClient, error) {
|
||||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return ic, fmt.Errorf("new identity client: %w", err)
|
||||
}
|
||||
applyProxy(&ic.BaseClient, cred)
|
||||
if region != "" {
|
||||
ic.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return ic, nil
|
||||
}
|
||||
|
||||
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。
|
||||
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) {
|
||||
ic, err := c.identityClientAt(cred, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []TenantUser
|
||||
var page *string
|
||||
for {
|
||||
resp, err := ic.ListUsers(ctx, identity.ListUsersRequest{CompartmentId: &cred.TenancyOCID, Page: page})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
users = append(users, toTenantUser(item, cred.UserOCID))
|
||||
}
|
||||
if resp.OpcNextPage == nil {
|
||||
return orEmpty(users), nil
|
||||
}
|
||||
page = resp.OpcNextPage
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
|
||||
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
|
||||
// 先授身份域管理员角色,再加入 Administrators 组。
|
||||
type CreateTenantUserInput struct {
|
||||
Name string // 登录用户名
|
||||
Description string
|
||||
Email string
|
||||
GivenName string
|
||||
FamilyName string
|
||||
GrantDomainAdmin bool // 授予「身份域管理员」应用角色
|
||||
AddToAdminGroup bool // 加入 Administrators 组
|
||||
}
|
||||
|
||||
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
||||
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
||||
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
||||
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if in.GrantDomainAdmin || in.AddToAdminGroup {
|
||||
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
|
||||
}
|
||||
return c.createUserClassic(ctx, cred, homeRegion, in)
|
||||
}
|
||||
|
||||
// createUserClassic 走经典 IAM API 创建用户(姓名并入描述)。
|
||||
func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
description := in.Description
|
||||
if description == "" {
|
||||
description = strings.TrimSpace(in.GivenName + " " + in.FamilyName)
|
||||
}
|
||||
details := identity.CreateUserDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Name: &in.Name,
|
||||
Description: &description,
|
||||
}
|
||||
if in.Email != "" {
|
||||
details.Email = &in.Email
|
||||
}
|
||||
resp, err := ic.CreateUser(ctx, identity.CreateUserRequest{CreateUserDetails: details})
|
||||
if err != nil {
|
||||
return TenantUser{}, fmt.Errorf("create user %s: %w", in.Name, err)
|
||||
}
|
||||
return toTenantUser(resp.User, cred.UserOCID), nil
|
||||
}
|
||||
|
||||
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
|
||||
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
resp, err := dc.CreateUser(ctx, identitydomains.CreateUserRequest{User: buildScimUser(in)})
|
||||
if err != nil {
|
||||
return TenantUser{}, fmt.Errorf("create domain user %s: %w", in.Name, err)
|
||||
}
|
||||
scimID := deref(resp.User.Id)
|
||||
// 同时勾选时约定先授身份域管理员,再加管理员组
|
||||
if in.GrantDomainAdmin {
|
||||
if err := grantDomainAdminRole(ctx, dc, scimID); err != nil {
|
||||
return toDomainTenantUser(resp.User), err
|
||||
}
|
||||
}
|
||||
if in.AddToAdminGroup {
|
||||
if err := addUserToAdminGroup(ctx, dc, scimID); err != nil {
|
||||
return toDomainTenantUser(resp.User), err
|
||||
}
|
||||
}
|
||||
return toDomainTenantUser(resp.User), nil
|
||||
}
|
||||
|
||||
func buildScimUser(in CreateTenantUserInput) identitydomains.User {
|
||||
u := identitydomains.User{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
|
||||
UserName: &in.Name,
|
||||
Name: &identitydomains.UserName{
|
||||
GivenName: &in.GivenName,
|
||||
FamilyName: &in.FamilyName,
|
||||
},
|
||||
}
|
||||
if in.Description != "" {
|
||||
u.Description = &in.Description
|
||||
}
|
||||
if in.Email != "" {
|
||||
primary := true
|
||||
u.Emails = []identitydomains.UserEmails{{
|
||||
Value: &in.Email,
|
||||
Type: identitydomains.UserEmailsTypeWork,
|
||||
Primary: &primary,
|
||||
}}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// findDomainAdminRole 查找「Identity Domain Administrator」应用角色。
|
||||
func findDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.AppRole, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q", domainAdminRoleName)
|
||||
count := 1
|
||||
roles, err := dc.ListAppRoles(ctx, identitydomains.ListAppRolesRequest{
|
||||
Filter: &filter,
|
||||
Attributes: common.String("id,displayName,app"),
|
||||
Count: &count,
|
||||
})
|
||||
if err != nil {
|
||||
return identitydomains.AppRole{}, fmt.Errorf("find domain administrator role: %w", err)
|
||||
}
|
||||
if len(roles.Resources) == 0 || roles.Resources[0].Id == nil || roles.Resources[0].App == nil {
|
||||
return identitydomains.AppRole{}, fmt.Errorf("identity domain administrator role not found")
|
||||
}
|
||||
return roles.Resources[0], nil
|
||||
}
|
||||
|
||||
// grantDomainAdminRole 授予「Identity Domain Administrator」应用角色。
|
||||
func grantDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||
role, err := findDomainAdminRole(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dc.CreateGrant(ctx, identitydomains.CreateGrantRequest{
|
||||
Grant: identitydomains.Grant{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Grant"},
|
||||
GrantMechanism: identitydomains.GrantGrantMechanismAdministratorToUser,
|
||||
Grantee: &identitydomains.GrantGrantee{
|
||||
Type: identitydomains.GrantGranteeTypeUser,
|
||||
Value: &userSCIMID,
|
||||
},
|
||||
App: &identitydomains.GrantApp{Value: role.App.Value},
|
||||
Entitlement: &identitydomains.GrantEntitlement{
|
||||
AttributeName: common.String("appRoles"),
|
||||
AttributeValue: role.Id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant domain administrator role: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeDomainAdminRole 撤销直接授予用户的「身份域管理员」角色;
|
||||
// 只删 ADMINISTRATOR_TO_USER 机制的授权,经组继承的不动。
|
||||
func revokeDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||
role, err := findDomainAdminRole(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter := fmt.Sprintf("grantee.value eq %q and entitlement.attributeValue eq %q", userSCIMID, deref(role.Id))
|
||||
grants, err := dc.ListGrants(ctx, identitydomains.ListGrantsRequest{
|
||||
Filter: &filter,
|
||||
Attributes: common.String("id,grantMechanism"),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find domain administrator grants: %w", err)
|
||||
}
|
||||
for _, g := range grants.Resources {
|
||||
if g.GrantMechanism != identitydomains.GrantGrantMechanismAdministratorToUser {
|
||||
continue
|
||||
}
|
||||
if _, err := dc.DeleteGrant(ctx, identitydomains.DeleteGrantRequest{GrantId: g.Id}); err != nil {
|
||||
return fmt.Errorf("revoke domain administrator role: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addUserToAdminGroup 把用户加入 Administrators 组。
|
||||
func addUserToAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||
group, err := adminGroupRef(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group == nil {
|
||||
return fmt.Errorf("administrators group not found")
|
||||
}
|
||||
var members interface{} = []map[string]string{{"value": userSCIMID, "type": "User"}}
|
||||
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
||||
GroupId: group.Value,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpAdd,
|
||||
Path: common.String("members"),
|
||||
Value: &members,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add user to administrators group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUserFromAdminGroup 把用户移出 Administrators 组。
|
||||
func removeUserFromAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
||||
group, err := adminGroupRef(ctx, dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group == nil {
|
||||
return fmt.Errorf("administrators group not found")
|
||||
}
|
||||
path := fmt.Sprintf("members[value eq %q]", userSCIMID)
|
||||
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
||||
GroupId: group.Value,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpRemove,
|
||||
Path: &path,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove user from administrators group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toDomainTenantUser 把 SCIM 用户转为列表通用形状(ID 用对应的 IAM OCID)。
|
||||
func toDomainTenantUser(u identitydomains.User) TenantUser {
|
||||
out := TenantUser{
|
||||
ID: deref(u.Ocid),
|
||||
Name: deref(u.UserName),
|
||||
Description: deref(u.Description),
|
||||
LifecycleState: "ACTIVE",
|
||||
}
|
||||
if out.ID == "" {
|
||||
out.ID = deref(u.Id)
|
||||
}
|
||||
for _, e := range u.Emails {
|
||||
if e.Primary != nil && *e.Primary {
|
||||
out.Email = deref(e.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpdateTenantUserInput 是编辑用户资料的输入;nil 字段不修改。
|
||||
// 管理员字段为三态:nil 不动,true 授予/加入,false 撤销/移出。
|
||||
type UpdateTenantUserInput struct {
|
||||
Description *string
|
||||
Email *string
|
||||
GivenName *string
|
||||
FamilyName *string
|
||||
GrantDomainAdmin *bool // 「身份域管理员」应用角色
|
||||
AddToAdminGroup *bool // Administrators 组成员
|
||||
}
|
||||
|
||||
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
||||
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
||||
// IAM API(仅备注与邮箱,姓名变更报错)。
|
||||
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
domainUser, found, err := domainUserByOcid(ctx, dc, userID)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
if found {
|
||||
user, err := patchDomainUser(ctx, dc, domainUser, in)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
scimID := deref(domainUser.Id)
|
||||
if err := applyAdminGrants(ctx, dc, scimID, in); err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
if in.GivenName != nil || in.FamilyName != nil {
|
||||
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
|
||||
}
|
||||
if in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil {
|
||||
return TenantUser{}, fmt.Errorf("update user %s: 管理员授权仅身份域用户可操作(该用户不在身份域中)", userID)
|
||||
}
|
||||
return c.updateUserClassic(ctx, cred, homeRegion, userID, in)
|
||||
}
|
||||
|
||||
// updateUserClassic 走经典 IAM API 更新备注与邮箱。
|
||||
func (c *RealClient) updateUserClassic(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
resp, err := ic.UpdateUser(ctx, identity.UpdateUserRequest{
|
||||
UserId: &userID,
|
||||
UpdateUserDetails: identity.UpdateUserDetails{Description: in.Description, Email: in.Email},
|
||||
})
|
||||
if err != nil {
|
||||
return TenantUser{}, fmt.Errorf("update user %s: %w", userID, err)
|
||||
}
|
||||
return toTenantUser(resp.User, cred.UserOCID), nil
|
||||
}
|
||||
|
||||
// patchDomainUser 以 SCIM Patch 修改域用户的指定字段。
|
||||
func patchDomainUser(ctx context.Context, dc identitydomains.IdentityDomainsClient, u identitydomains.User, in UpdateTenantUserInput) (TenantUser, error) {
|
||||
ops := buildUserPatchOps(u, in)
|
||||
if len(ops) == 0 {
|
||||
return toDomainTenantUser(u), nil
|
||||
}
|
||||
resp, err := dc.PatchUser(ctx, identitydomains.PatchUserRequest{
|
||||
UserId: u.Id,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: ops,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return TenantUser{}, fmt.Errorf("patch domain user %s: %w", deref(u.UserName), err)
|
||||
}
|
||||
return toDomainTenantUser(resp.User), nil
|
||||
}
|
||||
|
||||
// applyAdminGrants 同步管理员状态:nil 不动,true 授予/加入,false 撤销/移出。
|
||||
func applyAdminGrants(ctx context.Context, dc identitydomains.IdentityDomainsClient, scimID string, in UpdateTenantUserInput) error {
|
||||
if in.GrantDomainAdmin != nil {
|
||||
apply := revokeDomainAdminRole
|
||||
if *in.GrantDomainAdmin {
|
||||
apply = grantDomainAdminRole
|
||||
}
|
||||
if err := apply(ctx, dc, scimID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if in.AddToAdminGroup != nil {
|
||||
apply := removeUserFromAdminGroup
|
||||
if *in.AddToAdminGroup {
|
||||
apply = addUserToAdminGroup
|
||||
}
|
||||
return apply(ctx, dc, scimID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUserPatchOps(u identitydomains.User, in UpdateTenantUserInput) []identitydomains.Operations {
|
||||
var ops []identitydomains.Operations
|
||||
if in.GivenName != nil {
|
||||
ops = append(ops, replaceOp("name.givenName", *in.GivenName))
|
||||
}
|
||||
if in.FamilyName != nil {
|
||||
ops = append(ops, replaceOp("name.familyName", *in.FamilyName))
|
||||
}
|
||||
if in.Description != nil {
|
||||
ops = append(ops, replaceOp("description", *in.Description))
|
||||
}
|
||||
if in.Email != nil {
|
||||
ops = append(ops, emailPatchOp(u, *in.Email))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// emailPatchOp 生成主邮箱修改操作:已有 work 邮箱按 filter 路径替换,
|
||||
// 没有则整体添加一条 primary work 邮箱(避免整组 replace 冲掉 recovery 邮箱)。
|
||||
func emailPatchOp(u identitydomains.User, email string) identitydomains.Operations {
|
||||
for _, e := range u.Emails {
|
||||
if e.Type == identitydomains.UserEmailsTypeWork {
|
||||
return replaceOp(`emails[type eq "work"].value`, email)
|
||||
}
|
||||
}
|
||||
var value interface{} = []map[string]interface{}{
|
||||
{"value": email, "type": "work", "primary": true},
|
||||
}
|
||||
return identitydomains.Operations{
|
||||
Op: identitydomains.OperationsOpAdd,
|
||||
Path: common.String("emails"),
|
||||
Value: &value,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
|
||||
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ic.DeleteUser(ctx, identity.DeleteUserRequest{UserId: &userID}); err != nil {
|
||||
return fmt.Errorf("delete user %s: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
|
||||
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
|
||||
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := ic.CreateOrResetUIPassword(ctx, identity.CreateOrResetUIPasswordRequest{UserId: &userID})
|
||||
if err == nil {
|
||||
return deref(resp.Password), nil
|
||||
}
|
||||
if !isClassicUnsupported(err) {
|
||||
return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
|
||||
}
|
||||
return c.resetPasswordViaDomain(ctx, cred, homeRegion, userID)
|
||||
}
|
||||
|
||||
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
|
||||
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scimID, err := scimUserID(ctx, dc, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bypass := true
|
||||
_, err = dc.PutUserPasswordChanger(ctx, identitydomains.PutUserPasswordChangerRequest{
|
||||
UserPasswordChangerId: &scimID,
|
||||
UserPasswordChanger: identitydomains.UserPasswordChanger{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:UserPasswordChanger"},
|
||||
Password: &password,
|
||||
BypassNotification: &bypass,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// 身份域通常不允许凭据本人经管理通道改自己的密码
|
||||
if userID == cred.UserOCID {
|
||||
return "", fmt.Errorf("change password via identity domain(当前签名用户请在 OCI 控制台修改密码): %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("change password via identity domain: %w", err)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
||||
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
|
||||
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := deleteClassicTotpDevices(ctx, ic, userID)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if err := c.removeDomainFactors(ctx, cred, homeRegion, userID); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, userID string) (int, error) {
|
||||
resp, err := ic.ListMfaTotpDevices(ctx, identity.ListMfaTotpDevicesRequest{UserId: &userID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list mfa totp devices: %w", err)
|
||||
}
|
||||
deleted := 0
|
||||
for _, item := range resp.Items {
|
||||
_, err := ic.DeleteMfaTotpDevice(ctx, identity.DeleteMfaTotpDeviceRequest{
|
||||
UserId: &userID,
|
||||
MfaTotpDeviceId: item.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("delete mfa totp device: %w", err)
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
||||
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
||||
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scimID, err := scimUserID(ctx, dc, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_, err = dc.CreateAuthenticationFactorsRemover(ctx, identitydomains.CreateAuthenticationFactorsRemoverRequest{
|
||||
AuthenticationFactorsRemover: identitydomains.AuthenticationFactorsRemover{
|
||||
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:AuthenticationFactorsRemover"},
|
||||
User: &identitydomains.AuthenticationFactorsRemoverUser{Value: &scimID},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove authentication factors: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTenantUserApiKeys 实现 Client:删除用户的 API Key。
|
||||
// 默认跳过当前配置正在使用的指纹以免面板失联,includeCurrent 为 true 时一并删除。
|
||||
func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list api keys: %w", err)
|
||||
}
|
||||
deleted := 0
|
||||
for _, key := range resp.Items {
|
||||
fingerprint := deref(key.Fingerprint)
|
||||
if !includeCurrent && userID == cred.UserOCID && fingerprint == cred.Fingerprint {
|
||||
continue
|
||||
}
|
||||
_, err := ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("delete api key %s: %w", fingerprint, err)
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
||||
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
||||
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("user %s not found in identity domain", classicOCID)
|
||||
}
|
||||
return *u.Id, nil
|
||||
}
|
||||
|
||||
// domainUserByOcid 用经典 OCID 查域用户;不存在时 found 为 false 而非报错。
|
||||
// extraAttrs 用于按需追加 groups、appRoles 等重量级属性。
|
||||
func domainUserByOcid(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string, extraAttrs ...string) (identitydomains.User, bool, error) {
|
||||
filter := fmt.Sprintf("ocid eq %q", classicOCID)
|
||||
attrs := strings.Join(append([]string{"id,ocid,userName,description,name,emails"}, extraAttrs...), ",")
|
||||
count := 1
|
||||
resp, err := dc.ListUsers(ctx, identitydomains.ListUsersRequest{
|
||||
Filter: &filter,
|
||||
Attributes: &attrs,
|
||||
Count: &count,
|
||||
})
|
||||
if err != nil {
|
||||
return identitydomains.User{}, false, fmt.Errorf("find domain user by ocid: %w", err)
|
||||
}
|
||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||
return identitydomains.User{}, false, nil
|
||||
}
|
||||
return resp.Resources[0], true, nil
|
||||
}
|
||||
|
||||
// isClassicUnsupported 判断经典 IAM API 对该用户不适用(仅存在于 Identity Domain)。
|
||||
func isClassicUnsupported(err error) bool {
|
||||
var svcErr common.ServiceError
|
||||
if errors.As(err, &svcErr) {
|
||||
return svcErr.GetHTTPStatusCode() == 404 || svcErr.GetHTTPStatusCode() == 400
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// passwordCharsets 保证生成的密码覆盖四类字符,满足常见复杂度策略。
|
||||
var passwordCharsets = []string{
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZ",
|
||||
"abcdefghijkmnpqrstuvwxyz",
|
||||
"23456789",
|
||||
"#-_+!",
|
||||
}
|
||||
|
||||
// generatePassword 生成 16 位随机密码,每类字符至少出现一次。
|
||||
func generatePassword() (string, error) {
|
||||
const length = 16
|
||||
all := strings.Join(passwordCharsets, "")
|
||||
out := make([]byte, 0, length)
|
||||
for _, set := range passwordCharsets {
|
||||
ch, err := randomChar(set)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
for len(out) < length {
|
||||
ch, err := randomChar(all)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func randomChar(set string) (byte, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(set))))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
return set[n.Int64()], nil
|
||||
}
|
||||
|
||||
func toTenantUser(u identity.User, currentUserOCID string) TenantUser {
|
||||
return TenantUser{
|
||||
ID: deref(u.Id),
|
||||
Name: deref(u.Name),
|
||||
Description: deref(u.Description),
|
||||
Email: deref(u.Email),
|
||||
EmailVerified: u.EmailVerified != nil && *u.EmailVerified,
|
||||
MfaActivated: u.IsMfaActivated != nil && *u.IsMfaActivated,
|
||||
LifecycleState: string(u.LifecycleState),
|
||||
IsCurrentUser: deref(u.Id) == currentUserOCID,
|
||||
TimeCreated: sdkTime(u.TimeCreated),
|
||||
LastLoginTime: sdkTime(u.LastSuccessfulLoginTime),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
func TestGeneratePassword(t *testing.T) {
|
||||
for i := 0; i < 20; i++ {
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
t.Fatalf("generatePassword: %v", err)
|
||||
}
|
||||
if len(password) != 16 {
|
||||
t.Fatalf("len = %d, want 16", len(password))
|
||||
}
|
||||
for _, set := range passwordCharsets {
|
||||
if !strings.ContainsAny(password, set) {
|
||||
t.Errorf("password %q missing charset %q", password, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTenantUserDetail(t *testing.T) {
|
||||
adminRoles := &identitydomains.ExtensionUserUser{
|
||||
AppRoles: []identitydomains.UserExtAppRoles{
|
||||
{Value: common.String("r1"), Display: common.String("User Administrator")},
|
||||
{Value: common.String("r2"), Display: common.String(domainAdminRoleName)},
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
u identitydomains.User
|
||||
want TenantUserDetail
|
||||
}{
|
||||
{
|
||||
name: "姓名与双管理员状态齐全",
|
||||
u: identitydomains.User{
|
||||
Name: &identitydomains.UserName{
|
||||
GivenName: common.String("De"),
|
||||
FamilyName: common.String("Wang"),
|
||||
},
|
||||
Groups: []identitydomains.UserGroups{
|
||||
{Value: common.String("g1"), Display: common.String("Readers")},
|
||||
{Value: common.String("g2"), Display: common.String(administratorsGroupName)},
|
||||
},
|
||||
UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles,
|
||||
},
|
||||
want: TenantUserDetail{InDomain: true, GivenName: "De", FamilyName: "Wang", IsDomainAdmin: true, InAdminGroup: true},
|
||||
},
|
||||
{
|
||||
name: "普通成员无管理员标记",
|
||||
u: identitydomains.User{
|
||||
Name: &identitydomains.UserName{GivenName: common.String("A"), FamilyName: common.String("B")},
|
||||
Groups: []identitydomains.UserGroups{{Value: common.String("g1"), Display: common.String("Readers")}},
|
||||
},
|
||||
want: TenantUserDetail{InDomain: true, GivenName: "A", FamilyName: "B"},
|
||||
},
|
||||
{
|
||||
name: "空档案仅置 InDomain",
|
||||
u: identitydomains.User{},
|
||||
want: TenantUserDetail{InDomain: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := toTenantUserDetail(tt.u); got != tt.want {
|
||||
t.Errorf("toTenantUserDetail() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsageDetails(t *testing.T) {
|
||||
start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
q CostQuery
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "daily cost", q: CostQuery{Granularity: "DAILY", QueryType: "COST", GroupBy: "service"}},
|
||||
{name: "小写粒度可被归一化", q: CostQuery{Granularity: "monthly", QueryType: "usage", GroupBy: "skuName"}},
|
||||
{name: "非法粒度", q: CostQuery{Granularity: "HOURLY2", QueryType: "COST"}, wantErr: true},
|
||||
{name: "非法查询类型", q: CostQuery{Granularity: "DAILY", QueryType: "MONEY"}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.q.StartTime, tt.q.EndTime = start, end
|
||||
details, err := buildUsageDetails("ocid1.tenancy.oc1..test", tt.q)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("buildUsageDetails() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if details.TenantId == nil || *details.TenantId != "ocid1.tenancy.oc1..test" {
|
||||
t.Error("tenantId not set")
|
||||
}
|
||||
if len(details.GroupBy) != 1 || details.GroupBy[0] != tt.q.GroupBy {
|
||||
t.Errorf("groupBy = %v, want [%s]", details.GroupBy, tt.q.GroupBy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/monitoring"
|
||||
)
|
||||
|
||||
// trafficNamespace 是 VNIC 流量指标所在的 Monitoring 命名空间;
|
||||
// 指标由 OCI 自动上报,原始点 1 分钟一个,这里按天聚合。
|
||||
const (
|
||||
trafficNamespace = "oci_vcn"
|
||||
trafficResolution = "1440m"
|
||||
metricVnicInbound = "VnicFromNetworkBytes" // 实例收到的字节
|
||||
metricVnicOutbund = "VnicToNetworkBytes" // 实例发出的字节
|
||||
)
|
||||
|
||||
// TrafficQuery 是一次实例流量统计的条件。
|
||||
type TrafficQuery struct {
|
||||
Region string
|
||||
InstanceID string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
}
|
||||
|
||||
// TrafficPoint 是一个聚合窗口(天)的流量字节数。
|
||||
type TrafficPoint struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Bytes float64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// VnicTraffic 是单块 VNIC 的进出流量。
|
||||
type VnicTraffic struct {
|
||||
VnicID string `json:"vnicId"`
|
||||
InboundBytes float64 `json:"inboundBytes"`
|
||||
OutboundBytes float64 `json:"outboundBytes"`
|
||||
Inbound []TrafficPoint `json:"inbound"`
|
||||
Outbound []TrafficPoint `json:"outbound"`
|
||||
}
|
||||
|
||||
// InstanceTraffic 是实例全部 VNIC 的流量汇总。
|
||||
type InstanceTraffic struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
InboundBytes float64 `json:"inboundBytes"`
|
||||
OutboundBytes float64 `json:"outboundBytes"`
|
||||
Vnics []VnicTraffic `json:"vnics"`
|
||||
}
|
||||
|
||||
func (c *RealClient) monitoringClient(cred Credentials, region string) (monitoring.MonitoringClient, error) {
|
||||
mc, err := monitoring.NewMonitoringClientWithConfigurationProvider(provider(cred))
|
||||
if err != nil {
|
||||
return mc, fmt.Errorf("new monitoring client: %w", err)
|
||||
}
|
||||
applyProxy(&mc.BaseClient, cred)
|
||||
if region != "" {
|
||||
mc.SetRegion(normalizeRegion(region))
|
||||
}
|
||||
return mc, nil
|
||||
}
|
||||
|
||||
// SummarizeInstanceTraffic 实现 Client:逐 VNIC 查询进出流量并汇总。
|
||||
func (c *RealClient) SummarizeInstanceTraffic(ctx context.Context, cred Credentials, q TrafficQuery) (InstanceTraffic, error) {
|
||||
mc, err := c.monitoringClient(cred, q.Region)
|
||||
if err != nil {
|
||||
return InstanceTraffic{}, err
|
||||
}
|
||||
// VNIC 指标上报在实例所在 compartment,查询须用同一 compartment 才有数据
|
||||
vnics, compartmentID, err := c.instanceVnics(ctx, cred, q.Region, q.InstanceID)
|
||||
if err != nil {
|
||||
return InstanceTraffic{}, err
|
||||
}
|
||||
result := InstanceTraffic{InstanceID: q.InstanceID, Vnics: make([]VnicTraffic, 0, len(vnics))}
|
||||
for _, vnic := range vnics {
|
||||
vt := VnicTraffic{VnicID: deref(vnic.Id)}
|
||||
if vt.Inbound, err = summarizeVnicMetric(ctx, mc, compartmentID, vt.VnicID, metricVnicInbound, q); err != nil {
|
||||
return InstanceTraffic{}, err
|
||||
}
|
||||
if vt.Outbound, err = summarizeVnicMetric(ctx, mc, compartmentID, vt.VnicID, metricVnicOutbund, q); err != nil {
|
||||
return InstanceTraffic{}, err
|
||||
}
|
||||
vt.Inbound, vt.Outbound = orEmpty(vt.Inbound), orEmpty(vt.Outbound)
|
||||
vt.InboundBytes, vt.OutboundBytes = sumTraffic(vt.Inbound), sumTraffic(vt.Outbound)
|
||||
result.InboundBytes += vt.InboundBytes
|
||||
result.OutboundBytes += vt.OutboundBytes
|
||||
result.Vnics = append(result.Vnics, vt)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// summarizeVnicMetric 按天聚合查询单块 VNIC 的一个流量指标;
|
||||
// compartmentID 须是实例所在 compartment,指标数据挂在该 compartment 下。
|
||||
func summarizeVnicMetric(ctx context.Context, mc monitoring.MonitoringClient, compartmentID, vnicID, metric string, q TrafficQuery) ([]TrafficPoint, error) {
|
||||
query := fmt.Sprintf("%s[%s]{resourceId = %q}.sum()", metric, trafficResolution, vnicID)
|
||||
resolution := trafficResolution
|
||||
resp, err := mc.SummarizeMetricsData(ctx, monitoring.SummarizeMetricsDataRequest{
|
||||
CompartmentId: &compartmentID,
|
||||
SummarizeMetricsDataDetails: monitoring.SummarizeMetricsDataDetails{
|
||||
Namespace: common.String(trafficNamespace),
|
||||
Query: &query,
|
||||
StartTime: &common.SDKTime{Time: q.StartTime},
|
||||
EndTime: &common.SDKTime{Time: q.EndTime},
|
||||
Resolution: &resolution,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("summarize %s of vnic %s: %w", metric, vnicID, err)
|
||||
}
|
||||
var points []TrafficPoint
|
||||
for _, item := range resp.Items {
|
||||
for _, dp := range item.AggregatedDatapoints {
|
||||
if dp.Timestamp == nil || dp.Value == nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, TrafficPoint{Timestamp: dp.Timestamp.Time, Bytes: *dp.Value})
|
||||
}
|
||||
}
|
||||
return orEmpty(points), nil
|
||||
}
|
||||
|
||||
func sumTraffic(points []TrafficPoint) float64 {
|
||||
var total float64
|
||||
for _, p := range points {
|
||||
total += p.Bytes
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// 本文件实现 VCN 的级联删除:OCI 要求 VCN 删除前先清空其子网、网关、
|
||||
// 非默认路由表 / 安全列表 / DHCP 选项,顺序参照控制台删除向导。
|
||||
// 子网仍被 VNIC 占用(实例未终止)时 OCI 返回 409 IncorrectState,
|
||||
// 该错误经 api 层提炼后提示用户先终止实例,不在此做实例级清理。
|
||||
|
||||
// deleteVcnCascade 依次清理关联资源后删除 VCN。
|
||||
func deleteVcnCascade(ctx context.Context, vn core.VirtualNetworkClient, vcnID string) error {
|
||||
vcnResp, err := vn.GetVcn(ctx, core.GetVcnRequest{VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get vcn %s: %w", vcnID, err)
|
||||
}
|
||||
comp := deref(vcnResp.CompartmentId)
|
||||
if err := deleteVcnSubnets(ctx, vn, comp, vcnID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearVcnRouteTables(ctx, vn, comp, vcnID, deref(vcnResp.DefaultRouteTableId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteVcnGateways(ctx, vn, comp, vcnID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteVcnSecondaries(ctx, vn, comp, vcnID, vcnResp.Vcn); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := vn.DeleteVcn(ctx, core.DeleteVcnRequest{VcnId: &vcnID}); err != nil {
|
||||
return fmt.Errorf("delete vcn %s: %w", vcnID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteVcnSubnets 删除 VCN 下全部子网并等待终止完成。
|
||||
func deleteVcnSubnets(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
resp, err := vn.ListSubnets(ctx, core.ListSubnetsRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list subnets: %w", err)
|
||||
}
|
||||
for _, s := range resp.Items {
|
||||
if s.LifecycleState == core.SubnetLifecycleStateTerminated ||
|
||||
s.LifecycleState == core.SubnetLifecycleStateTerminating {
|
||||
continue
|
||||
}
|
||||
if _, err := vn.DeleteSubnet(ctx, core.DeleteSubnetRequest{SubnetId: s.Id}); err != nil {
|
||||
return fmt.Errorf("delete subnet %s: %w", deref(s.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return waitSubnetsGone(ctx, vn, compartmentID, vcnID)
|
||||
}
|
||||
|
||||
// waitSubnetsGone 轮询等待 VCN 下子网全部终止,超时 60 秒。
|
||||
func waitSubnetsGone(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := vn.ListSubnets(ctx, core.ListSubnetsRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait subnets terminated: %w", err)
|
||||
}
|
||||
alive := 0
|
||||
for _, s := range resp.Items {
|
||||
if s.LifecycleState != core.SubnetLifecycleStateTerminated {
|
||||
alive++
|
||||
}
|
||||
}
|
||||
if alive == 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("wait subnets terminated: timeout after 60s")
|
||||
}
|
||||
|
||||
// clearVcnRouteTables 清空所有路由表规则(解除对网关的引用)并删除非默认路由表。
|
||||
func clearVcnRouteTables(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID, defaultRtID string) error {
|
||||
resp, err := vn.ListRouteTables(ctx, core.ListRouteTablesRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list route tables: %w", err)
|
||||
}
|
||||
for _, rt := range resp.Items {
|
||||
if len(rt.RouteRules) > 0 {
|
||||
if _, err := vn.UpdateRouteTable(ctx, core.UpdateRouteTableRequest{
|
||||
RtId: rt.Id,
|
||||
UpdateRouteTableDetails: core.UpdateRouteTableDetails{RouteRules: []core.RouteRule{}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("clear route table %s: %w", deref(rt.DisplayName), err)
|
||||
}
|
||||
}
|
||||
if deref(rt.Id) != defaultRtID {
|
||||
if _, err := vn.DeleteRouteTable(ctx, core.DeleteRouteTableRequest{RtId: rt.Id}); err != nil {
|
||||
return fmt.Errorf("delete route table %s: %w", deref(rt.DisplayName), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteVcnGateways 删除 VCN 下的互联网 / NAT / 服务网关。
|
||||
func deleteVcnGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
if err := deleteInternetGateways(ctx, vn, compartmentID, vcnID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteNatGateways(ctx, vn, compartmentID, vcnID); err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteServiceGateways(ctx, vn, compartmentID, vcnID)
|
||||
}
|
||||
|
||||
func deleteInternetGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
resp, err := vn.ListInternetGateways(ctx, core.ListInternetGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list internet gateways: %w", err)
|
||||
}
|
||||
for _, g := range resp.Items {
|
||||
if _, err := vn.DeleteInternetGateway(ctx, core.DeleteInternetGatewayRequest{IgId: g.Id}); err != nil {
|
||||
return fmt.Errorf("delete internet gateway %s: %w", deref(g.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteNatGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
resp, err := vn.ListNatGateways(ctx, core.ListNatGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list nat gateways: %w", err)
|
||||
}
|
||||
for _, g := range resp.Items {
|
||||
if _, err := vn.DeleteNatGateway(ctx, core.DeleteNatGatewayRequest{NatGatewayId: g.Id}); err != nil {
|
||||
return fmt.Errorf("delete nat gateway %s: %w", deref(g.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteServiceGateways(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string) error {
|
||||
resp, err := vn.ListServiceGateways(ctx, core.ListServiceGatewaysRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list service gateways: %w", err)
|
||||
}
|
||||
for _, g := range resp.Items {
|
||||
if _, err := vn.DeleteServiceGateway(ctx, core.DeleteServiceGatewayRequest{ServiceGatewayId: g.Id}); err != nil {
|
||||
return fmt.Errorf("delete service gateway %s: %w", deref(g.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteVcnSecondaries 删除非默认安全列表与非默认 DHCP 选项。
|
||||
func deleteVcnSecondaries(ctx context.Context, vn core.VirtualNetworkClient, compartmentID, vcnID string, vcn core.Vcn) error {
|
||||
slResp, err := vn.ListSecurityLists(ctx, core.ListSecurityListsRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list security lists: %w", err)
|
||||
}
|
||||
for _, sl := range slResp.Items {
|
||||
if deref(sl.Id) == deref(vcn.DefaultSecurityListId) {
|
||||
continue
|
||||
}
|
||||
if _, err := vn.DeleteSecurityList(ctx, core.DeleteSecurityListRequest{SecurityListId: sl.Id}); err != nil {
|
||||
return fmt.Errorf("delete security list %s: %w", deref(sl.DisplayName), err)
|
||||
}
|
||||
}
|
||||
dhResp, err := vn.ListDhcpOptions(ctx, core.ListDhcpOptionsRequest{CompartmentId: &compartmentID, VcnId: &vcnID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list dhcp options: %w", err)
|
||||
}
|
||||
for _, d := range dhResp.Items {
|
||||
if deref(d.Id) == deref(vcn.DefaultDhcpOptionsId) {
|
||||
continue
|
||||
}
|
||||
if _, err := vn.DeleteDhcpOptions(ctx, core.DeleteDhcpOptionsRequest{DhcpId: d.Id}); err != nil {
|
||||
return fmt.Errorf("delete dhcp options %s: %w", deref(d.DisplayName), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/core"
|
||||
)
|
||||
|
||||
// Vnic 是实例已附加的虚拟网卡(附加关系与地址信息的合并视图)。
|
||||
type Vnic struct {
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
VnicID string `json:"vnicId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
PrivateIP string `json:"privateIp"`
|
||||
PublicIP string `json:"publicIp"`
|
||||
Ipv6Addresses []string `json:"ipv6Addresses"`
|
||||
SubnetID string `json:"subnetId"`
|
||||
SubnetName string `json:"subnetName,omitempty"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
TimeCreated *time.Time `json:"timeCreated"`
|
||||
}
|
||||
|
||||
// AttachVnicInput 是附加新 VNIC 的输入;PrivateIP 留空自动分配。
|
||||
type AttachVnicInput struct {
|
||||
SubnetID string `json:"subnetId" binding:"required"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PrivateIP string `json:"privateIp"`
|
||||
AssignPublicIP bool `json:"assignPublicIp"`
|
||||
// AssignIpv6 为 true 时同时自动分配一个 IPv6(要求子网已启用 IPv6)。
|
||||
AssignIpv6 bool `json:"assignIpv6"`
|
||||
}
|
||||
|
||||
// ListInstanceVnics 实现 Client:列出实例全部 VNIC(含附加中/分离中的关系)。
|
||||
func (c *RealClient) ListInstanceVnics(ctx context.Context, cred Credentials, region, instanceID string) ([]Vnic, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instResp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||||
}
|
||||
// 附加关系在实例所在 compartment,传租户根会漏查
|
||||
attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{
|
||||
CompartmentId: hostCompartment(cred, deref(instResp.CompartmentId)),
|
||||
InstanceId: &instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vnic attachments: %w", err)
|
||||
}
|
||||
vn, err := c.vcnClient(cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Vnic, 0, len(attResp.Items))
|
||||
subnetNames := map[string]string{}
|
||||
for _, att := range attResp.Items {
|
||||
out = append(out, buildVnic(ctx, vn, att, subnetNames))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildVnic 合并附加关系与 VNIC 详情;VNIC 未就绪(附加中)时只有关系字段。
|
||||
func buildVnic(ctx context.Context, vn core.VirtualNetworkClient, att core.VnicAttachment, subnetNames map[string]string) Vnic {
|
||||
v := Vnic{
|
||||
AttachmentID: deref(att.Id),
|
||||
VnicID: deref(att.VnicId),
|
||||
DisplayName: deref(att.DisplayName),
|
||||
SubnetID: deref(att.SubnetId),
|
||||
LifecycleState: string(att.LifecycleState),
|
||||
TimeCreated: sdkTime(att.TimeCreated),
|
||||
}
|
||||
if att.VnicId != nil {
|
||||
if resp, err := vn.GetVnic(ctx, core.GetVnicRequest{VnicId: att.VnicId}); err == nil {
|
||||
v.DisplayName = deref(resp.DisplayName)
|
||||
v.PrivateIP = deref(resp.PrivateIp)
|
||||
v.PublicIP = deref(resp.PublicIp)
|
||||
v.Ipv6Addresses = resp.Ipv6Addresses
|
||||
if resp.IsPrimary != nil {
|
||||
v.IsPrimary = *resp.IsPrimary
|
||||
}
|
||||
if deref(resp.SubnetId) != "" {
|
||||
v.SubnetID = deref(resp.SubnetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
v.SubnetName = subnetNameOf(ctx, vn, v.SubnetID, subnetNames)
|
||||
return v
|
||||
}
|
||||
|
||||
// subnetNameOf 查子网显示名,map 去重;失败留空不影响列表。
|
||||
func subnetNameOf(ctx context.Context, vn core.VirtualNetworkClient, id string, cache map[string]string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if name, ok := cache[id]; ok {
|
||||
return name
|
||||
}
|
||||
name := ""
|
||||
if resp, err := vn.GetSubnet(ctx, core.GetSubnetRequest{SubnetId: &id}); err == nil {
|
||||
name = deref(resp.DisplayName)
|
||||
}
|
||||
cache[id] = name
|
||||
return name
|
||||
}
|
||||
|
||||
// AttachVnic 实现 Client:为实例附加次要 VNIC;实例须运行中且 shape 有空余 VNIC 配额。
|
||||
func (c *RealClient) AttachVnic(ctx context.Context, cred Credentials, region, instanceID string, in AttachVnicInput) (Vnic, error) {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return Vnic{}, err
|
||||
}
|
||||
details := &core.CreateVnicDetails{
|
||||
SubnetId: &in.SubnetID,
|
||||
AssignPublicIp: &in.AssignPublicIP,
|
||||
}
|
||||
if in.AssignIpv6 {
|
||||
details.AssignIpv6Ip = &in.AssignIpv6
|
||||
}
|
||||
if in.DisplayName != "" {
|
||||
details.DisplayName = &in.DisplayName
|
||||
}
|
||||
if in.PrivateIP != "" {
|
||||
details.PrivateIp = &in.PrivateIP
|
||||
}
|
||||
resp, err := cc.AttachVnic(ctx, core.AttachVnicRequest{
|
||||
AttachVnicDetails: core.AttachVnicDetails{InstanceId: &instanceID, CreateVnicDetails: details},
|
||||
})
|
||||
if err != nil {
|
||||
return Vnic{}, fmt.Errorf("attach vnic: %w", err)
|
||||
}
|
||||
att := resp.VnicAttachment
|
||||
return Vnic{
|
||||
AttachmentID: deref(att.Id),
|
||||
VnicID: deref(att.VnicId),
|
||||
DisplayName: deref(att.DisplayName),
|
||||
SubnetID: deref(att.SubnetId),
|
||||
LifecycleState: string(att.LifecycleState),
|
||||
TimeCreated: sdkTime(att.TimeCreated),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DetachVnic 实现 Client:分离次要 VNIC(主 VNIC 由 OCI 拒绝)。
|
||||
func (c *RealClient) DetachVnic(ctx context.Context, cred Credentials, region, attachmentID string) error {
|
||||
cc, err := c.computeClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cc.DetachVnic(ctx, core.DetachVnicRequest{VnicAttachmentId: &attachmentID}); err != nil {
|
||||
return fmt.Errorf("detach vnic %s: %w", attachmentID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user