repo init

This commit is contained in:
ayurishchev committed 2026-08-21 07:34:45 +03:00
commit 7e44db87b2
48 files changed
+5346

No files matched your search

+104
View File
@@ -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
}
+48
View File
@@ -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)
}
+49
View File
@@ -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 &notFoundError{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
}
+77
View File
@@ -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
}