repo init
This commit is contained in:
commit
7e44db87b2
48 files changed
+5346
No files matched your search
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package openstack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClientLive is a smoke test against a real OpenStack deployment. It's
|
||||
// skipped unless OPENSTACK_LIVE_TEST=1 and the usual OS_* credential env
|
||||
// vars are set, so the default `go test ./...` run needs no cloud access.
|
||||
// It only exercises GetFloatingIPByAddress (read-only) against
|
||||
// OS_TEST_FLOATING_IP, to avoid mutating real infrastructure in CI.
|
||||
func TestClientLive(t *testing.T) {
|
||||
if os.Getenv("OPENSTACK_LIVE_TEST") != "1" {
|
||||
t.Skip("set OPENSTACK_LIVE_TEST=1 (and OS_AUTH_URL, OS_TOKEN, OS_TEST_FLOATING_IP) to run")
|
||||
}
|
||||
|
||||
testIP := os.Getenv("OS_TEST_FLOATING_IP")
|
||||
if testIP == "" {
|
||||
t.Fatal("OS_TEST_FLOATING_IP must name a floating IP address that exists in the target project")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := NewClient(ctx, ClientConfig{
|
||||
AuthURL: os.Getenv("OS_AUTH_URL"),
|
||||
Token: os.Getenv("OS_TOKEN"),
|
||||
ProjectID: os.Getenv("OS_PROJECT_ID"),
|
||||
ProjectName: os.Getenv("OS_PROJECT_NAME"),
|
||||
DomainName: os.Getenv("OS_PROJECT_DOMAIN_NAME"),
|
||||
Region: os.Getenv("OS_REGION_NAME"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
|
||||
fip, err := client.GetFloatingIPByAddress(ctx, testIP)
|
||||
if err != nil {
|
||||
t.Fatalf("get floating ip %s: %v", testIP, err)
|
||||
}
|
||||
if fip.Address != testIP {
|
||||
t.Fatalf("expected address %s, got %s", testIP, fip.Address)
|
||||
}
|
||||
t.Logf("found floating ip %s: id=%s port_id=%q", fip.Address, fip.ID, fip.PortID)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package openstack isolates all OpenStack/Neutron interaction behind a
|
||||
// small interface, so the orchestrator's business logic can be unit tested
|
||||
// without a real cloud, and so the real implementation is swappable for a
|
||||
// mock via config alone.
|
||||
package openstack
|
||||
|
||||
import "context"
|
||||
|
||||
type FloatingIP struct {
|
||||
ID string
|
||||
Address string
|
||||
PortID string // empty when not associated to any port
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
// FloatingIPClient is the only surface the orchestrator uses to manage
|
||||
// Floating IPs. It intentionally does not expose allocation/deallocation —
|
||||
// this tool only associates/disassociates pre-existing floating IPs with
|
||||
// validator ports; returning an address to the customer-facing pool is an
|
||||
// external business process out of scope here.
|
||||
type FloatingIPClient interface {
|
||||
// GetFloatingIPByAddress looks up the Neutron floating-ip resource for
|
||||
// a given public address. Returns ErrNotFound if no such floating IP
|
||||
// is registered in the service project.
|
||||
GetFloatingIPByAddress(ctx context.Context, address string) (*FloatingIP, error)
|
||||
|
||||
// AssociateFloatingIP attaches the floating IP to the given Neutron
|
||||
// port (the validator's primary NIC port).
|
||||
AssociateFloatingIP(ctx context.Context, fipID, portID string) error
|
||||
|
||||
// DisassociateFloatingIP detaches the floating IP from whatever port
|
||||
// it's currently attached to, if any. Disassociating an already-free
|
||||
// floating IP is a no-op, not an error.
|
||||
DisassociateFloatingIP(ctx context.Context, fipID string) error
|
||||
}
|
||||
|
||||
type notFoundError struct{ address string }
|
||||
|
||||
func (e *notFoundError) Error() string { return "floating ip not found: " + e.address }
|
||||
|
||||
// ErrNotFound wraps address into an error satisfying IsNotFound.
|
||||
func ErrNotFound(address string) error { return ¬FoundError{address} }
|
||||
|
||||
// IsNotFound reports whether err indicates the address has no matching
|
||||
// Neutron floating-ip resource.
|
||||
func IsNotFound(err error) bool {
|
||||
_, ok := err.(*notFoundError)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package openstack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MockClient is an in-memory FloatingIPClient used by unit tests and the
|
||||
// offline end-to-end harness. Seed it with the pool of floating IPs the
|
||||
// scenario expects to exist before use.
|
||||
type MockClient struct {
|
||||
mu sync.Mutex
|
||||
fips map[string]*FloatingIP // keyed by ID
|
||||
byIP map[string]string // address -> ID
|
||||
|
||||
// AssociateFailures/DisassociateFailures let tests force a failure for
|
||||
// a specific floating-IP ID on its next call, to exercise retry paths.
|
||||
AssociateFailures map[string]error
|
||||
DisassociateFailures map[string]error
|
||||
}
|
||||
|
||||
func NewMockClient() *MockClient {
|
||||
return &MockClient{
|
||||
fips: make(map[string]*FloatingIP),
|
||||
byIP: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Seed registers a floating IP as if pre-allocated in the service project.
|
||||
func (m *MockClient) Seed(id, address, projectID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.fips[id] = &FloatingIP{ID: id, Address: address, ProjectID: projectID}
|
||||
m.byIP[address] = id
|
||||
}
|
||||
|
||||
func (m *MockClient) GetFloatingIPByAddress(ctx context.Context, address string) (*FloatingIP, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.byIP[address]
|
||||
if !ok {
|
||||
return nil, ErrNotFound(address)
|
||||
}
|
||||
f := *m.fips[id]
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (m *MockClient) AssociateFloatingIP(ctx context.Context, fipID, portID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.AssociateFailures[fipID]; err != nil {
|
||||
delete(m.AssociateFailures, fipID)
|
||||
return err
|
||||
}
|
||||
f, ok := m.fips[fipID]
|
||||
if !ok {
|
||||
return fmt.Errorf("mock openstack: unknown floating ip %q", fipID)
|
||||
}
|
||||
f.PortID = portID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockClient) DisassociateFloatingIP(ctx context.Context, fipID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.DisassociateFailures[fipID]; err != nil {
|
||||
delete(m.DisassociateFailures, fipID)
|
||||
return err
|
||||
}
|
||||
f, ok := m.fips[fipID]
|
||||
if !ok {
|
||||
return fmt.Errorf("mock openstack: unknown floating ip %q", fipID)
|
||||
}
|
||||
f.PortID = ""
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user