feat(accounts): add explicit route binding workflow
This commit is contained in:
@@ -209,13 +209,28 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
routes, err := r.ListByShadowBinding(ctx, shadowHostID, shadowGroupID)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, err
|
||||
}
|
||||
switch len(routes) {
|
||||
case 0:
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
case 1:
|
||||
return routes[0], nil
|
||||
default:
|
||||
return LogicalGroupRoute{}, fmt.Errorf("multiple logical group routes match shadow binding %q/%q", strings.TrimSpace(shadowHostID), strings.TrimSpace(shadowGroupID))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) ListByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) ([]LogicalGroupRoute, error) {
|
||||
shadowHostID = strings.TrimSpace(shadowHostID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
if shadowHostID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_host_id is required")
|
||||
return nil, fmt.Errorf("shadow_host_id is required")
|
||||
}
|
||||
if shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
|
||||
return nil, fmt.Errorf("shadow_group_id is required")
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(
|
||||
@@ -223,17 +238,16 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
|
||||
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
|
||||
FROM logical_group_routes
|
||||
WHERE shadow_host_id = ? AND shadow_group_id = ?
|
||||
ORDER BY priority ASC, id ASC
|
||||
LIMIT 2`,
|
||||
ORDER BY priority ASC, id ASC`,
|
||||
shadowHostID,
|
||||
shadowGroupID,
|
||||
)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
return nil, fmt.Errorf("list logical group routes by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
routes := make([]LogicalGroupRoute, 0, 2)
|
||||
routes := make([]LogicalGroupRoute, 0, 4)
|
||||
for rows.Next() {
|
||||
var route LogicalGroupRoute
|
||||
if err := rows.Scan(
|
||||
@@ -251,21 +265,14 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
|
||||
&route.CreatedAt,
|
||||
&route.UpdatedAt,
|
||||
); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
|
||||
return nil, fmt.Errorf("scan logical group route by shadow binding: %w", err)
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
switch len(routes) {
|
||||
case 0:
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
case 1:
|
||||
return routes[0], nil
|
||||
default:
|
||||
return LogicalGroupRoute{}, fmt.Errorf("multiple logical group routes match shadow binding %q/%q", shadowHostID, shadowGroupID)
|
||||
return nil, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
|
||||
|
||||
@@ -12,6 +12,10 @@ const (
|
||||
ProviderAccountStatusDisabled = "disabled"
|
||||
ProviderAccountStatusDeprecated = "deprecated"
|
||||
ProviderAccountStatusBroken = "broken"
|
||||
|
||||
ProviderAccountBindingStateAssigned = "assigned"
|
||||
ProviderAccountBindingStateUnassigned = "unassigned"
|
||||
ProviderAccountBindingStateConflict = "conflict"
|
||||
)
|
||||
|
||||
type ProviderAccount struct {
|
||||
@@ -38,31 +42,34 @@ type ProviderAccountListFilter struct {
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
BindingState string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ProviderAccountView struct {
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteName string `json:"route_name,omitempty"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
ShadowHostID string `json:"shadow_host_id,omitempty"`
|
||||
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteName string `json:"route_name,omitempty"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
ShadowHostID string `json:"shadow_host_id,omitempty"`
|
||||
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
BindingState string `json:"binding_state,omitempty"`
|
||||
BindingCandidateCount int `json:"binding_candidate_count,omitempty"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderAccountsRepo struct {
|
||||
@@ -189,6 +196,20 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
pa.account_status,
|
||||
CASE
|
||||
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
|
||||
WHEN COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1 THEN 'conflict'
|
||||
ELSE 'unassigned'
|
||||
END,
|
||||
COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0),
|
||||
COALESCE(pa.last_probe_status, ''),
|
||||
COALESCE(pa.last_probe_at, ''),
|
||||
COALESCE(pa.disabled_reason, ''),
|
||||
@@ -224,6 +245,28 @@ func (r *ProviderAccountsRepo) UpdateStatusByID(ctx context.Context, id int64, a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProviderAccountsRepo) UpdateBindingByID(ctx context.Context, id int64, routeID, shadowGroupID string) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
routeID = strings.TrimSpace(routeID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE provider_accounts
|
||||
SET route_id = ?, shadow_group_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, routeID, shadowGroupID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update provider account %d binding: %w", id, err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("provider account %d binding rows affected: %w", id, err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProviderAccountsRepo) DeprecateMissingForScope(ctx context.Context, providerID, hostID int64, keepHostAccountIDs []string, reason string) error {
|
||||
if providerID <= 0 {
|
||||
return fmt.Errorf("provider_id is required")
|
||||
@@ -274,6 +317,20 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
pa.account_status,
|
||||
CASE
|
||||
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
|
||||
WHEN COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1 THEN 'conflict'
|
||||
ELSE 'unassigned'
|
||||
END,
|
||||
COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0),
|
||||
COALESCE(pa.last_probe_status, ''),
|
||||
COALESCE(pa.last_probe_at, ''),
|
||||
COALESCE(pa.disabled_reason, ''),
|
||||
@@ -309,6 +366,24 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query += ` AND pa.account_status = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := normalizeProviderAccountBindingState(filter.BindingState); value != "" {
|
||||
switch value {
|
||||
case ProviderAccountBindingStateAssigned:
|
||||
query += ` AND COALESCE(pa.route_id, '') <> ''`
|
||||
case ProviderAccountBindingStateConflict:
|
||||
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1`
|
||||
case ProviderAccountBindingStateUnassigned:
|
||||
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) <= 1`
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(filter.Query); value != "" {
|
||||
like := "%" + strings.ToLower(value) + "%"
|
||||
query += ` AND (
|
||||
@@ -355,6 +430,8 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
&view.AccountStatus,
|
||||
&view.BindingState,
|
||||
&view.BindingCandidateCount,
|
||||
&view.LastProbeStatus,
|
||||
&view.LastProbeAt,
|
||||
&view.DisabledReason,
|
||||
@@ -412,6 +489,8 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
&view.AccountStatus,
|
||||
&view.BindingState,
|
||||
&view.BindingCandidateCount,
|
||||
&view.LastProbeStatus,
|
||||
&view.LastProbeAt,
|
||||
&view.DisabledReason,
|
||||
@@ -463,3 +542,16 @@ func normalizeProviderAccountStatus(status string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderAccountBindingState(state string) string {
|
||||
switch strings.TrimSpace(state) {
|
||||
case ProviderAccountBindingStateAssigned:
|
||||
return ProviderAccountBindingStateAssigned
|
||||
case ProviderAccountBindingStateUnassigned:
|
||||
return ProviderAccountBindingStateUnassigned
|
||||
case ProviderAccountBindingStateConflict:
|
||||
return ProviderAccountBindingStateConflict
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
ShadowHostID: "shadow-host-1",
|
||||
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
@@ -101,9 +101,12 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
if len(rows) != 1 || rows[0].ID != accountID {
|
||||
t.Fatalf("ProviderAccounts().List() = %+v, want one row for account_id %d", rows, accountID)
|
||||
}
|
||||
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "shadow-host-1" {
|
||||
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "host-"+sanitizeTestName(t.Name()) {
|
||||
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
|
||||
}
|
||||
if rows[0].BindingState != ProviderAccountBindingStateAssigned || rows[0].BindingCandidateCount != 1 {
|
||||
t.Fatalf("ProviderAccounts().List() binding view = %+v", rows[0])
|
||||
}
|
||||
|
||||
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
|
||||
@@ -115,6 +118,39 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
if got.AccountStatus != ProviderAccountStatusDisabled || got.DisabledReason != "manual_disable" {
|
||||
t.Fatalf("ProviderAccounts().GetByID() after status update = %+v", got)
|
||||
}
|
||||
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
|
||||
RouteID: "route-2",
|
||||
LogicalGroupID: "lg-1",
|
||||
Name: "Route 2",
|
||||
Status: "active",
|
||||
Priority: 20,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
|
||||
}
|
||||
if err := accountRepo.UpdateBindingByID(ctx, accountID, "", "shadow-group-1"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateBindingByID(clear) error = %v", err)
|
||||
}
|
||||
conflictRows, err := accountRepo.List(ctx, ProviderAccountListFilter{BindingState: ProviderAccountBindingStateConflict})
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().List(conflict) error = %v", err)
|
||||
}
|
||||
if len(conflictRows) != 1 || conflictRows[0].ID != accountID || conflictRows[0].BindingState != ProviderAccountBindingStateConflict {
|
||||
t.Fatalf("ProviderAccounts().List(conflict) = %+v", conflictRows)
|
||||
}
|
||||
if err := accountRepo.UpdateBindingByID(ctx, accountID, "route-2", "shadow-group-1"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateBindingByID(assign) error = %v", err)
|
||||
}
|
||||
view, err = accountRepo.GetViewByID(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID(after binding) error = %v", err)
|
||||
}
|
||||
if view.RouteID != "route-2" || view.BindingState != ProviderAccountBindingStateAssigned {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID(after binding) = %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromImportBatchCreatesAndDeprecatesInventory(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user