An address still being associated (assigning_fip) has no fip_id in the database, so "clear queue" did not detach it, and the association then finished after the row was gone, leaving the floating IP on the validator port for good. - After clear/cancel/delete, ask the cloud which floating IPs sit on the affected validator ports (new ListFloatingIPsByPort) and detach those that this system queued (known in ip_registry); foreign ones are left. - SetFIPAssociated applies only to a row still in assigning_fip; if the address was removed meanwhile, associateFIP detaches the floating IP. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
291 lines
11 KiB
Go
291 lines
11 KiB
Go
package openstack
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/gophercloud/gophercloud/v2"
|
|
osauth "github.com/gophercloud/gophercloud/v2/openstack"
|
|
"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
|
|
"github.com/gophercloud/gophercloud/v2/pagination"
|
|
)
|
|
|
|
// AuthMethod selects how the client obtains the token it uses for Neutron
|
|
// calls.
|
|
type AuthMethod string
|
|
|
|
const (
|
|
// AuthMethodToken uses an admin-supplied token as-is (passthrough): the
|
|
// token is validated against Keystone but never exchanged for a freshly
|
|
// minted one, and the exact token value the operator provided is what
|
|
// every subsequent Neutron call uses. This is the default — it matches
|
|
// the deployment constraint that the admin credential is supplied
|
|
// directly via the process environment. Its trade-off: there is no way
|
|
// to renew the token automatically, since nothing longer-lived than the
|
|
// token itself is available to re-authenticate with. When it expires,
|
|
// the operator must reissue it and restart control-api.
|
|
AuthMethodToken AuthMethod = "token"
|
|
|
|
// AuthMethodPassword has the client obtain its own token via ordinary
|
|
// Keystone password authentication, and automatically re-authenticates
|
|
// (mints a fresh token) whenever the current one is rejected — not
|
|
// bounded by any single token's TTL, at the cost of holding a
|
|
// long-lived password credential in the process environment instead of
|
|
// a token.
|
|
AuthMethodPassword AuthMethod = "password"
|
|
)
|
|
|
|
// ClientConfig carries pre-resolved credential values (already read from
|
|
// environment variables by the caller — see config.OpenStackConfig). Some
|
|
// form of admin credential is always required via the process environment,
|
|
// never a config file; which fields are required depends on Method.
|
|
type ClientConfig struct {
|
|
AuthURL string
|
|
Method AuthMethod // "" is treated as AuthMethodToken
|
|
|
|
Token string // required when Method == AuthMethodToken
|
|
|
|
Username string // required when Method == AuthMethodPassword
|
|
Password string
|
|
UserDomainName string
|
|
|
|
ProjectID string
|
|
Region string
|
|
Interface string // "public" | "internal" | "admin"; "" defaults to "public"
|
|
|
|
// RequestTimeout bounds every single HTTP request to Keystone/Neutron
|
|
// (provider.HTTPClient.Timeout). Zero means no timeout (not recommended:
|
|
// a hung Neutron call would otherwise block the caller forever).
|
|
RequestTimeout time.Duration
|
|
// ListPageRetries is how many times one failed page of the floating-IP
|
|
// listing is retried (exponential backoff 1s,2s,4s,...). Zero disables
|
|
// retries; negative values mean "use DefaultListPageRetries".
|
|
ListPageRetries int
|
|
}
|
|
|
|
type Client struct {
|
|
networking *gophercloud.ServiceClient
|
|
retry pageRetry
|
|
}
|
|
|
|
// buildAuthOptions translates ClientConfig into gophercloud.AuthOptions. It
|
|
// touches no network and returns only validation errors, so the auth-method
|
|
// selection logic is unit-testable without a real OpenStack deployment.
|
|
func buildAuthOptions(cfg ClientConfig) (gophercloud.AuthOptions, error) {
|
|
if cfg.AuthURL == "" {
|
|
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: auth URL is required")
|
|
}
|
|
if cfg.ProjectID == "" {
|
|
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: project ID is required")
|
|
}
|
|
|
|
opts := gophercloud.AuthOptions{IdentityEndpoint: cfg.AuthURL}
|
|
|
|
switch cfg.Method {
|
|
case AuthMethodPassword:
|
|
if cfg.Username == "" || cfg.Password == "" {
|
|
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: username and password are required for auth_method=password")
|
|
}
|
|
opts.Username = cfg.Username
|
|
opts.Password = cfg.Password
|
|
opts.DomainName = cfg.UserDomainName
|
|
opts.Scope = &gophercloud.AuthScope{ProjectID: cfg.ProjectID}
|
|
// Safe and valuable here: a password credential can always mint a
|
|
// fresh token, so gophercloud can transparently re-authenticate on
|
|
// 401 for the entire lifetime of the process.
|
|
opts.AllowReauth = true
|
|
|
|
case AuthMethodToken, "":
|
|
if cfg.Token == "" {
|
|
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: token is required for auth_method=token")
|
|
}
|
|
opts.TokenID = cfg.Token
|
|
// Scope is deliberately left unset: gophercloud's v3auth takes this
|
|
// as the signal to "pass through" the given token rather than
|
|
// exchange it for a new one (GET /v3/auth/tokens to validate +
|
|
// fetch the catalog, using the same token value for every
|
|
// subsequent call, never minting a replacement). AllowReauth is
|
|
// left false on purpose — gophercloud actively rejects AllowReauth
|
|
// when Scope is unset, and there's nothing to reauthenticate with
|
|
// beyond the one token we were given anyway.
|
|
|
|
default:
|
|
return gophercloud.AuthOptions{}, fmt.Errorf("openstack: unknown auth method %q", cfg.Method)
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
// NewClient authenticates against Keystone (via the method selected by
|
|
// cfg.Method) and returns a Client scoped to the given project/region,
|
|
// backed by the Neutron (networking v2) service catalog entry.
|
|
func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
|
|
authOpts, err := buildAuthOptions(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
provider, err := osauth.AuthenticatedClient(ctx, authOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: authenticate: %w", err)
|
|
}
|
|
|
|
if cfg.RequestTimeout > 0 {
|
|
provider.HTTPClient.Timeout = cfg.RequestTimeout
|
|
}
|
|
|
|
iface := cfg.Interface
|
|
if iface == "" {
|
|
iface = string(gophercloud.AvailabilityPublic)
|
|
}
|
|
networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{
|
|
Region: cfg.Region,
|
|
Availability: gophercloud.Availability(iface),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: networking client: %w", err)
|
|
}
|
|
|
|
retries := cfg.ListPageRetries
|
|
if retries < 0 {
|
|
retries = DefaultListPageRetries
|
|
}
|
|
return &Client{networking: networking, retry: pageRetry{Retries: retries}}, nil
|
|
}
|
|
|
|
func (c *Client) GetFloatingIPByAddress(ctx context.Context, address string) (*FloatingIP, error) {
|
|
pages, err := floatingips.List(c.networking, floatingips.ListOpts{FloatingIP: address}).AllPages(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: list floating ips: %w", err)
|
|
}
|
|
list, err := floatingips.ExtractFloatingIPs(pages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: extract floating ips: %w", err)
|
|
}
|
|
if len(list) == 0 {
|
|
return nil, ErrNotFound(address)
|
|
}
|
|
f := list[0]
|
|
return &FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: f.TenantID}, nil
|
|
}
|
|
|
|
// fipListFields is the set of attributes requested from Neutron when listing:
|
|
// everything FloatingIP needs and nothing more (the full resource is several
|
|
// times larger, which matters with thousands of floating IPs).
|
|
var fipListFields = []string{"id", "floating_ip_address", "port_id", "project_id"}
|
|
|
|
// pagedListOpts wraps floatingips.ListOpts to add the `fields` query
|
|
// parameter, which gophercloud's ListOpts does not expose.
|
|
type pagedListOpts struct {
|
|
floatingips.ListOpts
|
|
fields []string
|
|
}
|
|
|
|
func (o pagedListOpts) ToFloatingIPListQuery() (string, error) {
|
|
q, err := o.ListOpts.ToFloatingIPListQuery()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(o.fields) == 0 {
|
|
return q, nil
|
|
}
|
|
v := url.Values{}
|
|
for _, f := range o.fields {
|
|
v.Add("fields", f)
|
|
}
|
|
if q == "" {
|
|
return "?" + v.Encode(), nil
|
|
}
|
|
return q + "&" + v.Encode(), nil
|
|
}
|
|
|
|
// fetchPage requests exactly one page (limit entries after marker). It uses
|
|
// marker pagination driven by us rather than gophercloud's `next` link: behind
|
|
// a proxy that link may point at an internal host.
|
|
func (c *Client) fetchPage(ctx context.Context, marker string, limit int) ([]FloatingIP, error) {
|
|
opts := pagedListOpts{
|
|
ListOpts: floatingips.ListOpts{Limit: limit, Marker: marker},
|
|
fields: fipListFields,
|
|
}
|
|
var out []FloatingIP
|
|
err := floatingips.List(c.networking, opts).EachPage(ctx, func(_ context.Context, page pagination.Page) (bool, error) {
|
|
list, err := floatingips.ExtractFloatingIPs(page)
|
|
if err != nil {
|
|
return false, fmt.Errorf("openstack: extract floating ips: %w", err)
|
|
}
|
|
out = make([]FloatingIP, 0, len(list))
|
|
for _, f := range list {
|
|
proj := f.TenantID
|
|
if proj == "" {
|
|
proj = f.ProjectID // Neutron may return only project_id when `fields` is used
|
|
}
|
|
out = append(out, FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: proj})
|
|
}
|
|
return false, nil // one page per request
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: list floating ips: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListFreeFloatingIPs pages through every floating IP of the project (page by
|
|
// page, retrying transient failures per page) and hands each page to onPage in
|
|
// server order. All entries of a page are passed — free and associated; the
|
|
// caller filters. It returns the number of non-empty pages read.
|
|
func (c *Client) ListFreeFloatingIPs(ctx context.Context, pageSize int, onPage func([]FloatingIP) error) (int, error) {
|
|
return paginate(ctx, pageSize, c.retry, c.fetchPage, onPage)
|
|
}
|
|
|
|
// ListFloatingIPsByPort asks Neutron for the floating IPs attached to portID.
|
|
func (c *Client) ListFloatingIPsByPort(ctx context.Context, portID string) ([]FloatingIP, error) {
|
|
pages, err := floatingips.List(c.networking, floatingips.ListOpts{PortID: portID}).AllPages(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: list floating ips of port %s: %w", portID, err)
|
|
}
|
|
list, err := floatingips.ExtractFloatingIPs(pages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openstack: extract floating ips: %w", err)
|
|
}
|
|
out := make([]FloatingIP, 0, len(list))
|
|
for _, f := range list {
|
|
proj := f.TenantID
|
|
if proj == "" {
|
|
proj = f.ProjectID
|
|
}
|
|
out = append(out, FloatingIP{ID: f.ID, Address: f.FloatingIP, PortID: f.PortID, ProjectID: proj})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
|
|
return listAll(ctx, c)
|
|
}
|
|
|
|
func (c *Client) AssociateFloatingIP(ctx context.Context, fipID, portID string) error {
|
|
_, err := floatingips.Update(ctx, c.networking, fipID, floatingips.UpdateOpts{
|
|
PortID: &portID,
|
|
}).Extract()
|
|
if err != nil {
|
|
return fmt.Errorf("openstack: associate floating ip %s -> port %s: %w", fipID, portID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) DisassociateFloatingIP(ctx context.Context, fipID string) error {
|
|
// gophercloud's UpdateOpts.PortID is *string with `omitempty`: a nil
|
|
// pointer is dropped from the request body entirely (no-op), while a
|
|
// pointer to "" is what actually serializes as port_id:null and
|
|
// disassociates the floating IP. See floatingips.UpdateOpts godoc.
|
|
empty := ""
|
|
_, err := floatingips.Update(ctx, c.networking, fipID, floatingips.UpdateOpts{
|
|
PortID: &empty,
|
|
}).Extract()
|
|
if err != nil {
|
|
return fmt.Errorf("openstack: disassociate floating ip %s: %w", fipID, err)
|
|
}
|
|
return nil
|
|
}
|