Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api-runtime/common-runtime/VMPublicIPManager.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,32 @@ func AssignVMDefaultPublicIP(connectionName string, vmName string) (*cres.Public
return nil, err
}

waitUntilVMPublicIPVisible(connectionName, vmName)

return info, nil
}

// waitUntilVMPublicIPVisible polls GetVM until the VM's PublicIP is visible
// or a short timeout elapses. Some CSPs (observed on NHN Cloud) report the
// Associate call itself as successful before the association is visible
// through a VM lookup - callers that immediately GetVM right after
// AssignVMDefaultPublicIP returns would otherwise see an empty PublicIP
// despite the assign having actually succeeded. Best-effort: a timeout is
// silently ignored, since AssociatePublicIP already succeeded and the
// caller has the authoritative PublicIPInfo regardless.
func waitUntilVMPublicIPVisible(connectionName string, vmName string) {
waiter := NewWaiter(2, 30)
for {
vmInfo, err := GetVM(connectionName, VM, vmName)
if err == nil && vmInfo.PublicIP != "" {
return
}
if !waiter.Wait() {
return
}
}
}

// UnassignVMDefaultPublicIP disassociates and DELETES the PublicIP currently
// attached to the VM's default NIC. Fails if the VM has no default PublicIP.
//
Expand Down
5 changes: 5 additions & 0 deletions api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -17629,6 +17629,11 @@ const docTemplate = `{
"spider.ClusterTokenStatus": {
"type": "object",
"properties": {
"expirationTimestamp": {
"description": "ExpirationTimestamp is when the token stops being accepted, in RFC3339.\nIt is omitted when the CSP provides no expiry information, which the spec allows:\nclients then keep the credential until a 401 forces a refresh.",
"type": "string",
"example": "2026-09-02T05:15:00Z"
},
"token": {
"type": "string",
"example": "k8s-aws-v1.aHR0cHM6Ly9zdHMuYXA..."
Expand Down
5 changes: 5 additions & 0 deletions api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -17626,6 +17626,11 @@
"spider.ClusterTokenStatus": {
"type": "object",
"properties": {
"expirationTimestamp": {
"description": "ExpirationTimestamp is when the token stops being accepted, in RFC3339.\nIt is omitted when the CSP provides no expiry information, which the spec allows:\nclients then keep the credential until a 401 forces a refresh.",
"type": "string",
"example": "2026-09-02T05:15:00Z"
},
"token": {
"type": "string",
"example": "k8s-aws-v1.aHR0cHM6Ly9zdHMuYXA..."
Expand Down
7 changes: 7 additions & 0 deletions api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2755,6 +2755,13 @@ definitions:
type: object
spider.ClusterTokenStatus:
properties:
expirationTimestamp:
description: |-
ExpirationTimestamp is when the token stops being accepted, in RFC3339.
It is omitted when the CSP provides no expiry information, which the spec allows:
clients then keep the credential until a 401 forces a refresh.
example: "2026-09-02T05:15:00Z"
type: string
token:
example: k8s-aws-v1.aHR0cHM6Ly9zdHMuYXA...
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,20 @@ func (h *AwsPublicIPHandler) RemoveDefaultPublicIP(vmIID irs.IID) (bool, error)
return false, err
}

// CB-Spider fills an unset StsToken with the literal string "Not set"
// (see KeyValueListGetValue in CloudDriverHandler_common.go), not "" - the
// same normalization AwsDriver.go already applies before building the v1
// session. Skipping it here would sign the request with the literal
// string "Not set" as the STS session token, which AWS rejects with
// AuthFailure even though ClientId/ClientSecret are valid.
stsToken := h.CredentialInfo.StsToken
if stsToken == "Not set" {
stsToken = ""
}
v2Client := ec2v2.New(ec2v2.Options{
Region: h.Region.Region,
Credentials: credentialsv2.NewStaticCredentialsProvider(
h.CredentialInfo.ClientId, h.CredentialInfo.ClientSecret, h.CredentialInfo.StsToken),
h.CredentialInfo.ClientId, h.CredentialInfo.ClientSecret, stsToken),
})
_, err = v2Client.ModifyNetworkInterfaceAttribute(context.TODO(), &ec2v2.ModifyNetworkInterfaceAttributeInput{
NetworkInterfaceId: awsv2.String(primaryENIId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
irs "github.com/cloud-barista/cb-spider/cloud-control-manager/cloud-driver/interfaces/resources"
ktvpcsdk "github.com/cloud-barista/ktcloudvpc-sdk-go"
ips "github.com/cloud-barista/ktcloudvpc-sdk-go/openstack/compute/v2/extensions/floatingips"
"github.com/cloud-barista/ktcloudvpc-sdk-go/openstack/networking/v2/ports"
portforward "github.com/cloud-barista/ktcloudvpc-sdk-go/openstack/networking/v2/extensions/layer3/portforwarding"
"github.com/cloud-barista/ktcloudvpc-sdk-go/openstack/networking/v2/extensions/layer3/staticnat"
"github.com/cloud-barista/ktcloudvpc-sdk-go/pagination"
Expand Down Expand Up @@ -337,9 +336,23 @@ func (h *KTVpcPublicIPHandler) AssociatePublicIP(publicIPIID irs.IID, vmIID irs.
// with a timeout - the goroutine is abandoned (not cancelled) if it fires,
// trading a leaked goroutine on the rare hang for a request that still
// returns an error instead of hanging.
func (h *KTVpcPublicIPHandler) resolveSecurityContext(vmIID irs.IID, nicIID irs.IID) (tierNetworkId string, sgSystemIDs []string, err error) {
// resolveVMInfo fetches the VM's full VMInfo via GetVM() - the shared
// lookup behind resolveSecurityContext (Tier/SecurityGroup info) and
// resolveVMPrivateIP (PrivateIP). KT Cloud VPC does not really run on
// Neutron - the openstack-shaped `ports` API (ports.Get/ports.List) is a
// thin compatibility shim that has been observed to return persistent
// Internal Server Errors, so it must not be used for any of this; GetVM()
// is the only reliable source.
//
// GetVM() itself pulls in some unrelated lookups (image/disk info) and one
// of those has been observed to hang indefinitely on a slow/misbehaving KT
// API response. To avoid blocking this request forever, the call is bounded
// with a timeout - the goroutine is abandoned (not cancelled) if it fires,
// trading a leaked goroutine on the rare hang for a request that still
// returns an error instead of hanging.
func (h *KTVpcPublicIPHandler) resolveVMInfo(vmIID irs.IID) (irs.VMInfo, error) {
if vmIID.SystemId == "" && vmIID.NameId == "" {
return "", nil, fmt.Errorf("resolveSecurityContext: vmIID is required to resolve Tier/SecurityGroup info via GetVM()")
return irs.VMInfo{}, fmt.Errorf("vmIID is required to resolve VM info via GetVM()")
}

// GetVM() (via mappingVMInfo) uses ImageClient/VolumeClient too (e.g. for
Expand Down Expand Up @@ -375,15 +388,21 @@ func (h *KTVpcPublicIPHandler) resolveSecurityContext(vmIID irs.IID, nicIID irs.
resultCh <- getVMResult{vmInfo, getErr}
}()

var vmInfo irs.VMInfo
select {
case r := <-resultCh:
if r.err != nil {
return "", nil, fmt.Errorf("resolveSecurityContext: GetVM failed for VM [%s]: %w", vmIID.NameId, r.err)
return irs.VMInfo{}, fmt.Errorf("GetVM failed for VM [%s]: %w", vmIID.NameId, r.err)
}
vmInfo = r.vmInfo
return r.vmInfo, nil
case <-time.After(90 * time.Second):
return "", nil, fmt.Errorf("resolveSecurityContext: GetVM timed out after 90s for VM [%s]", vmIID.NameId)
return irs.VMInfo{}, fmt.Errorf("GetVM timed out after 90s for VM [%s]", vmIID.NameId)
}
}

func (h *KTVpcPublicIPHandler) resolveSecurityContext(vmIID irs.IID, nicIID irs.IID) (tierNetworkId string, sgSystemIDs []string, err error) {
vmInfo, vmErr := h.resolveVMInfo(vmIID)
if vmErr != nil {
return "", nil, fmt.Errorf("resolveSecurityContext: %w", vmErr)
}

if vmInfo.SubnetIID.SystemId == "" {
Expand Down Expand Up @@ -492,26 +511,18 @@ func (h *KTVpcPublicIPHandler) removeStaticNATIfAny(publicIPSystemId string) {
}
}

// resolveVMPrivateIP finds the first private IP of a VM via its ports.
// resolveVMPrivateIP finds a VM's private IP via GetVM() (see resolveVMInfo -
// the openstack-shaped `ports` API is deliberately not used here, it has
// been observed to return persistent Internal Server Errors on KT Cloud VPC).
func (h *KTVpcPublicIPHandler) resolveVMPrivateIP(vmIID irs.IID) (string, error) {
deviceID := vmIID.SystemId
if deviceID == "" {
deviceID = vmIID.NameId
}
allPages, err := ports.List(h.NetworkClient, ports.ListOpts{DeviceID: deviceID}).AllPages()
if err != nil {
return "", fmt.Errorf("failed to list ports for VM [%s]: %w", deviceID, err)
}
portList, err := ports.ExtractPorts(allPages)
vmInfo, err := h.resolveVMInfo(vmIID)
if err != nil {
return "", fmt.Errorf("failed to extract ports for VM [%s]: %w", deviceID, err)
return "", fmt.Errorf("resolveVMPrivateIP: %w", err)
}
for _, p := range portList {
if len(p.FixedIPs) > 0 && p.FixedIPs[0].IPAddress != "" {
return p.FixedIPs[0].IPAddress, nil
}
if vmInfo.PrivateIP == "" {
return "", fmt.Errorf("no private IP found for VM [%s]", vmIID.NameId)
}
return "", fmt.Errorf("no private IP found for VM [%s]", deviceID)
return vmInfo.PrivateIP, nil
}

// RemoveDefaultPublicIP removes whatever PublicIP is currently bound to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,45 @@ func (h *NcpVpcPublicIPHandler) CreatePublicIP(reqInfo irs.PublicIPInfo) (irs.Pu
created := resp.PublicIpInstanceList[0]
systemId := ncloud.StringValue(created.PublicIpInstanceNo)

// NCP is async: poll until the IP reaches a stable operation state before returning.
// Attempting DeletePublicIpInstance while the IP is still initializing returns error 1080101.
h.waitForPublicIPStable(systemId)

info := h.extractPublicIPInfo(created)
info.IId.NameId = reqInfo.IId.NameId
return info, nil
}

// waitForPublicIPStable polls until the given Public IP (a) leaves the async
// INIT/CREAT state NCP puts it in right after creation or after being
// auto-assigned at VM-creation time, AND (b) has no in-flight
// PublicIpInstanceOperation ("Public IP instance operation", per the NCP SDK
// field doc) - e.g. a just-completed
// Associate/Disassociate is still being applied. Any operation against the
// IP while either condition holds (Disassociate, Delete, ...) fails with NCP
// error 1080101 ("This is not an authorized IP in operation") - the
// "operation" in that message is this very field, not a caller-IP ACL.
// Best-effort - a poll error or timeout is silently ignored and the caller
// proceeds anyway, same as CreatePublicIP already did before this was
// extracted into a shared helper.
func (h *NcpVpcPublicIPHandler) waitForPublicIPStable(systemId string) {
for i := 0; i < 30; i++ {
pollResp, pollErr := h.VMClient.V2Api.GetPublicIpInstanceList(&vserver.GetPublicIpInstanceListRequest{
RegionCode: ncloud.String(h.RegionInfo.Region),
PublicIpInstanceNoList: []*string{ncloud.String(systemId)},
})
if pollErr == nil && len(pollResp.PublicIpInstanceList) > 0 {
pip := pollResp.PublicIpInstanceList[0]
statusCode := ""
if pollResp.PublicIpInstanceList[0].PublicIpInstanceStatus != nil {
statusCode = ncloud.StringValue(pollResp.PublicIpInstanceList[0].PublicIpInstanceStatus.Code)
if pip.PublicIpInstanceStatus != nil {
statusCode = ncloud.StringValue(pip.PublicIpInstanceStatus.Code)
}
if statusCode != "" && statusCode != "INIT" && statusCode != "CREAT" {
break
statusStable := statusCode != "" && statusCode != "INIT" && statusCode != "CREAT"
opClear := pip.PublicIpInstanceOperation == nil || ncloud.StringValue(pip.PublicIpInstanceOperation.Code) == ""
if statusStable && opClear {
return
}
}
time.Sleep(2 * time.Second)
}

info := h.extractPublicIPInfo(created)
info.IId.NameId = reqInfo.IId.NameId
return info, nil
}

func (h *NcpVpcPublicIPHandler) ListPublicIP() ([]*irs.PublicIPInfo, error) {
Expand Down Expand Up @@ -307,37 +324,76 @@ func (h *NcpVpcPublicIPHandler) DisassociatePublicIP(publicIPIID irs.IID) (bool,

// RemoveDefaultPublicIP removes whatever Public IP Instance is currently
// associated with the VM, discovered live via GetPublicIpInstanceList
// filtered by ServerName (regardless of whether it was ever tracked as a
// separate CB-Spider PublicIP resource) - then disassociates and deletes it
// via the existing DisassociatePublicIP/DeletePublicIP methods. Works on a
// running VM - no stop/restart required.
// (regardless of whether it was ever tracked as a separate CB-Spider
// PublicIP resource) - then disassociates and deletes it via the existing
// DisassociatePublicIP/DeletePublicIP methods. Works on a running VM - no
// stop/restart required.
//
// vmIID here is a driver-level IID rebuilt by getDriverIID() from the VM's
// stored SystemId, so vmIID.NameId is NOT the VM's real server name (it is
// derived from SystemId, see api-runtime/common-runtime/CommonManager.go).
// Matching by ServerName would therefore filter on a bogus value, so the
// association is resolved by ServerInstanceNo instead, same as
// AssociatePublicIP/DisassociatePublicIP above.
func (h *NcpVpcPublicIPHandler) RemoveDefaultPublicIP(vmIID irs.IID) (bool, error) {
hiscallInfo := GetCallLogScheme(h.RegionInfo.Zone, call.PUBLICIP, vmIID.NameId, "RemoveDefaultPublicIP()")
start := call.Start()

serverInstanceNo := vmIID.SystemId
if serverInstanceNo == "" {
serverInstanceNo = vmIID.NameId
}
if serverInstanceNo == "" {
err := fmt.Errorf("RemoveDefaultPublicIP: vmIID (SystemId or NameId) is required for NCP")
cblogger.Error(err)
return false, err
}

req := &vserver.GetPublicIpInstanceListRequest{
RegionCode: ncloud.String(h.RegionInfo.Region),
ServerName: ncloud.String(vmIID.NameId),
RegionCode: ncloud.String(h.RegionInfo.Region),
IsAssociated: ncloud.Bool(true),
}
resp, err := h.VMClient.V2Api.GetPublicIpInstanceList(req)
if err != nil {
cblogger.Error(err)
LoggingError(hiscallInfo, err)
return false, err
}
if len(resp.PublicIpInstanceList) == 0 {

var attached []*vserver.PublicIpInstance
for _, pip := range resp.PublicIpInstanceList {
if ncloud.StringValue(pip.ServerInstanceNo) == serverInstanceNo {
attached = append(attached, pip)
}
}
if len(attached) == 0 {
err := fmt.Errorf("no PublicIP found attached to VM %s", vmIID.NameId)
cblogger.Error(err)
return false, err
}

for _, pip := range resp.PublicIpInstanceList {
pipIID := irs.IID{SystemId: ncloud.StringValue(pip.PublicIpInstanceNo)}
for _, pip := range attached {
systemId := ncloud.StringValue(pip.PublicIpInstanceNo)
// A PublicIP auto-assigned at VM-creation time (AssociateWithPublicIp)
// is commonly still mid-async-setup (INIT/CREAT) by the time a caller
// can react to the VM going Running - see waitForPublicIPStable.
h.waitForPublicIPStable(systemId)

pipIID := irs.IID{SystemId: systemId}
if _, err := h.DisassociatePublicIP(pipIID); err != nil {
cblogger.Error(err)
LoggingError(hiscallInfo, err)
return false, err
}

// Disassociating triggers its own async state transition, so the IP
// can be back in a non-stable operation state by the time Delete
// runs - wait again rather than assuming the earlier wait still
// covers it (this is the exact case CreatePublicIP's comment above
// documents: "Attempting DeletePublicIpInstance while the IP is
// still initializing returns error 1080101").
h.waitForPublicIPStable(systemId)

if _, err := h.DeletePublicIP(pipIID); err != nil {
cblogger.Error(err)
LoggingError(hiscallInfo, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1137,8 +1137,16 @@ func (vmHandler *NhnCloudVMHandler) mappingVMInfo(server servers.Server) (irs.VM
}
nicInfo.PublicIPs = publicIPs
allPublicIPs = append(allPublicIPs, publicIPs...)
if idx == 0 && len(publicIPs) > 0 {
vmInfo.PublicIP = publicIPs[0]
if idx == 0 {
// Overwrite (including clearing to "") with this live, per-NIC
// floating IP lookup - it supersedes the possibly-stale value
// read from Nova's server.Addresses above (e.g. right after
// UnassignVMDefaultPublicIP, Nova may still report the old IP).
if len(publicIPs) > 0 {
vmInfo.PublicIP = publicIPs[0]
} else {
vmInfo.PublicIP = ""
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -909,8 +909,16 @@ func (vmHandler *OpenStackVMHandler) mappingServerInfo(server servers.Server) ir
}
nicInfo.PublicIPs = publicIPs
allPublicIPs = append(allPublicIPs, publicIPs...)
if idx == 0 && len(publicIPs) > 0 {
vmInfo.PublicIP = publicIPs[0]
if idx == 0 {
// Overwrite (including clearing to "") with this live, per-NIC
// floating IP lookup - it supersedes the possibly-stale value
// read from Nova's server.Addresses above (e.g. right after
// UnassignVMDefaultPublicIP, Nova may still report the old IP).
if len(publicIPs) > 0 {
vmInfo.PublicIP = publicIPs[0]
} else {
vmInfo.PublicIP = ""
}
}
}
}
Expand Down
2 changes: 0 additions & 2 deletions test/vm-default-publicip-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,5 +254,3 @@ Failed : 0
- **AWS, GCP만 Resume 후 PublicIP가 바뀜(`CHANGED`)** — 나머지 8개 CSP(Azure/Alibaba/Tencent/IBM/OpenStack/NCP/NHN/KT)는 전부 `SAME`(동일 IP 유지).
- Alibaba는 Suspend 중 조회된 PublicIP가 `(none)`이었으나 Resume 후 Initial과 동일 IP로 복귀 — 최종 비교는 `SAME`.
- VM Delete, 기본 리소스(KeyPair/SG/VPC) Cleanup도 10개 CSP 전부 정상 완료.

전체 CSP 상세 표 및 문서 조사와의 비교는 [`vm-default-publicip-analysis.md`](../../vm-default-publicip-analysis.md)의 "실제 시험 결과 (2026-08-20)" 섹션 참고.
Loading
Loading