380 lines
14 KiB
Go
380 lines
14 KiB
Go
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)
|
||
}
|