566 lines
16 KiB
Go
566 lines
16 KiB
Go
package postgresadmin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"proxy-pool/internal/domain/adminstate"
|
|
)
|
|
|
|
const (
|
|
lockRevisionSQL = `LOCK TABLE control_revisions IN SHARE ROW EXCLUSIVE MODE`
|
|
currentRevisionSQL = `SELECT COALESCE(MAX(revision), 0)::bigint FROM control_revisions`
|
|
insertRevisionSQL = `
|
|
INSERT INTO control_revisions (revision, kind, created_at)
|
|
VALUES ($1, $2, $3)`
|
|
insertAuditSQL = `
|
|
INSERT INTO admin_audit_log (
|
|
request_id, actor_id, source_ip, action, resource_type, resource_name,
|
|
changed, revision, reason, occurred_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
|
|
insertOutboxSQL = `
|
|
INSERT INTO admin_outbox (
|
|
revision, event_type, aggregate_type, aggregate_id, payload, occurred_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6)`
|
|
)
|
|
|
|
func (adapter *adapter) CommitConfig(
|
|
ctx context.Context,
|
|
command adminstate.CommitConfigCommand,
|
|
) (adminstate.MutationResult, error) {
|
|
result := adminstate.MutationResult{RequestID: command.RequestID}
|
|
if err := contextError(ctx); err != nil {
|
|
return result, err
|
|
}
|
|
if command.Validate() != nil || !adapter.valid() {
|
|
return result, adminstate.ErrInvalidCommand
|
|
}
|
|
command = cloneConfigCommand(command)
|
|
|
|
tx, currentRevision, err := adapter.beginMutation(ctx, "commit config")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer rollback(tx)
|
|
|
|
var existingChecksum string
|
|
var isCurrent bool
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT checksum::text,
|
|
revision = (SELECT MAX(revision) FROM config_revisions)
|
|
FROM config_revisions
|
|
WHERE config_version = $1`, command.ConfigVersion).Scan(&existingChecksum, &isCurrent)
|
|
switch {
|
|
case err == nil:
|
|
if !isCurrent || !strings.EqualFold(existingChecksum, command.Checksum) {
|
|
return result, adminstate.ErrConflict
|
|
}
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionCommitConfig, "config", command.ConfigVersion, false,
|
|
currentRevision, "", command.OccurredAt, "audit config no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit config no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
result.Revision = uint64(currentRevision)
|
|
return result, nil
|
|
case !errors.Is(err, pgx.ErrNoRows):
|
|
return result, databaseError(ctx, "read config revision", err)
|
|
}
|
|
|
|
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "config", command.OccurredAt)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO config_revisions (
|
|
revision, config_version, checksum, source, created_at
|
|
) VALUES ($1, $2, $3, $4, $5)`,
|
|
nextRevision, command.ConfigVersion, command.Checksum, command.Source, utc(command.OccurredAt)); err != nil {
|
|
return result, databaseError(ctx, "insert config revision", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM routing_admin_state`); err != nil {
|
|
return result, databaseError(ctx, "replace routing state", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM upstream_admin_state`); err != nil {
|
|
return result, databaseError(ctx, "replace upstream state", err)
|
|
}
|
|
if err := copyUpstreams(ctx, tx, command.Upstreams, nextRevision, command.OccurredAt); err != nil {
|
|
return result, err
|
|
}
|
|
if err := copyRoutings(ctx, tx, command.Routings, nextRevision, command.OccurredAt); err != nil {
|
|
return result, err
|
|
}
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionCommitConfig, "config", command.ConfigVersion, true,
|
|
nextRevision, "", command.OccurredAt, "audit config change"); err != nil {
|
|
return result, err
|
|
}
|
|
payload, err := encodePayload(map[string]any{
|
|
"configVersion": command.ConfigVersion,
|
|
"checksum": command.Checksum,
|
|
"revision": nextRevision,
|
|
})
|
|
if err != nil {
|
|
return result, unavailable("encode config event")
|
|
}
|
|
if err := insertOutbox(ctx, tx, nextRevision, "config.committed", "config",
|
|
command.ConfigVersion, payload, command.OccurredAt, "insert config event"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit config change"); err != nil {
|
|
return result, err
|
|
}
|
|
return adminstate.MutationResult{
|
|
RequestID: command.RequestID,
|
|
Changed: true,
|
|
Revision: uint64(nextRevision),
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *adapter) SetUpstreamEnabled(
|
|
ctx context.Context,
|
|
command adminstate.SetUpstreamCommand,
|
|
) (adminstate.MutationResult, error) {
|
|
result := adminstate.MutationResult{RequestID: command.RequestID}
|
|
if err := contextError(ctx); err != nil {
|
|
return result, err
|
|
}
|
|
if command.Validate() != nil || !adapter.valid() {
|
|
return result, adminstate.ErrInvalidCommand
|
|
}
|
|
|
|
tx, currentRevision, err := adapter.beginMutation(ctx, "set upstream")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer rollback(tx)
|
|
|
|
var enabled bool
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT enabled
|
|
FROM upstream_admin_state
|
|
WHERE name = $1
|
|
FOR UPDATE`, command.Name).Scan(&enabled); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return result, adminstate.ErrNotFound
|
|
}
|
|
return result, databaseError(ctx, "read upstream state", err)
|
|
}
|
|
if enabled == command.Enabled {
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionSetUpstream, "upstream", command.Name, false,
|
|
currentRevision, "", command.OccurredAt, "audit upstream no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit upstream no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
result.Revision = uint64(currentRevision)
|
|
return result, nil
|
|
}
|
|
|
|
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "upstream", command.OccurredAt)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
commandTag, err := tx.Exec(ctx, `
|
|
UPDATE upstream_admin_state
|
|
SET enabled = $1, revision = $2, updated_at = $3
|
|
WHERE name = $4`, command.Enabled, nextRevision, utc(command.OccurredAt), command.Name)
|
|
if err != nil {
|
|
return result, databaseError(ctx, "update upstream state", err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return result, unavailable("update upstream state")
|
|
}
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionSetUpstream, "upstream", command.Name, true,
|
|
nextRevision, "", command.OccurredAt, "audit upstream change"); err != nil {
|
|
return result, err
|
|
}
|
|
payload, err := encodePayload(map[string]any{
|
|
"enabled": command.Enabled,
|
|
"name": command.Name,
|
|
"revision": nextRevision,
|
|
})
|
|
if err != nil {
|
|
return result, unavailable("encode upstream event")
|
|
}
|
|
if err := insertOutbox(ctx, tx, nextRevision, "upstream.enabled_changed", "upstream",
|
|
command.Name, payload, command.OccurredAt, "insert upstream event"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit upstream change"); err != nil {
|
|
return result, err
|
|
}
|
|
return adminstate.MutationResult{
|
|
RequestID: command.RequestID,
|
|
Changed: true,
|
|
Revision: uint64(nextRevision),
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *adapter) SwitchRouting(
|
|
ctx context.Context,
|
|
command adminstate.SwitchRoutingCommand,
|
|
) (adminstate.MutationResult, error) {
|
|
result := adminstate.MutationResult{RequestID: command.RequestID}
|
|
if err := contextError(ctx); err != nil {
|
|
return result, err
|
|
}
|
|
if command.Validate() != nil || !adapter.valid() {
|
|
return result, adminstate.ErrInvalidCommand
|
|
}
|
|
|
|
tx, currentRevision, err := adapter.beginMutation(ctx, "switch routing")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer rollback(tx)
|
|
|
|
var enabled bool
|
|
var candidates []string
|
|
var current string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT enabled, upstreams, current_upstream
|
|
FROM routing_admin_state
|
|
WHERE name = $1
|
|
FOR UPDATE`, command.Name).Scan(&enabled, &candidates, ¤t); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return result, adminstate.ErrNotFound
|
|
}
|
|
return result, databaseError(ctx, "read routing state", err)
|
|
}
|
|
if !enabled || current != command.ExpectedCurrent {
|
|
return result, adminstate.ErrConflict
|
|
}
|
|
if !contains(candidates, command.Target) {
|
|
return result, adminstate.ErrInvalidCommand
|
|
}
|
|
if current == command.Target {
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionSwitchRoute, "routing", command.Name, false,
|
|
currentRevision, command.Reason, command.OccurredAt, "audit routing no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit routing no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
result.Revision = uint64(currentRevision)
|
|
return result, nil
|
|
}
|
|
|
|
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "routing", command.OccurredAt)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
commandTag, err := tx.Exec(ctx, `
|
|
UPDATE routing_admin_state
|
|
SET current_upstream = $1, revision = $2, updated_at = $3
|
|
WHERE name = $4`, command.Target, nextRevision, utc(command.OccurredAt), command.Name)
|
|
if err != nil {
|
|
return result, databaseError(ctx, "update routing state", err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return result, unavailable("update routing state")
|
|
}
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionSwitchRoute, "routing", command.Name, true,
|
|
nextRevision, command.Reason, command.OccurredAt, "audit routing change"); err != nil {
|
|
return result, err
|
|
}
|
|
payload, err := encodePayload(map[string]any{
|
|
"current": command.Target,
|
|
"name": command.Name,
|
|
"previous": command.ExpectedCurrent,
|
|
"reason": command.Reason,
|
|
"revision": nextRevision,
|
|
})
|
|
if err != nil {
|
|
return result, unavailable("encode routing event")
|
|
}
|
|
if err := insertOutbox(ctx, tx, nextRevision, "routing.switched", "routing",
|
|
command.Name, payload, command.OccurredAt, "insert routing event"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit routing change"); err != nil {
|
|
return result, err
|
|
}
|
|
return adminstate.MutationResult{
|
|
RequestID: command.RequestID,
|
|
Changed: true,
|
|
Revision: uint64(nextRevision),
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *adapter) DisableRouting(
|
|
ctx context.Context,
|
|
command adminstate.DisableRoutingCommand,
|
|
) (adminstate.MutationResult, error) {
|
|
result := adminstate.MutationResult{RequestID: command.RequestID}
|
|
if err := contextError(ctx); err != nil {
|
|
return result, err
|
|
}
|
|
if command.Validate() != nil || !adapter.valid() {
|
|
return result, adminstate.ErrInvalidCommand
|
|
}
|
|
|
|
tx, currentRevision, err := adapter.beginMutation(ctx, "disable routing")
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer rollback(tx)
|
|
|
|
var enabled bool
|
|
var current string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT enabled, current_upstream
|
|
FROM routing_admin_state
|
|
WHERE name = $1
|
|
FOR UPDATE`, command.Name).Scan(&enabled, ¤t); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return result, adminstate.ErrNotFound
|
|
}
|
|
return result, databaseError(ctx, "read routing state", err)
|
|
}
|
|
if current != command.ExpectedCurrent {
|
|
return result, adminstate.ErrConflict
|
|
}
|
|
if !enabled {
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionDisableRoute, "routing", command.Name, false,
|
|
currentRevision, command.Reason, command.OccurredAt, "audit routing disable no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit routing disable no-op"); err != nil {
|
|
return result, err
|
|
}
|
|
result.Revision = uint64(currentRevision)
|
|
return result, nil
|
|
}
|
|
|
|
nextRevision, err := allocateRevision(ctx, tx, currentRevision, "routing", command.OccurredAt)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
commandTag, err := tx.Exec(ctx, `
|
|
UPDATE routing_admin_state
|
|
SET enabled = FALSE, revision = $1, updated_at = $2
|
|
WHERE name = $3 AND enabled = TRUE AND current_upstream = $4`,
|
|
nextRevision, utc(command.OccurredAt), command.Name, command.ExpectedCurrent)
|
|
if err != nil {
|
|
return result, databaseError(ctx, "disable routing state", err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return result, adminstate.ErrConflict
|
|
}
|
|
if err := insertAudit(ctx, tx, command.RequestID, command.Actor,
|
|
adminstate.ActionDisableRoute, "routing", command.Name, true,
|
|
nextRevision, command.Reason, command.OccurredAt, "audit routing disable"); err != nil {
|
|
return result, err
|
|
}
|
|
payload, err := encodePayload(map[string]any{
|
|
"current": command.ExpectedCurrent,
|
|
"enabled": false,
|
|
"name": command.Name,
|
|
"reason": command.Reason,
|
|
"revision": nextRevision,
|
|
})
|
|
if err != nil {
|
|
return result, unavailable("encode routing disable event")
|
|
}
|
|
if err := insertOutbox(ctx, tx, nextRevision, "routing.disabled", "routing",
|
|
command.Name, payload, command.OccurredAt, "insert routing disable event"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := commit(ctx, tx, "commit routing disable"); err != nil {
|
|
return result, err
|
|
}
|
|
return adminstate.MutationResult{
|
|
RequestID: command.RequestID,
|
|
Changed: true,
|
|
Revision: uint64(nextRevision),
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *adapter) beginMutation(ctx context.Context, operation string) (pgx.Tx, int64, error) {
|
|
tx, err := adapter.begin(ctx, pgx.TxOptions{AccessMode: pgx.ReadWrite}, operation)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if _, err := tx.Exec(ctx, lockRevisionSQL); err != nil {
|
|
rollback(tx)
|
|
return nil, 0, databaseError(ctx, "lock revision state", err)
|
|
}
|
|
var currentRevision int64
|
|
if err := tx.QueryRow(ctx, currentRevisionSQL).Scan(¤tRevision); err != nil {
|
|
rollback(tx)
|
|
return nil, 0, databaseError(ctx, "read current revision", err)
|
|
}
|
|
if currentRevision < 0 {
|
|
rollback(tx)
|
|
return nil, 0, unavailable("read current revision")
|
|
}
|
|
return tx, currentRevision, nil
|
|
}
|
|
|
|
func allocateRevision(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
currentRevision int64,
|
|
kind string,
|
|
occurredAt time.Time,
|
|
) (int64, error) {
|
|
if currentRevision == math.MaxInt64 {
|
|
return 0, adminstate.ErrUnavailable
|
|
}
|
|
nextRevision := currentRevision + 1
|
|
commandTag, err := tx.Exec(ctx, insertRevisionSQL, nextRevision, kind, utc(occurredAt))
|
|
if err != nil {
|
|
return 0, databaseError(ctx, "allocate revision", err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return 0, unavailable("allocate revision")
|
|
}
|
|
return nextRevision, nil
|
|
}
|
|
|
|
func copyUpstreams(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
definitions []adminstate.UpstreamDefinition,
|
|
revision int64,
|
|
occurredAt time.Time,
|
|
) error {
|
|
if len(definitions) == 0 {
|
|
return nil
|
|
}
|
|
updatedAt := utc(occurredAt)
|
|
count, err := tx.CopyFrom(ctx, pgx.Identifier{"upstream_admin_state"},
|
|
[]string{"name", "enabled", "revision", "updated_at"},
|
|
pgx.CopyFromSlice(len(definitions), func(index int) ([]any, error) {
|
|
definition := definitions[index]
|
|
return []any{definition.Name, definition.Enabled, revision, updatedAt}, nil
|
|
}))
|
|
if err != nil {
|
|
return databaseError(ctx, "replace upstream state", err)
|
|
}
|
|
if count != int64(len(definitions)) {
|
|
return unavailable("replace upstream state")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyRoutings(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
definitions []adminstate.RoutingDefinition,
|
|
revision int64,
|
|
occurredAt time.Time,
|
|
) error {
|
|
if len(definitions) == 0 {
|
|
return nil
|
|
}
|
|
updatedAt := utc(occurredAt)
|
|
count, err := tx.CopyFrom(ctx, pgx.Identifier{"routing_admin_state"},
|
|
[]string{"name", "enabled", "upstreams", "current_upstream", "revision", "updated_at"},
|
|
pgx.CopyFromSlice(len(definitions), func(index int) ([]any, error) {
|
|
definition := definitions[index]
|
|
return []any{
|
|
definition.Name,
|
|
definition.Enabled,
|
|
definition.Upstreams,
|
|
definition.CurrentUpstream,
|
|
revision,
|
|
updatedAt,
|
|
}, nil
|
|
}))
|
|
if err != nil {
|
|
return databaseError(ctx, "replace routing state", err)
|
|
}
|
|
if count != int64(len(definitions)) {
|
|
return unavailable("replace routing state")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertAudit(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
requestID string,
|
|
actor adminstate.Actor,
|
|
action adminstate.Action,
|
|
resourceType string,
|
|
resourceName string,
|
|
changed bool,
|
|
revision int64,
|
|
reason string,
|
|
occurredAt time.Time,
|
|
operation string,
|
|
) error {
|
|
commandTag, err := tx.Exec(ctx, insertAuditSQL,
|
|
requestID,
|
|
actor.ID,
|
|
sourceIPValue(actor.SourceIP),
|
|
string(action),
|
|
resourceType,
|
|
resourceName,
|
|
changed,
|
|
revision,
|
|
reason,
|
|
utc(occurredAt),
|
|
)
|
|
if err != nil {
|
|
return databaseError(ctx, operation, err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return unavailable(operation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertOutbox(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
revision int64,
|
|
eventType string,
|
|
aggregateType string,
|
|
aggregateID string,
|
|
payload json.RawMessage,
|
|
occurredAt time.Time,
|
|
operation string,
|
|
) error {
|
|
commandTag, err := tx.Exec(ctx, insertOutboxSQL,
|
|
revision,
|
|
eventType,
|
|
aggregateType,
|
|
aggregateID,
|
|
payload,
|
|
utc(occurredAt),
|
|
)
|
|
if err != nil {
|
|
return databaseError(ctx, operation, err)
|
|
}
|
|
if commandTag.RowsAffected() != 1 {
|
|
return unavailable(operation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func contains(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|