Files
LinkPort/internal/gateway/live_update.go
T

298 lines
8.8 KiB
Go

package gateway
import (
"errors"
"linkport/internal/model"
"net"
"reflect"
)
// ErrConfigChanged reports a failed live-configuration compare-and-swap.
var ErrConfigChanged = errors.New("gateway configuration changed; reload and retry")
// ConfigUpdate carries the snapshot used to construct Next. The From methods
// compare Current with the live snapshot before performing any persistence.
type ConfigUpdate struct {
Current model.Proxy
Next model.Proxy
}
type preparedConfigUpdate struct {
instance *instance
config model.Proxy
handler model.Handler
replacement net.Listener
}
// StartFrom loads and persists the latest enabled configuration while holding
// the lifecycle lock. The listener is published only after persistence succeeds.
func (m *Manager) StartFrom(load func() (model.Proxy, error), persist func(model.Proxy) error) (model.Proxy, error) {
if load == nil || persist == nil {
return model.Proxy{}, errors.New("load and persist callbacks are required")
}
m.mu.Lock()
defer m.mu.Unlock()
p, err := load()
if err != nil {
return p, err
}
p.Enabled = true
prepared, err := m.prepareStartLocked(p)
if err != nil {
return p, err
}
if err = persist(p); err != nil {
prepared.listener.Close()
if prepared.managementListener != nil {
prepared.managementListener.Close()
}
return p, err
}
m.publishStartLocked(prepared)
return p, nil
}
// StopAndPersist persists the latest disabled snapshot before stopping. A
// persistence failure leaves a running gateway untouched.
func (m *Manager) StopAndPersist(id string, load func() (model.Proxy, error), persist func(model.Proxy) error) (model.Proxy, error) {
if persist == nil {
return model.Proxy{}, errors.New("persist callback is required")
}
m.mu.Lock()
inst := m.instances[id]
var p model.Proxy
var err error
if inst == nil {
if load == nil {
m.mu.Unlock()
return model.Proxy{}, errors.New("load callback is required for a stopped gateway")
}
p, err = load()
if err != nil {
m.mu.Unlock()
return p, err
}
} else {
if inst.stopping {
m.mu.Unlock()
return model.Proxy{}, errors.New("gateway is already stopping")
}
p = inst.config
}
if p.ID != id {
m.mu.Unlock()
return p, errors.New("loaded gateway ID does not match")
}
p.Enabled = false
if err = persist(p); err != nil {
m.mu.Unlock()
return p, err
}
m.snapshots[id] = p
if inst == nil {
delete(m.failures, id)
m.mu.Unlock()
return p, nil
}
inst.config = p
inst.stopLocked()
m.mu.Unlock()
inst.waitStopped()
m.mu.Lock()
if m.instances[id] == inst {
delete(m.instances, id)
}
delete(m.failures, id)
m.mu.Unlock()
return p, nil
}
// UpdateConfig persists and publishes one gateway configuration. A stopped
// gateway is only persisted; a running gateway is updated without revoking
// connections that already captured the previous configuration snapshot.
func (m *Manager) UpdateConfig(next model.Proxy, persist func() error) error {
return m.updateConfigs([]ConfigUpdate{{Next: next}}, false, persist)
}
// UpdateConfigs atomically publishes resolved configuration snapshots after a
// single persistence commit. Listener changes are bound before persistence, so
// a bind or persistence failure leaves every running gateway unchanged.
func (m *Manager) UpdateConfigs(next []model.Proxy, persist func() error) error {
updates := make([]ConfigUpdate, len(next))
for idx := range next {
updates[idx].Next = next[idx]
}
return m.updateConfigs(updates, false, persist)
}
// UpdateConfigFrom applies a compare-and-swap update for one gateway.
func (m *Manager) UpdateConfigFrom(current, next model.Proxy, persist func() error) error {
return m.UpdateConfigsFrom([]ConfigUpdate{{Current: current, Next: next}}, persist)
}
// UpdateConfigsFrom atomically rejects the entire batch when any running
// gateway changed after its candidate configuration was constructed.
func (m *Manager) UpdateConfigsFrom(updates []ConfigUpdate, persist func() error) error {
return m.updateConfigs(updates, true, persist)
}
func comparableConfig(config model.Proxy) model.Proxy {
// Enabled controls restart persistence rather than a live connection. A
// manual Start publishes the pre-commit false value, so it cannot be a CAS
// token. Hooks are attached to per-connection copies at accept time.
config.Enabled = false
config.TargetDialer = nil
config.PreparedTarget = nil
// OutboundID is a persistence indirection. Saving an inline non-direct
// outbound creates an ID, while the resolved transport remains identical.
config.OutboundID = ""
if config.Targets != nil {
targets := make(map[string]model.Target, len(config.Targets))
for id, target := range config.Targets {
target.OutboundID = ""
targets[id] = target
}
config.Targets = targets
}
return config
}
func (m *Manager) updateConfigs(changes []ConfigUpdate, compareCurrent bool, persist func() error) error {
if persist == nil {
return errors.New("persist callback is required")
}
m.mu.Lock()
defer m.mu.Unlock()
seen := make(map[string]struct{}, len(changes))
updates := make([]preparedConfigUpdate, 0, len(changes))
closeReplacements := func() {
for idx := range updates {
if updates[idx].replacement != nil {
updates[idx].replacement.Close()
updates[idx].replacement = nil
}
}
}
for _, change := range changes {
config := change.Next
if config.ID == "" {
return errors.New("gateway ID is required")
}
if compareCurrent && change.Current.ID != config.ID {
return errors.New("current and next gateway IDs must match")
}
if _, ok := seen[config.ID]; ok {
return errors.New("duplicate gateway configuration")
}
seen[config.ID] = struct{}{}
inst := m.instances[config.ID]
if compareCurrent {
observed, ok := m.snapshots[config.ID]
if inst != nil {
observed, ok = inst.config, true
}
if ok {
if inst == nil {
// Full configuration replacement is only allowed while every
// gateway is stopped, so the store may legitimately be newer
// than this lifecycle snapshot. Enabled still distinguishes a
// stale update that was queued before StopAndPersist.
if observed.Enabled != change.Current.Enabled {
return ErrConfigChanged
}
} else if !reflect.DeepEqual(comparableConfig(observed), comparableConfig(change.Current)) {
return ErrConfigChanged
}
}
}
}
for _, change := range changes {
config := change.Next
inst := m.instances[config.ID]
if inst == nil {
continue
}
if inst.stopping {
closeReplacements()
return errors.New("gateway is stopping; retry after it stops")
}
if config.Type != inst.config.Type {
closeReplacements()
return errors.New("gateway protocol cannot change while running")
}
if config.ManagementListenPort != inst.config.ManagementListenPort || (inst.managementListener != nil && config.ListenHost != inst.config.ListenHost) {
closeReplacements()
return errors.New("stop the gateway before changing RabbitMQ management listen settings")
}
if config.ManagementListenPort != 0 && config.ManagementListenPort == config.ListenPort {
closeReplacements()
return errors.New("RabbitMQ management and AMQP listen ports must differ")
}
handler := m.handlers[config.Type]
if handler == nil {
closeReplacements()
return errors.New("unsupported protocol")
}
if !config.HasDestination() {
closeReplacements()
return errors.New("select a target before updating the running gateway")
}
if err := config.ValidateTargetSelection(); err != nil {
closeReplacements()
return err
}
if err := config.ValidateGatewayHostKeys(); err != nil {
closeReplacements()
return err
}
address, err := listenAddress(config.ListenHost, config.ListenPort)
if err != nil {
closeReplacements()
return err
}
update := preparedConfigUpdate{instance: inst, config: config, handler: handler}
if config.ListenHost != inst.config.ListenHost || config.ListenPort != inst.config.ListenPort {
update.replacement, err = net.Listen("tcp", address)
if err != nil {
closeReplacements()
return listenStartError(address, err)
}
}
updates = append(updates, update)
}
if err := persist(); err != nil {
closeReplacements()
return err
}
for _, change := range changes {
if m.instances[change.Next.ID] == nil {
m.snapshots[change.Next.ID] = change.Next
}
}
for idx := range updates {
update := &updates[idx]
inst := update.instance
published := update.config
published.Enabled = inst.config.Enabled
inst.config = published
m.snapshots[published.ID] = published
inst.persistent.Reconcile(published)
delete(m.failures, update.config.ID)
if update.replacement == nil {
continue
}
old := inst.listener
inst.listener = update.replacement
inst.wg.Add(1)
go m.accept(inst.ctx, inst, update.replacement, update.handler)
update.replacement = nil
old.Close()
}
return nil
}