fix(provision): replace duplicate accounts before closure probe

This commit is contained in:
phamnazage-jpg
2026-05-21 14:19:41 +08:00
parent 95cdb490d2
commit 543f46562f
4 changed files with 339 additions and 9 deletions

View File

@@ -76,9 +76,10 @@ type hostAdapter interface {
}
type resolvedManagedResources struct {
Group sub2api.GroupRef
Channel sub2api.ChannelRef
Plan *sub2api.PlanRef
Group sub2api.GroupRef
Channel sub2api.ChannelRef
Plan *sub2api.PlanRef
Accounts []sub2api.NamedResource
CreatedGroup bool
CreatedChannel bool
@@ -160,6 +161,11 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
failedAccounts++
}
}
if failedAccounts == 0 {
if err := deleteNamedAccounts(ctx, s.host, resources.Accounts); err != nil {
return failOrDegrade(report, req.Mode, fmt.Errorf("cleanup existing accounts: %w", err))
}
}
if failedAccounts > 0 && req.Mode == ImportModeStrict {
report.BatchStatus = BatchStatusFailed
report.ProviderStatus = ProviderStatusFailed
@@ -201,15 +207,16 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
func (s *ImportService) ensureManagedResources(ctx context.Context, provider pack.ProviderManifest, accessMode string) (resolvedManagedResources, error) {
names := SuggestResourceNamesForMode(provider, accessMode)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,
PlanName: names.Plan,
GroupName: names.Group,
ChannelName: names.Channel,
PlanName: names.Plan,
AccountNamePrefix: SuggestAccountNamePrefix(provider),
})
if err != nil {
return resolvedManagedResources{}, fmt.Errorf("list managed resources: %w", err)
}
result := resolvedManagedResources{}
result := resolvedManagedResources{Accounts: append([]sub2api.NamedResource(nil), snapshot.Accounts...)}
group, created, err := ensureGroup(ctx, s.host, snapshot.Groups, provider, accessMode, names.Group)
if err != nil {
return resolvedManagedResources{}, fmt.Errorf("ensure group: %w", err)
@@ -361,6 +368,20 @@ func hasModel(models []sub2api.AccountModel, target string) bool {
return false
}
func deleteNamedAccounts(ctx context.Context, host hostAdapter, accounts []sub2api.NamedResource) error {
var errs []error
for index := len(accounts) - 1; index >= 0; index-- {
accountID := strings.TrimSpace(accounts[index].ID)
if accountID == "" {
continue
}
if err := host.DeleteAccount(ctx, accountID); err != nil {
errs = append(errs, fmt.Errorf("delete stale account %s: %w", accountID, err))
}
}
return errors.Join(errs...)
}
type managedResourceRollback struct {
host hostAdapter
groupID string

View File

@@ -294,6 +294,74 @@ func TestImportReconcilesExistingChannelConfiguration(t *testing.T) {
}
}
func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_new_1": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_new_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}, {ID: "account_old_2", Name: "deepseek-02"}},
},
}
_, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if host.listManagedReq.AccountNamePrefix != "deepseek-" {
t.Fatalf("AccountNamePrefix = %q, want %q", host.listManagedReq.AccountNamePrefix, "deepseek-")
}
wantDeleted := []string{"account:account_old_2", "account:account_old_1"}
if !reflect.DeepEqual(host.deletedResources, wantDeleted) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, wantDeleted)
}
if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway"}) {
t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence)
}
}
func TestImportKeepsExistingAccountsWhenReplacementValidationFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_new_1", Name: "deepseek-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_new_1": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_new_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
managedSnapshot: sub2api.ManagedResourceSnapshot{
Accounts: []sub2api.NamedResource{{ID: "account_old_1", Name: "deepseek-01"}},
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusPartial {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial)
}
if len(host.deletedResources) != 0 {
t.Fatalf("deleted resources = %#v, want no stale-account cleanup when replacement validation fails", host.deletedResources)
}
}
type fakeHostAdapter struct {
batchAccounts []sub2api.AccountRef
batchCreateReq sub2api.BatchCreateAccountsRequest
@@ -317,6 +385,7 @@ type fakeHostAdapter struct {
createChannelReq sub2api.CreateChannelRequest
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
callSequence []string
}
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
@@ -371,6 +440,7 @@ func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, req sub2api.Bat
return f.batchAccounts, nil
}
func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) error {
f.callSequence = append(f.callSequence, "deleteAccount:"+accountID)
f.deletedResources = append(f.deletedResources, "account:"+accountID)
return nil
}
@@ -399,6 +469,7 @@ func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.Assi
return sub2api.SubscriptionRef{ID: "subscription_1"}, nil
}
func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) {
f.callSequence = append(f.callSequence, "gateway")
f.gatewayProbe = req
if f.gatewayErr != nil {
return sub2api.GatewayAccessResult{}, f.gatewayErr

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
require_var() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "missing required env: $name" >&2
exit 1
fi
}
json_has_model() {
local file="$1"
local model="$2"
python3 - "$file" "$model" <<'PY'
import json, pathlib, sys
path = pathlib.Path(sys.argv[1])
model = sys.argv[2].strip()
obj = json.loads(path.read_text(encoding='utf-8'))
for item in obj.get('data', []):
if str(item.get('id', '')).strip() == model:
print('true')
raise SystemExit(0)
print('false')
PY
}
status_from_headers() {
local file="$1"
python3 - "$file" <<'PY'
import pathlib, re, sys
text = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
for line in text.splitlines():
m = re.match(r'^HTTP/\S+\s+(\d{3})\b', line.strip())
if m:
print(m.group(1))
raise SystemExit(0)
print('0')
PY
}
content_type_from_headers() {
local file="$1"
python3 - "$file" <<'PY'
import pathlib, sys
text = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
for line in text.splitlines():
if ':' not in line:
continue
k, v = line.split(':', 1)
if k.strip().lower() == 'content-type':
print(v.strip())
raise SystemExit(0)
print('')
PY
}
require_var ARTIFACT_DIR
require_var HOST_BASE
require_var HOST_MANAGED_KEY
require_var UPSTREAM_BASE
require_var UPSTREAM_API_KEY
MODEL="${MODEL:-deepseek-v4-flash}"
PROMPT="${PROMPT:-ping}"
ARTIFACT_DIR="${ARTIFACT_DIR%/}"
mkdir -p "$ARTIFACT_DIR"
host_models_headers="$ARTIFACT_DIR/01-host-models.headers.txt"
host_models_body="$ARTIFACT_DIR/02-host-models.body.json"
host_chat_headers="$ARTIFACT_DIR/03-host-chat.headers.txt"
host_chat_body="$ARTIFACT_DIR/04-host-chat.body.json"
upstream_chat_headers="$ARTIFACT_DIR/05-upstream-chat.headers.txt"
upstream_chat_body="$ARTIFACT_DIR/06-upstream-chat.body.txt"
summary_file="$ARTIFACT_DIR/summary.json"
chat_payload="$(python3 - "$MODEL" "$PROMPT" <<'PY'
import json, sys
print(json.dumps({
'model': sys.argv[1],
'messages': [{'role': 'user', 'content': sys.argv[2]}],
'max_tokens': 8,
'temperature': 0,
}, ensure_ascii=False))
PY
)"
curl -sS -D "$host_models_headers" -o "$host_models_body" \
-H "Authorization: Bearer $HOST_MANAGED_KEY" \
"${HOST_BASE%/}/v1/models"
curl -sS -D "$host_chat_headers" -o "$host_chat_body" \
-H "Authorization: Bearer $HOST_MANAGED_KEY" \
-H 'Content-Type: application/json' \
"${HOST_BASE%/}/v1/chat/completions" \
-d "$chat_payload"
curl -sS -D "$upstream_chat_headers" -o "$upstream_chat_body" \
-H "Authorization: Bearer $UPSTREAM_API_KEY" \
-H 'Content-Type: application/json' \
"${UPSTREAM_BASE%/}/chat/completions" \
-d "$chat_payload"
host_models_status="$(status_from_headers "$host_models_headers")"
host_chat_status="$(status_from_headers "$host_chat_headers")"
upstream_chat_status="$(status_from_headers "$upstream_chat_headers")"
host_has_expected_model="$(json_has_model "$host_models_body" "$MODEL")"
upstream_content_type="$(content_type_from_headers "$upstream_chat_headers")"
python3 - "$summary_file" "$host_models_status" "$host_has_expected_model" "$host_chat_status" "$upstream_chat_status" "$upstream_content_type" "$host_chat_body" "$upstream_chat_body" <<'PY'
import json, pathlib, sys
summary_path = pathlib.Path(sys.argv[1])
host_models_status = int(sys.argv[2])
host_has_expected_model = sys.argv[3].strip().lower() == 'true'
host_chat_status = int(sys.argv[4])
upstream_chat_status = int(sys.argv[5])
upstream_content_type = sys.argv[6].strip()
host_chat_body = pathlib.Path(sys.argv[7]).read_text(encoding='utf-8').strip()
upstream_chat_body = pathlib.Path(sys.argv[8]).read_text(encoding='utf-8').strip()
classification = 'unknown'
if host_models_status == 200 and host_has_expected_model and host_chat_status == 502 and upstream_chat_status == 200:
classification = 'host_compatibility_gap'
elif host_models_status == 200 and host_has_expected_model and upstream_chat_status == 403 and 'insufficient_user_quota' in upstream_chat_body:
classification = 'upstream_key_quota_issue'
summary = {
'host_models_status': host_models_status,
'host_has_expected_model': host_has_expected_model,
'host_chat_status': host_chat_status,
'upstream_chat_status': upstream_chat_status,
'upstream_chat_content_type': upstream_content_type,
'classification': classification,
'host_chat_body': host_chat_body,
'upstream_chat_body_preview': upstream_chat_body[:400],
}
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(summary, ensure_ascii=False, indent=2))
PY

