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) }