fix(migrate): 兑换迁移码在双方认领锁下检查目标配额,超出返回 403 migrate_quota_exceeded 且码不消耗

This commit is contained in:
Lemon-miaow committed 2026-09-27 12:46:49 +08:00
1 parent fe48319866
commit f692725bed
12 files changed
+362 -11

No files matched your search

+8
View File
@@ -5709,6 +5709,14 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403':
description: >
The source's servers would push the caller over a quota cap
(migrate_quota_exceeded). Nothing moved and the code is unspent; it redeems once
the quota fits, until it expires.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/me/submissions: /api/v1/me/submissions:
post: post:
+10
View File
@@ -47,6 +47,9 @@ type fakeRepo struct {
// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4) // claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
// refusing a name whose advisory pre-check already passed. // refusing a name whose advisory pre-check already passed.
claimQuotaRefuse map[string]bool claimQuotaRefuse map[string]bool
// migrateQuotaRefuse names redeeming targets whose quota RedeemMigration's gate
// finds too small for the servers moving in; it refuses only a source that owns one.
migrateQuotaRefuse map[string]bool
// serverResources / resourceUpdates mirror the cached resource columns: // serverResources / resourceUpdates mirror the cached resource columns:
// ServerResources is what the resize path reads (to preserve storage), and // ServerResources is what the resize path reads (to preserve storage), and
// UpdateServerResources records the write for assertions. // UpdateServerResources records the write for assertions.
@@ -1471,6 +1474,13 @@ func (f *fakeRepo) RedeemMigration(_ context.Context, targetUserID, codeHash str
if mig == nil { if mig == nil {
return "", nil, ErrLinkCodeInvalid return "", nil, ErrLinkCodeInvalid
} }
if f.migrateQuotaRefuse[targetUserID] {
for _, rec := range f.byName {
if rec.OwnerID == mig.sourceUserID {
return "", nil, ErrQuotaExceeded // before anything moves, as the real gate
}
}
}
// Re-point every server the source owns to the target (byName holds pointers). // Re-point every server the source owns to the target (byName holds pointers).
var moved []string var moved []string
for name, rec := range f.byName { for name, rec := range f.byName {
+9
View File
@@ -410,6 +410,11 @@ func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) {
var errMigrateNotConfirmed = newError(http.StatusConflict, "not_confirmed", var errMigrateNotConfirmed = newError(http.StatusConflict, "not_confirmed",
"confirm the migration on this browser first; a confirmation lasts 10 minutes") "confirm the migration on this browser first; a confirmation lasts 10 minutes")
// errMigrateQuotaExceeded refuses a redeem whose servers would push the target over
// its quota. The code stays unspent, so raising the quota and redeeming again works.
var errMigrateQuotaExceeded = newError(http.StatusForbidden, "migrate_quota_exceeded",
"the servers this migration moves do not fit this account's quota; ask an admin to raise it, then redeem the same code again before it expires")
// migrateRedeemRequest is the redeem body: the one-time code the target received. // migrateRedeemRequest is the redeem body: the one-time code the target received.
type migrateRedeemRequest struct { type migrateRedeemRequest struct {
Code string `json:"code"` Code string `json:"code"`
@@ -441,6 +446,10 @@ func (a *API) handleMigrateRedeem(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "migrate code is invalid or expired")) writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "migrate code is invalid or expired"))
return return
} }
if errors.Is(err, ErrQuotaExceeded) {
writeError(w, r, errMigrateQuotaExceeded)
return
}
writeError(w, r, err) writeError(w, r, err)
return return
} }
@@ -315,6 +315,67 @@ func TestMigrateRedeemBinding(t *testing.T) {
} }
} }
// TestMigrateRedeemOverQuota: a target whose quota cannot take the source's servers is
// refused with 403 migrate_quota_exceeded, and the refusal leaves everything as it was —
// no server moved, the source live, the migration still code_issued, nothing audited or
// mailed — so the same code redeems once an admin raises the quota.
func TestMigrateRedeemOverQuota(t *testing.T) {
const uuid = "88888888-8888-8888-8888-888888888888"
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
tgt := &Principal{UserID: "u2", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", Role: "user"})
repo.links[uuid] = "u1"
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
repo.migrateQuotaRefuse = map[string]bool{"u2": true}
mk, mailer, _ := migrateEnv(repo)
ehSrc := mk(src).ExternalHandler()
if w := startMigrate(t, mk(src).InternalHandler(), uuid); w.Code != http.StatusCreated {
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String())
}
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String())
}
w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader)
if w.Code != http.StatusCreated {
t.Fatalf("issue-code: %d (%s)", w.Code, w.Body.String())
}
mcode, _ := acctBody(t, w)["code"].(string)
audits, notices := len(repo.audits), len(mailer.notices)
ehTgt := mk(tgt).ExternalHandler()
w = do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "migrate_quota_exceeded" {
t.Fatalf("redeem over quota: code = %d body %s, want 403 migrate_quota_exceeded", w.Code, w.Body.String())
}
if repo.byName["alpha"].OwnerID != "u1" {
t.Fatalf("server moved on a refused redeem: owner=%s", repo.byName["alpha"].OwnerID)
}
if d, _ := repo.UserDetail(context.Background(), "u1"); d.DeletedAt != nil || d.Disabled {
t.Fatalf("source retired on a refused redeem: %+v", d)
}
if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "code_issued" {
t.Fatalf("migration after a refused redeem = %+v, want still code_issued", m)
}
if len(repo.audits) != audits || len(mailer.notices) != notices {
t.Fatalf("a refused redeem audited %v / mailed %q", repo.audits[audits:], mailer.notices[notices:])
}
delete(repo.migrateQuotaRefuse, "u2")
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("redeem once the quota fits: code = %d body %s, want 200", w.Code, w.Body.String())
}
if repo.byName["alpha"].OwnerID != "u2" {
t.Fatalf("server not moved once the quota fits: owner=%s", repo.byName["alpha"].OwnerID)
}
}
// TestMigrateGuards covers the input/state refusals: an unlinked UUID has no account to // TestMigrateGuards covers the input/state refusals: an unlinked UUID has no account to
// migrate; a code cannot be issued before confirmation; the target may be neither the // migrate; a code cannot be issued before confirmation; the target may be neither the
// source itself nor an unknown account. // source itself nor an unknown account.
+55 -6
View File
@@ -396,7 +396,7 @@ func (p *PGRepo) QuotaCheck(ctx context.Context, userID string, excludeName stri
return false, err return false, err
} }
return quotaAllows(maxServers, maxCPU, maxMem, maxStor, count, cpuSum, memSum, storSum, incoming), nil return quotaAllows(maxServers, maxCPU, maxMem, maxStor, count, cpuSum, memSum, storSum, 1, incoming), nil
} }
// UpdateServerResources updates the resource cache for a server after a spec // UpdateServerResources updates the resource cache for a server after a spec
@@ -498,7 +498,7 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
return false, err return false, err
} }
if !quotaAllows(maxServers, maxCPU, maxMem, maxStor, count, cpuSum, memSum, storSum, if !quotaAllows(maxServers, maxCPU, maxMem, maxStor, count, cpuSum, memSum, storSum,
ResourceSpec{CPUMilli: cpu, MemoryMB: mem, StorageMB: stor}) { 1, ResourceSpec{CPUMilli: cpu, MemoryMB: mem, StorageMB: stor}) {
return false, ErrQuotaExceeded return false, ErrQuotaExceeded
} }
@@ -526,13 +526,14 @@ func (p *PGRepo) ClaimServer(ctx context.Context, name, userID string) (bool, er
} }
// quotaAllows applies the four spec §9.3 caps to one per-owner aggregate plus // quotaAllows applies the four spec §9.3 caps to one per-owner aggregate plus
// the incoming spec. Shared by QuotaCheck (the advisory pre-check) and // incomingCount servers whose specs sum to incoming. Shared by QuotaCheck (the
// ClaimServer (the atomic gate) so the two can never drift. An invalid (NULL or // advisory pre-check), ClaimServer (the atomic gate) and RedeemMigration (a
// migration's servers arriving at once) so they can never drift. An invalid (NULL or
// missing) cap means unlimited for that dimension; storage is compared in MB // missing) cap means unlimited for that dimension; storage is compared in MB
// against max_storage_gb × 1024. // against max_storage_gb × 1024.
func quotaAllows(maxServers, maxCPU, maxMem, maxStor sql.NullInt64, func quotaAllows(maxServers, maxCPU, maxMem, maxStor sql.NullInt64,
count, cpuSum, memSum, storSum int64, incoming ResourceSpec) bool { count, cpuSum, memSum, storSum, incomingCount int64, incoming ResourceSpec) bool {
if maxServers.Valid && count >= maxServers.Int64 { if maxServers.Valid && count+incomingCount > maxServers.Int64 {
return false return false
} }
if maxCPU.Valid && cpuSum+int64(incoming.CPUMilli) > maxCPU.Int64 { if maxCPU.Valid && cpuSum+int64(incoming.CPUMilli) > maxCPU.Int64 {
@@ -2492,6 +2493,54 @@ func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash str
return "", nil, err return "", nil, err
} }
// Take both accounts' claim lanes, ClaimServer's lock, in id order so two
// migrations crossing between the same pair cannot deadlock. The target's makes
// the quota read below and the move one consistent decision against its own
// claims; the source's keeps a claim of its own from landing between the count
// of its servers and their move.
lanes := []string{sourceUserID, targetUserID}
if lanes[1] < lanes[0] {
lanes[0], lanes[1] = lanes[1], lanes[0]
}
for _, id := range lanes {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, id); err != nil {
return "", nil, err
}
}
// The target's four caps (spec §9.3) must hold with every server the source owns
// added in, as a claim of each would. Over any cap, nothing moves and the code
// stays unspent, so the target can have an admin raise its quota and redeem again
// before the code expires. A source that owns nothing moves nothing, and retires
// even into a target already over a cap.
var n, cpu, mem, stor int64
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(cached_cpu_milli), 0), COALESCE(SUM(cached_memory_mb), 0), COALESCE(SUM(cached_storage_mb), 0)
FROM servers WHERE owner_id = $1 AND deleted_at IS NULL`,
sourceUserID).Scan(&n, &cpu, &mem, &stor); err != nil {
return "", nil, err
}
if n > 0 {
var maxServers, maxCPU, maxMem, maxStor sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT max_servers, max_cpu_milli, max_memory_mb, max_storage_gb
FROM quotas WHERE user_id = $1`, targetUserID).Scan(
&maxServers, &maxCPU, &maxMem, &maxStor); err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", nil, err
}
var count, cpuSum, memSum, storSum int64
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(cached_cpu_milli), 0), COALESCE(SUM(cached_memory_mb), 0), COALESCE(SUM(cached_storage_mb), 0)
FROM servers WHERE owner_id = $1 AND deleted_at IS NULL`,
targetUserID).Scan(&count, &cpuSum, &memSum, &storSum); err != nil {
return "", nil, err
}
if !quotaAllows(maxServers, maxCPU, maxMem, maxStor, count, cpuSum, memSum, storSum,
n, ResourceSpec{CPUMilli: int(cpu), MemoryMB: int(mem), StorageMB: int(stor)}) {
return "", nil, ErrQuotaExceeded
}
}
// Re-point every server the source owns to the target, collecting the names for // Re-point every server the source owns to the target, collecting the names for
// the audit trail. Server ownership is the only thing that moves. // the audit trail. Server ownership is the only thing that moves.
rows, err := tx.QueryContext(ctx, rows, err := tx.QueryContext(ctx,
+6 -3
View File
@@ -825,9 +825,12 @@ type Repo interface {
// the migration 'redeemed' — all in one transaction. It returns the source user id // the migration 'redeemed' — all in one transaction. It returns the source user id
// and the moved server names for the audit trail. No matching or expired code, or a // and the moved server names for the audit trail. No matching or expired code, or a
// code whose named target is a different user → ErrLinkCodeInvalid (an intercepted // code whose named target is a different user → ErrLinkCodeInvalid (an intercepted
// code is useless to anyone but the named target). Server ownership is the only thing // code is useless to anyone but the named target). When the source owns servers, the
// moved — the mc_uuid link and web credentials stay with their accounts. now drives // target's quota must hold with all of them added in, checked under ClaimServer's
// expiry and the terminal timestamps. // lock for both accounts; over any cap → ErrQuotaExceeded, with nothing moved and the
// code left unspent. Server ownership is the only thing moved — the mc_uuid link and
// web credentials stay with their accounts. now drives expiry and the terminal
// timestamps.
RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (sourceUserID string, movedServers []string, err error) RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (sourceUserID string, movedServers []string, err error)
} }
+194 -2
View File
@@ -339,6 +339,12 @@ func TestMigrationUnderConcurrency(t *testing.T) {
// waitForLockWait polls until a statement starting with prefix is waiting on a lock. // waitForLockWait polls until a statement starting with prefix is waiting on a lock.
func waitForLockWait(t *testing.T, prefix string) { func waitForLockWait(t *testing.T, prefix string) {
t.Helper()
waitForLockWaiters(t, prefix, 1)
}
// waitForLockWaiters polls until want statements starting with prefix wait on a lock.
func waitForLockWaiters(t *testing.T, prefix string, want int) {
t.Helper() t.Helper()
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
@@ -348,12 +354,12 @@ func waitForLockWait(t *testing.T, prefix string) {
prefix).Scan(&n); err != nil { prefix).Scan(&n); err != nil {
t.Fatalf("read pg_stat_activity: %v", err) t.Fatalf("read pg_stat_activity: %v", err)
} }
if n > 0 { if n >= want {
return return
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
t.Fatalf("no statement %q waited on a lock within 5s", prefix) t.Fatalf("fewer than %d statements %q waited on a lock within 5s", want, prefix)
} }
// A restart that reaches the source while its redeem is in flight must not leave the // A restart that reaches the source while its redeem is in flight must not leave the
@@ -400,6 +406,192 @@ func TestStartMigrationBehindARedeem(t *testing.T) {
} }
} }
// untouched checks a refused redeem left the source as it was: its servers, the
// account and its session live, and the code still issued.
func untouched(t *testing.T, what, srcID, session string, servers ...string) {
t.Helper()
for _, s := range servers {
if owner := serverOwner(t, s); owner != srcID {
t.Fatalf("%s moved %s to %s", what, s, owner)
}
}
var live, sessionLive bool
if err := db.QueryRow(`SELECT NOT disabled AND deleted_at IS NULL FROM users WHERE id = $1`, srcID).Scan(&live); err != nil {
t.Fatalf("read source: %v", err)
}
if err := db.QueryRow(`SELECT revoked_at IS NULL FROM sessions WHERE token_hash = $1`, session).Scan(&sessionLive); err != nil {
t.Fatalf("read source session: %v", err)
}
if !live || !sessionLive {
t.Fatalf("%s: source live=%v, session live=%v; want both", what, live, sessionLive)
}
if m, err := repo.MigrationForSource(context.Background(), srcID); err != nil || m.State != "code_issued" {
t.Fatalf("%s: migration = %+v, %v; want still code_issued", what, m, err)
}
}
// A redeem moves the source's servers only when the target's four caps hold with all
// of them added in. Over any cap it changes nothing, and the same code redeems once
// the quota fits. Deleted servers count on neither side.
func TestMigrationRedeemWithinTargetQuota(t *testing.T) {
ctx := context.Background()
now := mustNow()
src := newUser(t, "user", "migq-src")
dst := newUser(t, "user", "migq-dst")
sfx := suffix(t)
a, b := "mqa-"+sfx, "mqb-"+sfx
seedOwnedServer(t, a, src.ID, false)
seedOwnedServer(t, b, src.ID, false)
seedOwnedServer(t, "mqgone-"+sfx, src.ID, true)
seedOwnedServer(t, "mqown-"+sfx, dst.ID, false)
mustExec(t, `UPDATE servers SET cached_storage_mb = 400 WHERE name IN ($1, $2)`, a, b)
mustExec(t, `UPDATE servers SET cached_storage_mb = 225 WHERE name = $1`, "mqown-"+sfx)
seedOwnedServer(t, "mqowngone-"+sfx, dst.ID, true)
session := newSession(t, src.ID, "migq-src", now.Add(time.Hour))
startToCode(t, src.ID, dst.ID, "h-quota", now, now.Add(10*time.Minute))
// With both servers the target would own 3 servers, 300 millicores, 384 MB of
// memory and 1025 MB of storage. Each cap one short of its figure refuses on its
// own; storage is capped in whole GB, and 1 GB is 1024 MB.
n := func(v int) *int { return &v }
for _, c := range []struct {
name string
q api.QuotaInput
}{
{"servers", api.QuotaInput{MaxServers: n(2)}},
{"cpu", api.QuotaInput{MaxCPUMilli: n(299)}},
{"memory", api.QuotaInput{MaxMemoryMB: n(383)}},
{"storage", api.QuotaInput{MaxStorageGB: n(1)}},
} {
if _, err := repo.SetQuotas(ctx, dst.ID, c.q, "pgint"); err != nil {
t.Fatalf("SetQuotas(%s): %v", c.name, err)
}
if _, _, err := repo.RedeemMigration(ctx, dst.ID, "h-quota", now); !errors.Is(err, api.ErrQuotaExceeded) {
t.Fatalf("redeem over the %s cap = %v, want ErrQuotaExceeded", c.name, err)
}
untouched(t, "a redeem over the "+c.name+" cap", src.ID, session, a, b)
}
// Every cap at its figure fits, storage at the next whole GB.
if _, err := repo.SetQuotas(ctx, dst.ID, api.QuotaInput{MaxServers: n(3), MaxCPUMilli: n(300), MaxMemoryMB: n(384), MaxStorageGB: n(2)}, "pgint"); err != nil {
t.Fatalf("SetQuotas(fits): %v", err)
}
_, moved, err := repo.RedeemMigration(ctx, dst.ID, "h-quota", now)
sort.Strings(moved)
if err != nil || strings.Join(moved, ",") != a+","+b {
t.Fatalf("redeem once the quota fits = %v, %v; want [%s %s]", moved, err, a, b)
}
if o := serverOwner(t, a); o != dst.ID {
t.Fatalf("owner of %s after the redeem = %s, want the target", a, o)
}
// A source that owns nothing brings nothing, so it retires even into a target
// already over a cap.
empty := newUser(t, "user", "migq-empty")
startToCode(t, empty.ID, dst.ID, "h-empty", now, now.Add(10*time.Minute))
if _, err := repo.SetQuotas(ctx, dst.ID, api.QuotaInput{MaxServers: n(0)}, "pgint"); err != nil {
t.Fatalf("SetQuotas(0): %v", err)
}
if _, moved, err := repo.RedeemMigration(ctx, dst.ID, "h-empty", now); err != nil || len(moved) != 0 {
t.Fatalf("redeem of a source with no servers = %v, %v; want nothing moved", moved, err)
}
var retired bool
if err := db.QueryRow(`SELECT disabled AND deleted_at IS NOT NULL FROM users WHERE id = $1`, empty.ID).Scan(&retired); err != nil || !retired {
t.Fatalf("empty source after its redeem: retired=%v, %v; want retired", retired, err)
}
}
// holdLane takes a user's claim lane (ClaimServer's advisory lock) in a transaction the
// caller ends.
func holdLane(t *testing.T, userIDs ...string) *sql.Tx {
t.Helper()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
for _, id := range userIDs {
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, id); err != nil {
tx.Rollback() //nolint:errcheck // the test is failing anyway
t.Fatalf("hold lane: %v", err)
}
}
return tx
}
// A redeem decides under both accounts' claim lanes: a server either account claims
// while the redeem waits is counted against the target's quota. The target holds one
// server of a two-server cap and the source one, so either claim tips it over.
func TestMigrationRedeemWaitsForClaims(t *testing.T) {
ctx := context.Background()
now := mustNow()
n := func(v int) *int { return &v }
for _, lane := range []string{"target", "source"} {
src := newUser(t, "user", "migw-src")
dst := newUser(t, "user", "migw-dst")
sfx := suffix(t)
mine, claimed := "mwa-"+sfx, "mwc-"+sfx
seedOwnedServer(t, mine, src.ID, false)
seedOwnedServer(t, "mwown-"+sfx, dst.ID, false)
mustExec(t, `INSERT INTO servers (name, cached_cpu_milli, cached_memory_mb, cached_storage_mb) VALUES ($1, 100, 128, 1)`, claimed)
if _, err := repo.SetQuotas(ctx, dst.ID, api.QuotaInput{MaxServers: n(2)}, "pgint"); err != nil {
t.Fatalf("SetQuotas: %v", err)
}
session := newSession(t, src.ID, "migw-src", now.Add(time.Hour))
startToCode(t, src.ID, dst.ID, "h-wait", now, now.Add(10*time.Minute))
claimer := dst.ID
if lane == "source" {
claimer = src.ID
}
hold := holdLane(t, claimer)
redeemed := make(chan error, 1)
go func() {
_, _, err := repo.RedeemMigration(ctx, dst.ID, "h-wait", now)
redeemed <- err
}()
waitForLockWait(t, "SELECT pg_advisory_xact_lock")
if _, err := hold.Exec(`UPDATE servers SET owner_id = $2 WHERE name = $1`, claimed, claimer); err != nil {
t.Fatalf("%s claim: %v", lane, err)
}
if err := hold.Commit(); err != nil {
t.Fatalf("%s claim commit: %v", lane, err)
}
if err := <-redeemed; !errors.Is(err, api.ErrQuotaExceeded) {
t.Fatalf("redeem behind a %s claim = %v, want ErrQuotaExceeded", lane, err)
}
untouched(t, "a redeem behind a "+lane+" claim", src.ID, session, mine)
}
}
// Two migrations crossing between one pair of accounts take their lanes in one order,
// so neither deadlocks the other: both wait behind a holder of both lanes and, once it
// lets go, both redeem.
func TestCrossingMigrationsDoNotDeadlock(t *testing.T) {
ctx := context.Background()
now := mustNow()
x := newUser(t, "user", "migx-a")
y := newUser(t, "user", "migx-b")
sfx := suffix(t)
seedOwnedServer(t, "mxa-"+sfx, x.ID, false)
seedOwnedServer(t, "mxb-"+sfx, y.ID, false)
startToCode(t, x.ID, y.ID, "h-x", now, now.Add(10*time.Minute))
startToCode(t, y.ID, x.ID, "h-y", now, now.Add(10*time.Minute))
hold := holdLane(t, x.ID, y.ID)
errs := make(chan error, 2)
go func() { _, _, err := repo.RedeemMigration(ctx, y.ID, "h-x", now); errs <- err }()
go func() { _, _, err := repo.RedeemMigration(ctx, x.ID, "h-y", now); errs <- err }()
waitForLockWaiters(t, "SELECT pg_advisory_xact_lock", 2)
if err := hold.Commit(); err != nil {
t.Fatalf("release lanes: %v", err)
}
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatalf("crossing redeem: %v", err)
}
}
}
// ---- setup tokens (migration 0012) -------------------------------------------------- // ---- setup tokens (migration 0012) --------------------------------------------------
// A setup token redeems once, never at or after expiry, and racing redeems of one // A setup token redeems once, never at or after expiry, and racing redeems of one
@@ -79,6 +79,7 @@
"account_retired": "This account has been retired — sign in with the account it was migrated to.", "account_retired": "This account has been retired — sign in with the account it was migrated to.",
"no_migration": "There is no migration in progress.", "no_migration": "There is no migration in progress.",
"not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.", "not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.",
"migrate_quota_exceeded": "The servers this migration brings over don't fit your quota. Ask an admin to raise it, then redeem the same code again before it expires.",
"invalid_quota": "A quota must be a whole number from 0 to 2147483647. Leave it empty for unlimited.", "invalid_quota": "A quota must be a whole number from 0 to 2147483647. Leave it empty for unlimited.",
"already_confirmed": "This migration has already been confirmed.", "already_confirmed": "This migration has already been confirmed.",
"invalid_target": "The migration target must be a different account.", "invalid_target": "The migration target must be a different account.",
@@ -79,6 +79,7 @@
"account_retired": "该账户已退役——请使用迁移后的账户登录。", "account_retired": "该账户已退役——请使用迁移后的账户登录。",
"no_migration": "当前没有进行中的迁移。", "no_migration": "当前没有进行中的迁移。",
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。", "not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
"migrate_quota_exceeded": "这次迁移带来的服务器超出了你的配额。请管理员调高配额后,在迁移码过期前用同一个码再兑换一次。",
"invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。", "invalid_quota": "配额要填 0 到 2147483647 之间的整数,不限就留空。",
"already_confirmed": "该迁移已经确认过了。", "already_confirmed": "该迁移已经确认过了。",
"invalid_target": "迁移目标账户不能与来源账户相同。", "invalid_target": "迁移目标账户不能与来源账户相同。",
+6
View File
@@ -218,6 +218,12 @@ describe("account migration wire shapes", () => {
code: "MIGR-1234", code: "MIGR-1234",
}); });
}); });
it("says a redeem over quota can be retried with the same code once the quota fits", () => {
expect(humanizeError({ status: 403, code: "migrate_quota_exceeded" })).toBe(
"The servers this migration brings over don't fit your quota. Ask an admin to raise it, then redeem the same code again before it expires.",
);
});
}); });
// Pin the GET /fleet wire shape (the SysAdmin cockpit's read). It is the ONLY // Pin the GET /fleet wire shape (the SysAdmin cockpit's read). It is the ONLY
+2
View File
@@ -1184,6 +1184,8 @@ export function humanizeError(e: unknown): string {
return t("no_migration"); return t("no_migration");
case "not_confirmed": case "not_confirmed":
return t("not_confirmed"); return t("not_confirmed");
case "migrate_quota_exceeded":
return t("migrate_quota_exceeded");
case "invalid_quota": case "invalid_quota":
return t("invalid_quota"); return t("invalid_quota");
case "already_confirmed": case "already_confirmed":
+9
View File
@@ -7685,6 +7685,15 @@ export interface operations {
}; };
}; };
401: components["responses"]["Unauthorized"]; 401: components["responses"]["Unauthorized"];
/** @description The source's servers would push the caller over a quota cap (migrate_quota_exceeded). Nothing moved and the code is unspent; it redeems once the quota fits, until it expires. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
}; };
}; };
mySubmissions: { mySubmissions: {