2026-08-21 07:34:45 +03:00
|
|
|
package openstack
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
2026-10-01 19:31:11 +03:00
|
|
|
"net/url"
|
|
|
|
|
"time"
|
2026-08-21 07:34:45 +03:00
|
|
|
|
|
|
|
|
"github.com/gophercloud/gophercloud/v2"
|
|
|
|
|
osauth "github.com/gophercloud/gophercloud/v2/openstack"
|
|
|
|
|
"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
|
2026-10-01 19:31:11 +03:00
|
|
|
"github.com/gophercloud/gophercloud/v2/pagination"
|
2026-08-21 07:34:45 +03:00
|
|
|
)
|
|
|
|
|
|
2026-08-21 09:58:08 +03:00
|
|
|
// 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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-21 07:34:45 +03:00
|
|
|
// ClientConfig carries pre-resolved credential values (already read from
|
2026-08-21 09:58:08 +03:00
|
|
|
// 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.
|
2026-08-21 07:34:45 +03:00
|
|
|
type ClientConfig struct {
|
2026-08-21 09:58:08 +03:00
|
|
|
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"
|
2026-10-01 19:31:11 +03:00
|
|
|
|
|
|
|
|
// 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
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Client struct {
|
|
|
|
|
networking *gophercloud.ServiceClient
|
2026-10-01 19:31:11 +03:00
|
|
|
retry pageRetry
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-21 09:58:08 +03:00
|
|
|
// 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")
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-21 09:58:08 +03:00
|
|
|
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")
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
2026-08-21 09:58:08 +03:00
|
|
|
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
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
provider, err := osauth.AuthenticatedClient(ctx, authOpts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openstack: authenticate: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 19:31:11 +03:00
|
|
|
if cfg.RequestTimeout > 0 {
|
|
|
|
|
provider.HTTPClient.Timeout = cfg.RequestTimeout
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 09:58:08 +03:00
|
|
|
iface := cfg.Interface
|
|
|
|
|
if iface == "" {
|
|
|
|
|
iface = string(gophercloud.AvailabilityPublic)
|
|
|
|
|
}
|
|
|
|
|
networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{
|
|
|
|
|
Region: cfg.Region,
|
|
|
|
|
Availability: gophercloud.Availability(iface),
|
|
|
|
|
})
|
2026-08-21 07:34:45 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openstack: networking client: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 19:31:11 +03:00
|
|
|
retries := cfg.ListPageRetries
|
|
|
|
|
if retries < 0 {
|
|
|
|
|
retries = DefaultListPageRetries
|
|
|
|
|
}
|
|
|
|
|
return &Client{networking: networking, retry: pageRetry{Retries: retries}}, nil
|
2026-08-21 07:34:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 19:31:11 +03:00
|
|
|
// 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
|
|
|
|
|
})
|
2026-09-23 09:52:01 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openstack: list floating ips: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 19:31:11 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 21:00:31 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 19:31:11 +03:00
|
|
|
func (c *Client) ListFloatingIPs(ctx context.Context) ([]FloatingIP, error) {
|
|
|
|
|
return listAll(ctx, c)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 07:34:45 +03:00
|
|
|
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
|
|
|
|
|
}
|