初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+43
View File
@@ -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
}
+38
View File
@@ -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)
}
})
}
}
+328
View File
@@ -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 实现 ClientavailabilityDomain 为空时列出全区域块卷。
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
}
+179
View File
@@ -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)
}
})
}
+144
View File
@@ -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 比较两个 DTOEventTime 按值比较,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)
}
})
}
}
+292
View File
@@ -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 实现 ClientavailabilityDomain 为空时列出全区域引导卷。
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
}
+285
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+288
View File
@@ -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)
// FederationSAML 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 → shapeCacheEntryshape 清单缓存
}
// 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
}
+64
View File
@@ -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
}
+111
View File
@@ -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),
}
}
+108
View File
@@ -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
}
+98
View File
@@ -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 标准 configini)文本的 [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
}
}
+79
View File
@@ -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)
}
})
}
}
+304
View File
@@ -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-500replace 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: &current.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
}
+78
View File
@@ -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 匹配消息中的完整 OCIDunique 段 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
}
+111
View File
@@ -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)
}
})
}
}
+438
View File
@@ -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) 外链 URLIDCS 拒绝 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
}
+276
View File
@@ -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
}
+266
View File
@@ -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), &params) != nil || params == nil {
params = map[string]any{}
}
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: &params}
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: &params}
}
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},
}
}
+367
View File
@@ -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, &params) == nil {
fd.Parameters = &params
}
}
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
}
+40
View File
@@ -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("正常模型应入池")
}
}
+193
View File
@@ -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)
}
}
+100
View File
@@ -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
}
+452
View File
@@ -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-initbase64 编码;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 实现 ClientavailabilityDomain 为空时默认使用第一个
// 可用域(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 实现 ClientpreserveBootVolume 为 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
}
+181
View File
@@ -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)
}
}
+314
View File
@@ -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"}
}
+121
View File
@@ -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)
}
}
})
}
}
+392
View File
@@ -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
}
+37
View File
@@ -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)
}
}
+733
View File
@@ -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 实现 ClientvcnID 为空时列出全部子网。
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 实现 ClientvcnID 为空时列出全部安全列表。
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
}
+75
View File
@@ -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}
}
+132
View File
@@ -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)
}
+229
View File
@@ -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)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package oci
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
)
// regionsJSON 是本地维护的 OCI 区域表。官方 APIListRegions、
// 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, &regions); 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
}
+46
View File
@@ -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" }
]
+47
View File
@@ -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)
}
})
}
}
+59
View File
@@ -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
}
+62
View File
@@ -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)
}
})
}
}
+115
View File
@@ -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 解引用 *float32nil 时返回零值。
func deref32(p *float32) float32 {
if p == nil {
return 0
}
return *p
}
+273
View File
@@ -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})
}
+159
View File
@@ -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
}
+361
View File
@@ -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: &regionKey,
},
})
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
}
+65
View File
@@ -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])
}
}
})
}
}
+736
View File
@@ -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),
}
}
+110
View File
@@ -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)
}
})
}
}
+129
View File
@@ -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
}
+185
View File
@@ -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
}
+156
View File
@@ -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
}