468 lines
16 KiB
Go
468 lines
16 KiB
Go
package oci
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/oracle/oci-go-sdk/v65/core"
|
||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||
)
|
||
|
||
// Instance 是一个计算实例;IP 字段来自 VNIC 补全查询。
|
||
type Instance struct {
|
||
ID string `json:"id"`
|
||
DisplayName string `json:"displayName"`
|
||
LifecycleState string `json:"lifecycleState"`
|
||
Shape string `json:"shape"`
|
||
Ocpus float32 `json:"ocpus"`
|
||
MemoryInGBs float32 `json:"memoryInGBs"`
|
||
AvailabilityDomain string `json:"availabilityDomain"`
|
||
Region string `json:"region"`
|
||
CompartmentID string `json:"compartmentId"`
|
||
ImageID string `json:"imageId"`
|
||
SubnetID string `json:"subnetId,omitempty"`
|
||
PrivateIP string `json:"privateIp,omitempty"`
|
||
PublicIP string `json:"publicIp,omitempty"`
|
||
Ipv6Addresses []string `json:"ipv6Addresses,omitempty"`
|
||
FreeformTags map[string]string `json:"freeformTags,omitempty"`
|
||
DefinedTags map[string]map[string]any `json:"definedTags,omitempty"`
|
||
TimeCreated *time.Time `json:"timeCreated"`
|
||
}
|
||
|
||
// CreateInstanceInput 是创建实例的输入:镜像与引导卷二选一作为启动源,
|
||
// Flex shape 必须提供 ocpus 与 memoryInGBs。
|
||
type CreateInstanceInput struct {
|
||
Region string
|
||
CompartmentID string // 目标 compartment,空为租户根
|
||
AvailabilityDomain string
|
||
DisplayName string
|
||
Shape string
|
||
Ocpus float32
|
||
MemoryInGBs float32
|
||
ImageID string
|
||
BootVolumeID string
|
||
BootVolumeSizeGBs int64
|
||
BootVolumeVpusPerGB int64 // 引导卷性能,10 均衡 / 20 高性能,仅镜像启动源生效
|
||
SubnetID string // 为空时自动创建 VCN 与子网
|
||
AssignPublicIP bool
|
||
AssignIpv6 bool
|
||
SSHPublicKey string // 与 RootPassword 互斥
|
||
RootPassword string // 与 SSHPublicKey 互斥,非空时生成开启 root 密码登录的 cloud-init
|
||
GenerateRootPassword bool // 由服务端生成随机 root 密码并写入 FreeformTags["RootPassword"]
|
||
UserData string // 显式 cloud-init,base64 编码;RootPassword 非空时被其生成脚本覆盖
|
||
FreeformTags map[string]string // 实例自由标签
|
||
}
|
||
|
||
// UpdateInstanceInput 是更新实例的输入;零值字段不更新。
|
||
type UpdateInstanceInput struct {
|
||
Region string
|
||
DisplayName string
|
||
Shape string // 更换 shape;运行中实例 OCI 会自动重启,换 Flex 时须同时给 ocpus
|
||
Ocpus float32
|
||
MemoryInGBs float32
|
||
}
|
||
|
||
// instanceActions 是允许的电源操作集合。
|
||
var instanceActions = map[string]core.InstanceActionActionEnum{
|
||
"START": core.InstanceActionActionStart,
|
||
"STOP": core.InstanceActionActionStop,
|
||
"RESET": core.InstanceActionActionReset,
|
||
"SOFTSTOP": core.InstanceActionActionSoftstop,
|
||
"SOFTRESET": core.InstanceActionActionSoftreset,
|
||
}
|
||
|
||
func (c *RealClient) computeClient(cred Credentials, region string) (core.ComputeClient, error) {
|
||
cc, err := core.NewComputeClientWithConfigurationProvider(provider(cred))
|
||
if err != nil {
|
||
return cc, fmt.Errorf("new compute client: %w", err)
|
||
}
|
||
applyProxy(&cc.BaseClient, cred)
|
||
if region != "" {
|
||
cc.SetRegion(normalizeRegion(region))
|
||
}
|
||
return cc, nil
|
||
}
|
||
|
||
// ListAvailabilityDomains 实现 Client:列出区域内的可用域名称。
|
||
func (c *RealClient) ListAvailabilityDomains(ctx context.Context, cred Credentials, region string) ([]string, error) {
|
||
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("new identity client: %w", err)
|
||
}
|
||
applyProxy(&ic.BaseClient, cred)
|
||
if region != "" {
|
||
ic.SetRegion(normalizeRegion(region))
|
||
}
|
||
resp, err := ic.ListAvailabilityDomains(ctx, identity.ListAvailabilityDomainsRequest{
|
||
CompartmentId: &cred.TenancyOCID,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list availability domains: %w", err)
|
||
}
|
||
names := make([]string, 0, len(resp.Items))
|
||
for _, ad := range resp.Items {
|
||
names = append(names, deref(ad.Name))
|
||
}
|
||
return names, nil
|
||
}
|
||
|
||
// defaultAvailabilityDomain 返回区域内第一个可用域(即 AD-1)。
|
||
func (c *RealClient) defaultAvailabilityDomain(ctx context.Context, cred Credentials, region string) (string, error) {
|
||
ads, err := c.ListAvailabilityDomains(ctx, cred, region)
|
||
if err != nil {
|
||
return "", fmt.Errorf("resolve default availability domain: %w", err)
|
||
}
|
||
if len(ads) == 0 {
|
||
return "", fmt.Errorf("resolve default availability domain: region has no availability domain")
|
||
}
|
||
return ads[0], nil
|
||
}
|
||
|
||
// ListInstances 实现 Client:列出实例并补全网络 IP 信息。
|
||
func (c *RealClient) ListInstances(ctx context.Context, cred Credentials, region string) ([]Instance, error) {
|
||
cc, err := c.computeClient(cred, region)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var instances []Instance
|
||
var page *string
|
||
for {
|
||
resp, err := cc.ListInstances(ctx, core.ListInstancesRequest{
|
||
CompartmentId: cred.EffectiveCompartment(),
|
||
Page: page,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list instances: %w", err)
|
||
}
|
||
for _, item := range resp.Items {
|
||
// 已终止实例的 VNIC 已不存在,保留在列表中仅供展示
|
||
instances = append(instances, toInstance(item))
|
||
}
|
||
if resp.OpcNextPage == nil {
|
||
break
|
||
}
|
||
page = resp.OpcNextPage
|
||
}
|
||
c.attachInstanceIPs(ctx, cred, region, instances)
|
||
// 列表瘦身:标签体积大且列表页不消费(详情接口仍完整返回)
|
||
for i := range instances {
|
||
instances[i].FreeformTags = nil
|
||
instances[i].DefinedTags = nil
|
||
}
|
||
return orEmpty(instances), nil
|
||
}
|
||
|
||
// LaunchInstance 实现 Client;availabilityDomain 为空时默认使用第一个
|
||
// 可用域(ad-1),subnetId 为空时自动准备默认网络。
|
||
func (c *RealClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||
cc, err := c.computeClient(cred, in.Region)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
if in.AvailabilityDomain == "" {
|
||
ad, err := c.defaultAvailabilityDomain(ctx, cred, in.Region)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
in.AvailabilityDomain = ad
|
||
}
|
||
if in.SubnetID == "" {
|
||
subnetID, err := c.ensureDefaultSubnet(ctx, cred, in.Region)
|
||
if err != nil {
|
||
return Instance{}, fmt.Errorf("auto provision subnet: %w", err)
|
||
}
|
||
in.SubnetID = subnetID
|
||
}
|
||
details, err := buildLaunchDetails(*cred.EffectiveCompartment(), in)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
resp, err := cc.LaunchInstance(ctx, core.LaunchInstanceRequest{LaunchInstanceDetails: details})
|
||
if err != nil {
|
||
return Instance{}, fmt.Errorf("launch instance: %w", err)
|
||
}
|
||
return toInstance(resp.Instance), nil
|
||
}
|
||
|
||
func buildLaunchDetails(compartmentID string, in CreateInstanceInput) (core.LaunchInstanceDetails, error) {
|
||
details := core.LaunchInstanceDetails{
|
||
CompartmentId: &compartmentID,
|
||
AvailabilityDomain: &in.AvailabilityDomain,
|
||
Shape: &in.Shape,
|
||
DisplayName: &in.DisplayName,
|
||
SourceDetails: buildInstanceSource(in),
|
||
CreateVnicDetails: &core.CreateVnicDetails{
|
||
SubnetId: &in.SubnetID,
|
||
AssignPublicIp: &in.AssignPublicIP,
|
||
},
|
||
}
|
||
if details.SourceDetails == nil {
|
||
return details, fmt.Errorf("launch instance: imageId or bootVolumeId is required")
|
||
}
|
||
if in.AssignIpv6 {
|
||
details.CreateVnicDetails.AssignIpv6Ip = &in.AssignIpv6
|
||
}
|
||
if in.Ocpus > 0 {
|
||
details.ShapeConfig = &core.LaunchInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||
if in.MemoryInGBs > 0 {
|
||
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||
}
|
||
}
|
||
if len(in.FreeformTags) > 0 {
|
||
details.FreeformTags = in.FreeformTags
|
||
}
|
||
details.Metadata = buildInstanceMetadata(in)
|
||
return details, nil
|
||
}
|
||
|
||
func buildInstanceSource(in CreateInstanceInput) core.InstanceSourceDetails {
|
||
if in.BootVolumeID != "" {
|
||
return core.InstanceSourceViaBootVolumeDetails{BootVolumeId: &in.BootVolumeID}
|
||
}
|
||
if in.ImageID != "" {
|
||
src := core.InstanceSourceViaImageDetails{ImageId: &in.ImageID}
|
||
if in.BootVolumeSizeGBs > 0 {
|
||
src.BootVolumeSizeInGBs = &in.BootVolumeSizeGBs
|
||
}
|
||
if in.BootVolumeVpusPerGB > 0 {
|
||
src.BootVolumeVpusPerGB = &in.BootVolumeVpusPerGB
|
||
}
|
||
return src
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func buildInstanceMetadata(in CreateInstanceInput) map[string]string {
|
||
metadata := make(map[string]string, 2)
|
||
if in.SSHPublicKey != "" {
|
||
metadata["ssh_authorized_keys"] = in.SSHPublicKey
|
||
}
|
||
// RootPassword 生成的脚本优先作为 user_data,其次才是显式 UserData
|
||
if in.RootPassword != "" {
|
||
metadata["user_data"] = rootPasswordUserData(in.RootPassword)
|
||
} else if in.UserData != "" {
|
||
metadata["user_data"] = in.UserData
|
||
}
|
||
if len(metadata) == 0 {
|
||
return nil
|
||
}
|
||
return metadata
|
||
}
|
||
|
||
// rootPasswordUserData 生成开启 root 密码登录的 cloud-init 脚本并 base64 编码。
|
||
// 密码用单引号安全转义,避免 shell 注入。
|
||
func rootPasswordUserData(password string) string {
|
||
script := "#!/bin/bash\n" +
|
||
"echo root:" + shellSingleQuote(password) + " | chpasswd\n" +
|
||
"sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config\n" +
|
||
"sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication yes/g' /etc/ssh/sshd_config\n" +
|
||
"rm -f /etc/ssh/sshd_config.d/* /etc/ssh/ssh_config.d/*\n" +
|
||
"systemctl restart sshd\n"
|
||
return base64.StdEncoding.EncodeToString([]byte(script))
|
||
}
|
||
|
||
// shellSingleQuote 用单引号包裹字符串,内部单引号转义为 '\”。
|
||
func shellSingleQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||
}
|
||
|
||
// GetInstance 实现 Client:查询实例并补全网络 IP 信息。
|
||
func (c *RealClient) GetInstance(ctx context.Context, cred Credentials, region, instanceID string) (Instance, error) {
|
||
cc, err := c.computeClient(cred, region)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
resp, err := cc.GetInstance(ctx, core.GetInstanceRequest{InstanceId: &instanceID})
|
||
if err != nil {
|
||
return Instance{}, fmt.Errorf("get instance %s: %w", instanceID, err)
|
||
}
|
||
instances := []Instance{toInstance(resp.Instance)}
|
||
c.attachInstanceIPs(ctx, cred, region, instances)
|
||
return instances[0], nil
|
||
}
|
||
|
||
// UpdateInstance 实现 Client:改名或调整 Flex 规格。
|
||
func (c *RealClient) UpdateInstance(ctx context.Context, cred Credentials, instanceID string, in UpdateInstanceInput) (Instance, error) {
|
||
cc, err := c.computeClient(cred, in.Region)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
details := core.UpdateInstanceDetails{}
|
||
if in.DisplayName != "" {
|
||
details.DisplayName = &in.DisplayName
|
||
}
|
||
if in.Shape != "" {
|
||
details.Shape = &in.Shape
|
||
}
|
||
if in.Ocpus > 0 {
|
||
details.ShapeConfig = &core.UpdateInstanceShapeConfigDetails{Ocpus: &in.Ocpus}
|
||
if in.MemoryInGBs > 0 {
|
||
details.ShapeConfig.MemoryInGBs = &in.MemoryInGBs
|
||
}
|
||
}
|
||
resp, err := cc.UpdateInstance(ctx, core.UpdateInstanceRequest{
|
||
InstanceId: &instanceID,
|
||
UpdateInstanceDetails: details,
|
||
})
|
||
if err != nil {
|
||
return Instance{}, fmt.Errorf("update instance %s: %w", instanceID, err)
|
||
}
|
||
return toInstance(resp.Instance), nil
|
||
}
|
||
|
||
// TerminateInstance 实现 Client;preserveBootVolume 为 true 时保留引导卷。
|
||
func (c *RealClient) TerminateInstance(ctx context.Context, cred Credentials, region, instanceID string, preserveBootVolume bool) error {
|
||
cc, err := c.computeClient(cred, region)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = cc.TerminateInstance(ctx, core.TerminateInstanceRequest{
|
||
InstanceId: &instanceID,
|
||
PreserveBootVolume: &preserveBootVolume,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("terminate instance %s: %w", instanceID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// InstanceAction 实现 Client:执行电源操作。
|
||
func (c *RealClient) InstanceAction(ctx context.Context, cred Credentials, region, instanceID, action string) (Instance, error) {
|
||
actionEnum, ok := instanceActions[strings.ToUpper(strings.TrimSpace(action))]
|
||
if !ok {
|
||
return Instance{}, fmt.Errorf("instance action: unsupported action %q", action)
|
||
}
|
||
cc, err := c.computeClient(cred, region)
|
||
if err != nil {
|
||
return Instance{}, err
|
||
}
|
||
resp, err := cc.InstanceAction(ctx, core.InstanceActionRequest{
|
||
InstanceId: &instanceID,
|
||
Action: actionEnum,
|
||
})
|
||
if err != nil {
|
||
return Instance{}, fmt.Errorf("instance action %s on %s: %w", action, instanceID, err)
|
||
}
|
||
return toInstance(resp.Instance), nil
|
||
}
|
||
|
||
// hostCompartment 返回宿主资源自身所在 compartment 的请求指针:OCI 的
|
||
// 关联查询要求 compartmentId 与资源实际所在 compartment 一致,传租户根
|
||
// 会漏掉子 compartment 里的资源;id 缺失时保底用生效 compartment。
|
||
func hostCompartment(cred Credentials, id string) *string {
|
||
if id != "" {
|
||
return &id
|
||
}
|
||
return cred.EffectiveCompartment()
|
||
}
|
||
|
||
// attachInstanceIPs 批量补全实例的子网与 IP:按实例所在 compartment 分组
|
||
// 列出 VNIC 挂载,再逐 VNIC 查询地址;直接用实例自带的 compartment,
|
||
// 避免为定位挂载再逐台调用 GetInstance。
|
||
func (c *RealClient) attachInstanceIPs(ctx context.Context, cred Credentials, region string, instances []Instance) {
|
||
active := make(map[string]*Instance, len(instances))
|
||
compartments := make(map[string]struct{})
|
||
for i := range instances {
|
||
if instances[i].LifecycleState == string(core.InstanceLifecycleStateTerminated) {
|
||
continue
|
||
}
|
||
active[instances[i].ID] = &instances[i]
|
||
compartments[deref(hostCompartment(cred, instances[i].CompartmentID))] = struct{}{}
|
||
}
|
||
if len(active) == 0 {
|
||
return
|
||
}
|
||
cc, err := c.computeClient(cred, region)
|
||
if err != nil {
|
||
return
|
||
}
|
||
vn, err := c.vcnClient(cred, region)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for compartmentID := range compartments {
|
||
fillInstanceIPs(ctx, cc, vn, compartmentID, active)
|
||
}
|
||
}
|
||
|
||
// fillInstanceIPs 列出单个 compartment 内的 VNIC 挂载,为命中的实例补全地址。
|
||
// 逐 VNIC 的 GetVnic 是列表接口的主要耗时点,限并发 8 路并行拉取。
|
||
func fillInstanceIPs(ctx context.Context, cc core.ComputeClient, vn core.VirtualNetworkClient, compartmentID string, active map[string]*Instance) {
|
||
attResp, err := cc.ListVnicAttachments(ctx, core.ListVnicAttachmentsRequest{CompartmentId: &compartmentID})
|
||
if err != nil {
|
||
return
|
||
}
|
||
var (
|
||
wg sync.WaitGroup
|
||
mu sync.Mutex
|
||
sem = make(chan struct{}, 8)
|
||
primarySeen = make(map[*Instance]bool)
|
||
)
|
||
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()
|
||
applyVnicAddrs(inst, vnicResp.Vnic, primarySeen)
|
||
mu.Unlock()
|
||
}(att.VnicId, inst)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// applyVnicAddrs 将 VNIC 地址写入实例。多网卡实例以主网卡为准:
|
||
// 主网卡返回前先用先到的网卡兜底,主网卡到达后覆盖并锁定,避免并发
|
||
// 完成顺序决定展示结果。调用方需持有保护 inst 与 primarySeen 的锁。
|
||
func applyVnicAddrs(inst *Instance, v core.Vnic, primarySeen map[*Instance]bool) {
|
||
isPrimary := v.IsPrimary != nil && *v.IsPrimary
|
||
if !isPrimary && (primarySeen[inst] || inst.SubnetID != "") {
|
||
return
|
||
}
|
||
inst.SubnetID = deref(v.SubnetId)
|
||
inst.PrivateIP = deref(v.PrivateIp)
|
||
inst.PublicIP = deref(v.PublicIp)
|
||
inst.Ipv6Addresses = v.Ipv6Addresses
|
||
if isPrimary {
|
||
primarySeen[inst] = true
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|