395 lines
9.8 KiB
Go
395 lines
9.8 KiB
Go
package adminstate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/netip"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
MaxIdentifierBytes = 128
|
|
MaxActorIDBytes = 256
|
|
MaxReasonBytes = 512
|
|
MaxSourceBytes = 512
|
|
MaxDefinitions = 10_000
|
|
MaxPageSize = 1_000
|
|
SHA256HexBytes = 64
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCommand = errors.New("invalid admin state command")
|
|
ErrNotFound = errors.New("admin state resource not found")
|
|
ErrConflict = errors.New("admin state conflict")
|
|
ErrUnavailable = errors.New("admin state unavailable")
|
|
)
|
|
|
|
var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
type Mutator interface {
|
|
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
|
|
SwitchRouting(context.Context, SwitchRoutingCommand) (MutationResult, error)
|
|
CommitConfig(context.Context, CommitConfigCommand) (MutationResult, error)
|
|
}
|
|
|
|
type SnapshotReader interface {
|
|
Snapshot(context.Context) (Snapshot, error)
|
|
}
|
|
|
|
type AuditReader interface {
|
|
ReadAudit(context.Context, AuditQuery) ([]AuditRecord, error)
|
|
}
|
|
|
|
type Outbox interface {
|
|
Claim(context.Context, ClaimCommand) ([]Event, error)
|
|
Acknowledge(context.Context, AcknowledgeCommand) error
|
|
}
|
|
|
|
type Store interface {
|
|
Mutator
|
|
SnapshotReader
|
|
AuditReader
|
|
Outbox
|
|
}
|
|
|
|
type Actor struct {
|
|
ID string
|
|
SourceIP string
|
|
}
|
|
|
|
type SetUpstreamCommand struct {
|
|
RequestID string
|
|
Actor Actor
|
|
OccurredAt time.Time
|
|
Name string
|
|
Enabled bool
|
|
}
|
|
|
|
type SwitchRoutingCommand struct {
|
|
RequestID string
|
|
Actor Actor
|
|
OccurredAt time.Time
|
|
Name string
|
|
ExpectedCurrent string
|
|
Target string
|
|
Reason string
|
|
}
|
|
|
|
type CommitConfigCommand struct {
|
|
RequestID string
|
|
Actor Actor
|
|
OccurredAt time.Time
|
|
ConfigVersion string
|
|
Checksum string
|
|
Source string
|
|
Upstreams []UpstreamDefinition
|
|
Routings []RoutingDefinition
|
|
}
|
|
|
|
type UpstreamDefinition struct {
|
|
Name string
|
|
Enabled bool
|
|
}
|
|
|
|
type RoutingDefinition struct {
|
|
Name string
|
|
Enabled bool
|
|
Upstreams []string
|
|
CurrentUpstream string
|
|
}
|
|
|
|
type MutationResult struct {
|
|
RequestID string
|
|
Changed bool
|
|
Revision uint64
|
|
Message string
|
|
}
|
|
|
|
type ConfigRevision struct {
|
|
Revision uint64
|
|
ConfigVersion string
|
|
Checksum string
|
|
Source string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type UpstreamState struct {
|
|
Name string
|
|
Enabled bool
|
|
Revision uint64
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type RoutingState struct {
|
|
Name string
|
|
Enabled bool
|
|
Upstreams []string
|
|
CurrentUpstream string
|
|
Revision uint64
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Revision uint64
|
|
Config *ConfigRevision
|
|
Upstreams []UpstreamState
|
|
Routings []RoutingState
|
|
}
|
|
|
|
type Action string
|
|
|
|
const (
|
|
ActionSetUpstream Action = "set_upstream_enabled"
|
|
ActionSwitchRoute Action = "switch_routing"
|
|
ActionCommitConfig Action = "commit_config"
|
|
)
|
|
|
|
type AuditRecord struct {
|
|
ID uint64
|
|
RequestID string
|
|
Actor Actor
|
|
Action Action
|
|
ResourceType string
|
|
ResourceName string
|
|
Changed bool
|
|
Revision uint64
|
|
Reason string
|
|
OccurredAt time.Time
|
|
}
|
|
|
|
type AuditQuery struct {
|
|
AfterID uint64
|
|
Limit int
|
|
}
|
|
|
|
type Event struct {
|
|
ID uint64
|
|
Revision uint64
|
|
Type string
|
|
AggregateType string
|
|
AggregateID string
|
|
Payload json.RawMessage
|
|
OccurredAt time.Time
|
|
ClaimedBy string
|
|
ClaimUntil *time.Time
|
|
PublishedAt *time.Time
|
|
}
|
|
|
|
type ClaimCommand struct {
|
|
ConsumerID string
|
|
Now time.Time
|
|
Limit int
|
|
Lease time.Duration
|
|
}
|
|
|
|
type AcknowledgeCommand struct {
|
|
ConsumerID string
|
|
Now time.Time
|
|
EventIDs []uint64
|
|
}
|
|
|
|
// Validate checks whether the command is safe for every Store adapter to execute.
|
|
func (command SetUpstreamCommand) Validate() error {
|
|
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
|
!validIdentifier(command.Name) {
|
|
return ErrInvalidCommand
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks whether the command is safe for every Store adapter to execute.
|
|
func (command SwitchRoutingCommand) Validate() error {
|
|
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
|
!validIdentifier(command.Name) || !validIdentifier(command.ExpectedCurrent) ||
|
|
!validIdentifier(command.Target) || !validOptionalText(command.Reason, MaxReasonBytes) {
|
|
return ErrInvalidCommand
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the complete management snapshot and all of its references.
|
|
func (command CommitConfigCommand) Validate() error {
|
|
if err := validateMutationBase(command.RequestID, command.Actor, command.OccurredAt); err != nil ||
|
|
!validRequiredText(command.ConfigVersion, MaxIdentifierBytes) ||
|
|
!validSHA256(command.Checksum) || !validRequiredText(command.Source, MaxSourceBytes) ||
|
|
len(command.Upstreams) > MaxDefinitions || len(command.Routings) > MaxDefinitions {
|
|
return ErrInvalidCommand
|
|
}
|
|
|
|
upstreamNames := make(map[string]struct{}, len(command.Upstreams))
|
|
for _, upstream := range command.Upstreams {
|
|
if !validIdentifier(upstream.Name) {
|
|
return ErrInvalidCommand
|
|
}
|
|
if _, duplicate := upstreamNames[upstream.Name]; duplicate {
|
|
return ErrInvalidCommand
|
|
}
|
|
upstreamNames[upstream.Name] = struct{}{}
|
|
}
|
|
|
|
routingNames := make(map[string]struct{}, len(command.Routings))
|
|
for _, routing := range command.Routings {
|
|
if !validIdentifier(routing.Name) || len(routing.Upstreams) == 0 ||
|
|
len(routing.Upstreams) > MaxDefinitions || !validIdentifier(routing.CurrentUpstream) {
|
|
return ErrInvalidCommand
|
|
}
|
|
if _, duplicate := routingNames[routing.Name]; duplicate {
|
|
return ErrInvalidCommand
|
|
}
|
|
routingNames[routing.Name] = struct{}{}
|
|
candidates := make(map[string]struct{}, len(routing.Upstreams))
|
|
for _, upstream := range routing.Upstreams {
|
|
if !validIdentifier(upstream) {
|
|
return ErrInvalidCommand
|
|
}
|
|
if _, exists := upstreamNames[upstream]; !exists {
|
|
return ErrInvalidCommand
|
|
}
|
|
if _, duplicate := candidates[upstream]; duplicate {
|
|
return ErrInvalidCommand
|
|
}
|
|
candidates[upstream] = struct{}{}
|
|
}
|
|
if _, exists := candidates[routing.CurrentUpstream]; !exists {
|
|
return ErrInvalidCommand
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the bounded outbox claim request.
|
|
func (command ClaimCommand) Validate() error {
|
|
if !validIdentifier(command.ConsumerID) || command.Now.IsZero() || command.Limit <= 0 ||
|
|
command.Limit > MaxPageSize || command.Lease <= 0 {
|
|
return ErrInvalidCommand
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the bounded, duplicate-free outbox acknowledgement request.
|
|
func (command AcknowledgeCommand) Validate() error {
|
|
if !validIdentifier(command.ConsumerID) || command.Now.IsZero() || len(command.EventIDs) == 0 ||
|
|
len(command.EventIDs) > MaxPageSize {
|
|
return ErrInvalidCommand
|
|
}
|
|
seen := make(map[uint64]struct{}, len(command.EventIDs))
|
|
for _, eventID := range command.EventIDs {
|
|
if eventID == 0 {
|
|
return ErrInvalidCommand
|
|
}
|
|
if _, duplicate := seen[eventID]; duplicate {
|
|
return ErrInvalidCommand
|
|
}
|
|
seen[eventID] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the bounded audit pagination request.
|
|
func (query AuditQuery) Validate() error {
|
|
if query.Limit <= 0 || query.Limit > MaxPageSize {
|
|
return ErrInvalidCommand
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateMutationBase(requestID string, actor Actor, occurredAt time.Time) error {
|
|
if !validRequiredText(requestID, MaxIdentifierBytes) || !validRequiredText(actor.ID, MaxActorIDBytes) ||
|
|
occurredAt.IsZero() {
|
|
return ErrInvalidCommand
|
|
}
|
|
if actor.SourceIP != "" {
|
|
address, err := netip.ParseAddr(actor.SourceIP)
|
|
if err != nil || !address.IsValid() {
|
|
return ErrInvalidCommand
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validIdentifier(value string) bool {
|
|
return len(value) <= MaxIdentifierBytes && identifierPattern.MatchString(value)
|
|
}
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != SHA256HexBytes {
|
|
return false
|
|
}
|
|
decoded, err := hex.DecodeString(value)
|
|
return err == nil && len(decoded) == SHA256HexBytes/2
|
|
}
|
|
|
|
func validRequiredText(value string, maximum int) bool {
|
|
return value != "" && validOptionalText(value, maximum)
|
|
}
|
|
|
|
func validOptionalText(value string, maximum int) bool {
|
|
if len(value) > maximum || !utf8.ValidString(value) || strings.TrimSpace(value) != value {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if unicode.IsControl(character) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cloneCommitConfigCommand(command CommitConfigCommand) CommitConfigCommand {
|
|
cloned := command
|
|
cloned.Upstreams = append([]UpstreamDefinition(nil), command.Upstreams...)
|
|
cloned.Routings = make([]RoutingDefinition, len(command.Routings))
|
|
for index, routing := range command.Routings {
|
|
cloned.Routings[index] = routing
|
|
cloned.Routings[index].Upstreams = append([]string(nil), routing.Upstreams...)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneSnapshot(snapshot Snapshot) Snapshot {
|
|
cloned := snapshot
|
|
if snapshot.Config != nil {
|
|
config := *snapshot.Config
|
|
cloned.Config = &config
|
|
}
|
|
cloned.Upstreams = append([]UpstreamState(nil), snapshot.Upstreams...)
|
|
cloned.Routings = make([]RoutingState, len(snapshot.Routings))
|
|
for index, routing := range snapshot.Routings {
|
|
cloned.Routings[index] = routing
|
|
cloned.Routings[index].Upstreams = append([]string(nil), routing.Upstreams...)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneEvent(event Event) Event {
|
|
cloned := event
|
|
cloned.Payload = append(json.RawMessage(nil), event.Payload...)
|
|
if event.ClaimUntil != nil {
|
|
value := *event.ClaimUntil
|
|
cloned.ClaimUntil = &value
|
|
}
|
|
if event.PublishedAt != nil {
|
|
value := *event.PublishedAt
|
|
cloned.PublishedAt = &value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneEvents(events []Event) []Event {
|
|
cloned := make([]Event, len(events))
|
|
for index, event := range events {
|
|
cloned[index] = cloneEvent(event)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneAuditRecords(records []AuditRecord) []AuditRecord {
|
|
return append([]AuditRecord(nil), records...)
|
|
}
|