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 }