Files
oci-portal/internal/oci/instance.go
T

453 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}