package openstack import ( "context" "fmt" "github.com/gophercloud/gophercloud/v2" osauth "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" ) // ClientConfig carries pre-resolved credential values (already read from // environment variables by the caller — see config.OpenStackAuth). Token // auth is required per the deployment constraint that the admin credential // is supplied via the process environment, not a config file. type ClientConfig struct { AuthURL string Token string ProjectID string ProjectName string DomainName string Region string } type Client struct { networking *gophercloud.ServiceClient } // NewClient authenticates against Keystone using a pre-issued admin token // 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) { if cfg.AuthURL == "" || cfg.Token == "" { return nil, fmt.Errorf("openstack: auth URL and token are required") } authOpts := gophercloud.AuthOptions{ IdentityEndpoint: cfg.AuthURL, TokenID: cfg.Token, TenantID: cfg.ProjectID, TenantName: cfg.ProjectName, DomainName: cfg.DomainName, } if cfg.ProjectID != "" || cfg.ProjectName != "" { authOpts.Scope = &gophercloud.AuthScope{ ProjectID: cfg.ProjectID, ProjectName: cfg.ProjectName, DomainName: cfg.DomainName, } } provider, err := osauth.AuthenticatedClient(ctx, authOpts) if err != nil { return nil, fmt.Errorf("openstack: authenticate: %w", err) } networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{Region: cfg.Region}) if err != nil { return nil, fmt.Errorf("openstack: networking client: %w", err) } return &Client{networking: networking}, 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 } 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 }