View File

@@ -46,12 +46,14 @@ run_test_build_subscription_access_prep_sql() {
}
run_test_real_host_acceptance_after_import_hook() {
local tmpdir fakebin artifact_dir hook_file
local tmpdir fakebin artifact_dir hook_file guide_file stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
fakebin="$tmpdir/bin"
artifact_dir="$tmpdir/artifacts"
hook_file="$artifact_dir/hook.txt"
guide_file="$artifact_dir/00-artifact-guide.txt"
stdout_file="$tmpdir/real_host_acceptance.stdout.txt"
mkdir -p "$fakebin"
cat > "$fakebin/curl" <<'EOF'
@@ -133,13 +135,111 @@ EOF
SUBSCRIPTION_USERS="42" \
SKIP_ROLLBACK="1" \
AFTER_IMPORT_HOOK_COMMAND='printf "%s\n" "$BATCH_ID:$BATCH_DETAIL_FILE:$ACCESS_MODE" > "$ARTIFACT_DIR/hook.txt"' \
"$ROOT_DIR/scripts/real_host_acceptance.sh" >/dev/null
"$ROOT_DIR/scripts/real_host_acceptance.sh" >"$stdout_file"
[[ -f "$hook_file" ]] || fail "after-import hook did not create $hook_file"
[[ -f "$guide_file" ]] || fail "artifact guide was not created"
local hook_contents
hook_contents="$(cat "$hook_file")"
assert_contains "$hook_contents" "123:"
assert_contains "$hook_contents" "05a-batch-detail-pre-access.json:subscription"
local guide_contents stdout_contents
guide_contents="$(cat "$guide_file")"
stdout_contents="$(cat "$stdout_file")"
assert_contains "$guide_contents" "清单 4必须分层留证据不可混用"
assert_contains "$guide_contents" "/api/v1/admin/accounts/:id/models 正确 ≠ /v1/models 正确"
assert_contains "$guide_contents" "/v1/models 正确 ≠ /v1/chat/completions 正确"
assert_contains "$stdout_contents" "artifact guide: $artifact_dir/00-artifact-guide.txt"
assert_contains "$stdout_contents" "checklist layered evidence: see 05b-after-import-hook.stdout.txt / 05b-after-import-hook.stderr.txt"
}
run_test_check_deepseek_completion_split() {
local tmpdir fakebin artifact_dir summary_file stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
fakebin="$tmpdir/bin"
artifact_dir="$tmpdir/artifacts"
summary_file="$artifact_dir/summary.json"
stdout_file="$tmpdir/check_deepseek_completion_split.stdout.txt"
mkdir -p "$fakebin" "$artifact_dir"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
headers_file=""
body_file=""
url=""
prev=""
for arg in "$@"; do
case "$prev" in
-D)
headers_file="$arg"
prev=""
continue
;;
-o)
body_file="$arg"
prev=""
continue
;;
esac
case "$arg" in
-D|-o)
prev="$arg"
continue
;;
http://*|https://*)
url="$arg"
;;
esac
done
[[ -n "$headers_file" && -n "$body_file" && -n "$url" ]] || {
echo "missing curl capture args: $*" >&2
exit 1
}
case "$url" in
http://host.example.com/v1/models)
printf '%s
Content-Type: application/json
' 'HTTP/1.1 200 OK' > "$headers_file"
printf '%s
' '{"data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}' > "$body_file"
;;
http://host.example.com/v1/chat/completions)
printf '%s
Content-Type: application/json
' 'HTTP/1.1 502 Bad Gateway' > "$headers_file"
printf '%s
' '{"error":{"message":"Upstream service temporarily unavailable","type":"upstream_error"}}' > "$body_file"
;;
https://upstream.example.com/v1/chat/completions)
printf '%s
Content-Type: text/event-stream
' 'HTTP/1.1 200 OK' > "$headers_file"
printf '%s
' 'data: {"choices":[{"delta":{"content":"pong"}}]}' > "$body_file"
;;
*)
echo "unexpected curl url: $url" >&2
exit 1
;;
esac
EOF
chmod +x "$fakebin/curl"
PATH="$fakebin:$PATH" ARTIFACT_DIR="$artifact_dir" HOST_BASE="http://host.example.com" HOST_MANAGED_KEY="managed-key" UPSTREAM_BASE="https://upstream.example.com/v1" UPSTREAM_API_KEY="upstream-key" MODEL="deepseek-v4-flash" bash "$ROOT_DIR/scripts/check_deepseek_completion_split.sh" >"$stdout_file"
[[ -f "$summary_file" ]] || fail "missing summary file: $summary_file"
local summary stdout_contents
summary="$(cat "$summary_file")"
stdout_contents="$(cat "$stdout_file")"
assert_contains "$summary" '"classification": "host_compatibility_gap"'
assert_contains "$summary" '"host_models_status": 200'
assert_contains "$summary" '"host_chat_status": 502'
assert_contains "$summary" '"upstream_chat_status": 200'
assert_contains "$summary" '"upstream_chat_content_type": "text/event-stream"'
assert_contains "$stdout_contents" '"classification": "host_compatibility_gap"'
}
run_test_import_remote43_provider_subscription_prep() {
@@ -387,6 +487,7 @@ EOF
run_test_build_subscription_access_prep_sql
run_test_real_host_acceptance_after_import_hook
run_test_check_deepseek_completion_split
run_test_import_remote43_provider_subscription_prep
echo "PASS: real host script regression checks"