初始提交: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
@@ -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)
}