fix: harden deepseek official remote43 import closure
This commit is contained in:
@@ -128,6 +128,9 @@ func isAdvisoryAccountProbeFailure(probe sub2api.ProbeResult) bool {
|
||||
if strings.Contains(message, "api returned 403: forbidden") {
|
||||
return true
|
||||
}
|
||||
if message == "api returned 404:" {
|
||||
return true
|
||||
}
|
||||
|
||||
if !strings.Contains(message, "responses api") {
|
||||
return false
|
||||
@@ -226,6 +229,11 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
|
||||
return report, fmt.Errorf("batch create accounts: %w", err)
|
||||
}
|
||||
rollback.AddAccounts(accounts)
|
||||
if shouldPreemptivelyDisableOpenAIResponses(req.Provider) {
|
||||
if err := access.RepairOpenAIResponsesCapability(ctx, s.host, accountRefIDs(accounts)); err != nil {
|
||||
return failOrDegrade(report, req.Mode, fmt.Errorf("preemptively repair openai responses capability: %w", err))
|
||||
}
|
||||
}
|
||||
for _, account := range accounts {
|
||||
probe, err := s.host.TestAccount(ctx, account.ID, req.Provider.SmokeTestModel)
|
||||
if err != nil {
|
||||
@@ -256,12 +264,6 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I
|
||||
report.AccessStatus = AccessStatusBroken
|
||||
return report, fmt.Errorf("strict import failed: %d account(s) did not pass smoke validation", failedAccounts)
|
||||
}
|
||||
if shouldPreemptivelyDisableOpenAIResponses(req.Provider) {
|
||||
if err := access.RepairOpenAIResponsesCapability(ctx, s.host, importedAccountIDs(report.Accounts)); err != nil {
|
||||
return failOrDegrade(report, req.Mode, fmt.Errorf("preemptively repair openai responses capability: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
closureService := access.NewService(s.host)
|
||||
gateway, err := closureService.Close(ctx, access.ClosureRequest{
|
||||
Mode: req.Access.Mode,
|
||||
@@ -344,6 +346,16 @@ func importedAccountIDs(accounts []AccountImportResult) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func accountRefIDs(accounts []sub2api.AccountRef) []string {
|
||||
ids := make([]string, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if trimmed := strings.TrimSpace(account.ID); trimmed != "" {
|
||||
ids = append(ids, trimmed)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func importedAccountsSuspectResponsesCapabilityMismatch(accounts []AccountImportResult) bool {
|
||||
for _, account := range accounts {
|
||||
if !account.SmokeModelSeen {
|
||||
|
||||
@@ -291,6 +291,53 @@ func TestImportServiceTreatsForbiddenProbeRaceAsAdvisoryWhenGatewaySucceeds(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportServiceTreatsBare404ProbeAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
|
||||
testResults: map[string]sub2api.ProbeResult{
|
||||
"account_1": {
|
||||
OK: false,
|
||||
Status: "failed",
|
||||
Message: "API returned 404:",
|
||||
},
|
||||
},
|
||||
models: map[string][]sub2api.AccountModel{
|
||||
"account_1": {{ID: "deepseek-chat"}},
|
||||
},
|
||||
gatewayResult: sub2api.GatewayAccessResult{
|
||||
OK: true,
|
||||
StatusCode: 200,
|
||||
HasExpectedModel: true,
|
||||
Models: []string{"deepseek-chat"},
|
||||
CompletionOK: true,
|
||||
CompletionStatus: 200,
|
||||
CompletionType: "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
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 != BatchStatusSucceeded {
|
||||
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
|
||||
}
|
||||
if report.ProviderStatus != ProviderStatusActive {
|
||||
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
|
||||
}
|
||||
if report.AccessStatus != AccessStatusSelfServiceReady {
|
||||
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
|
||||
}
|
||||
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
|
||||
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportServiceRetriesTransientGatewayCompletionFailure(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
|
||||
@@ -596,6 +643,52 @@ func sampleChatOnlyProviderManifest() pack.ProviderManifest {
|
||||
return provider
|
||||
}
|
||||
|
||||
func TestImportServicePreemptiveResponsesRepairHappensBeforeAccountProbe(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
|
||||
testResults: map[string]sub2api.ProbeResult{
|
||||
"account_1": {OK: true, Status: "passed"},
|
||||
},
|
||||
models: map[string][]sub2api.AccountModel{
|
||||
"account_1": {{ID: "kimi-k2.6"}},
|
||||
},
|
||||
gatewayResult: sub2api.GatewayAccessResult{
|
||||
OK: true,
|
||||
StatusCode: 200,
|
||||
HasExpectedModel: true,
|
||||
Models: []string{"kimi-k2.6"},
|
||||
CompletionOK: true,
|
||||
CompletionStatus: 200,
|
||||
},
|
||||
}
|
||||
|
||||
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
|
||||
Provider: sampleChatOnlyProviderManifest(),
|
||||
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 != BatchStatusSucceeded {
|
||||
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
|
||||
}
|
||||
wantSequence := []string{"disable_responses", "clear_temp_unschedulable", "testAccount:account_1", "gateway", "completion"}
|
||||
if !reflect.DeepEqual(host.callSequence, wantSequence) {
|
||||
t.Fatalf("callSequence = %v, want %v", host.callSequence, wantSequence)
|
||||
}
|
||||
if host.disableResponsesCalls != 1 {
|
||||
t.Fatalf("disable responses calls = %d, want 1", host.disableResponsesCalls)
|
||||
}
|
||||
if len(host.disabledResponsesAccountIDs) != 1 || host.disabledResponsesAccountIDs[0] != "account_1" {
|
||||
t.Fatalf("disabled responses account ids = %v, want [account_1]", host.disabledResponsesAccountIDs)
|
||||
}
|
||||
if host.clearTempUnschedulableCalls != 1 {
|
||||
t.Fatalf("clear temp unschedulable calls = %d, want 1", host.clearTempUnschedulableCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportReconcilesExistingChannelConfiguration(t *testing.T) {
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}},
|
||||
@@ -666,7 +759,7 @@ func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T)
|
||||
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", "completion"}) {
|
||||
if !reflect.DeepEqual(host.callSequence, []string{"testAccount:account_new_1", "deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway", "completion"}) {
|
||||
t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence)
|
||||
}
|
||||
}
|
||||
@@ -810,6 +903,7 @@ func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) err
|
||||
return nil
|
||||
}
|
||||
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID, modelID string) (sub2api.ProbeResult, error) {
|
||||
f.callSequence = append(f.callSequence, "testAccount:"+accountID)
|
||||
if f.testedModels == nil {
|
||||
f.testedModels = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ func Open(ctx context.Context, dsn string) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
// SQLite only tolerates a single writer at a time. Pin the pool to one
|
||||
// connection so HTTP flows that chain several writes (for example host
|
||||
// probe refresh + import persistence) do not self-deadlock into SQLITE_BUSY.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
@@ -15,6 +15,24 @@ func TestOpenClose(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPinsSingleSQLiteConnection(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "single-conn.db")
|
||||
store, err := Open(context.Background(), "file:"+filepath.ToSlash(dbPath)+"?_busy_timeout=5000")
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stats := store.SQLDB().Stats()
|
||||
if stats.MaxOpenConnections != 1 {
|
||||
t.Fatalf("MaxOpenConnections = %d, want 1", stats.MaxOpenConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenInvalidDSN(t *testing.T) {
|
||||
_, err := Open(context.Background(), "file:/nonexistent/dir/test.db?_pragma=foreign_keys(0)")
|
||||
if err == nil {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- `kimi-k2-5-official.json`
|
||||
- `kimi-k2-thinking-official.json`
|
||||
- `minimax-m2-7-official.json`
|
||||
- `minimax-53hk.json`
|
||||
- `step-3-5-flash-official.json`
|
||||
|
||||
对应的 `provider_id` 与首选模型如下:
|
||||
@@ -45,6 +46,8 @@
|
||||
- `kimi-k2-thinking`
|
||||
- `minimax-m2-7-official`
|
||||
- `MiniMax-M2.7-highspeed`, `MiniMax-M2.5-highspeed`
|
||||
- `minimax-53hk`
|
||||
- `MiniMax-M2.7-highspeed`, `MiniMax-M2.5-highspeed`
|
||||
- `step-3-5-flash-official`
|
||||
- `step-3.5-flash`
|
||||
|
||||
@@ -81,3 +84,4 @@ go run ./cmd/cli apply-host-overlay \
|
||||
- `qwen.json`
|
||||
- `glm.json`
|
||||
- `minimax.json`
|
||||
- `minimax-53hk.json`
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
303bfaea55379f4a4329bd7f720b9c692981dd486a31176b6b93731f56b18cbc providers/minimax-m2-7-official.json
|
||||
95f1ffc6f764b221b260fd62d649a2f91d136b8e7cf627ac65600cf92c015fd9 pack.json
|
||||
8158ef1427d59ed10b9f1f91924b8100f5666eeb50d66799be59e1a3fd9fff02 providers/deepseek-chat-official.json
|
||||
06373a0d6c0337b2cc5e3b4b9269fdd0399b782e092a77ccbcce6d36f7b3354f providers/deepseek-reasoner-official.json
|
||||
5d33003fb6fefaf2dbb8445be7c18cc817d266215bc996ae91b30fbed5a98e7b providers/deepseek.json
|
||||
391c3aa02b669eb591f2a3dda45500d66e6a519f07a05c158c8de6be69313e54 providers/glm-4-7-official.json
|
||||
fe8c84b1b316e8c2e9eb60411383d04bfc0f0ff0c029db3b7d83aa976a6f6d54 providers/glm-5-1-official.json
|
||||
2db47989a9715464a34b00f7e322ceb1396f96617ddcfa7dee5bd3e7b262c17d providers/kimi-a7m.json
|
||||
51c8ab51666002c40f0f91744fbb5039d4a97c8c4bbb0b67c5936a5b5f8bf0bc providers/kimi-k2-5-official.json
|
||||
65e3a1a5e56889ddb0474a3b55294aceb6920fa72dcf6f2c56d3199462daa4cf providers/kimi-k2-thinking-official.json
|
||||
2da4777edeaa58db5cf79ecfba6cdac4dd3349bd8ff8d804c2e64ee42de4a377 providers/minimax-53hk.json
|
||||
303bfaea55379f4a4329bd7f720b9c692981dd486a31176b6b93731f56b18cbc providers/minimax-m2-7-official.json
|
||||
5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json
|
||||
fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json
|
||||
437028a75810bd970915ec31db1b415d384052143e404fcaa66e335ef2865ae3 providers/qwen-coder-official.json
|
||||
4817f3faa54dd7fb20cf25a2ded0e702286d55ad829e686c125b70fc55cd52d6 providers/qwen-official.json
|
||||
51c8ab51666002c40f0f91744fbb5039d4a97c8c4bbb0b67c5936a5b5f8bf0bc providers/kimi-k2-5-official.json
|
||||
5d33003fb6fefaf2dbb8445be7c18cc817d266215bc996ae91b30fbed5a98e7b providers/deepseek.json
|
||||
5dcc402daddacce6dcaceb1501020342f1b1121fbffe9097ede4d5aae072f84e providers/minimax.json
|
||||
65e3a1a5e56889ddb0474a3b55294aceb6920fa72dcf6f2c56d3199462daa4cf providers/kimi-k2-thinking-official.json
|
||||
a39de44fa68fcb5ee9c3ef38ed3bd5c30acd23cacd2f618d670de0bf9e7096e3 providers/deepseek-reasoner-official.json
|
||||
2db47989a9715464a34b00f7e322ceb1396f96617ddcfa7dee5bd3e7b262c17d providers/kimi-a7m.json
|
||||
eda16afc83e12055d3a41b5e37fd0923d3741b66da5af780bcea53ff34fa130e providers/step-3-5-flash-official.json
|
||||
584991c1a5a3973bda9701ad15bb1c1b167038baa513cb67985708a65bdf6ca6 overlays/kimi-a7m-sub2api-v0.1.129.md
|
||||
2b2597694ab03409360bf73de43a5cfcea0e26369c4ab18cc8552ff3278729aa overlays/kimi-a7m-sub2api-v0.1.129.patch
|
||||
4d0069e7bb014b886d4b21fa9a2144fcf65e835f158eda1bb98e092efebd93f3 pack.json
|
||||
eda16afc83e12055d3a41b5e37fd0923d3741b66da5af780bcea53ff34fa130e providers/step-3-5-flash-official.json
|
||||
fa486a449407f38de8b180ff301568deccef5177ca0436158b1d5b0e6d9328b2 providers/openai-zhongzhuan.json
|
||||
fdf7fa2e1ff4aa4f5dcd3f3ec2f55db11d6197625a467d6d0afa8a554a6ba6e6 providers/deepseek-chat-official.json
|
||||
fe8c84b1b316e8c2e9eb60411383d04bfc0f0ff0c029db3b7d83aa976a6f6d54 providers/glm-5-1-official.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"vendor": "YourTeam",
|
||||
"target_host": "sub2api",
|
||||
"min_host_version": "0.1.126",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"force_disable_openai_responses_api": true,
|
||||
"default_models": ["deepseek-chat"],
|
||||
"smoke_test_model": "deepseek-chat",
|
||||
"group_template": {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"force_disable_openai_responses_api": true,
|
||||
"default_models": ["deepseek-reasoner"],
|
||||
"smoke_test_model": "deepseek-reasoner",
|
||||
"group_template": {
|
||||
|
||||
@@ -9,6 +9,7 @@ key_file="${4:-}"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT_DIR/scripts/host_access_prep_lib.sh"
|
||||
ARTIFACT_REDACTION_SCRIPT="$ROOT_DIR/scripts/artifact_redaction.py"
|
||||
|
||||
KEY="${KEY:-/home/long/下载/zjsea.pem}"
|
||||
REMOTE="${REMOTE:-ubuntu@43.155.133.187}"
|
||||
@@ -326,12 +327,11 @@ PY
|
||||
remote_pg_query "$sql" > "$output_path"
|
||||
}
|
||||
|
||||
write_json_file "$ART/00-local-key-source.json" "$(python3 - <<'PY' "$key_source" "$provider_id" "$upstream_key"
|
||||
write_json_file "$ART/00-local-key-source.json" "$(python3 - <<'PY' "$ARTIFACT_REDACTION_SCRIPT" "$key_source" "$provider_id" "$upstream_key"
|
||||
import json, sys
|
||||
source, provider_id, key = sys.argv[1:4]
|
||||
from pathlib import Path
|
||||
redaction_script, source, provider_id, key = sys.argv[1:5]
|
||||
import subprocess
|
||||
result = subprocess.check_output([sys.executable, 'scripts/artifact_redaction.py', 'redact-key', key], text=True)
|
||||
result = subprocess.check_output([sys.executable, redaction_script, 'redact-key', key], text=True)
|
||||
redacted = json.loads(result)
|
||||
print(json.dumps({
|
||||
'source': source,
|
||||
@@ -555,10 +555,10 @@ subscription_cache_key="$(build_subscription_billing_cache_key "$sub_uid" "$subs
|
||||
|
||||
prep_sql="$(build_subscription_access_prep_sql "$sub_uid" "$sub_key" "$subscription_group_id" "$MIN_BALANCE" "$SUBSCRIPTION_DAYS" "$admin_uid" "$SUBSCRIPTION_NOTES")"
|
||||
remote_pg_exec "$prep_sql" > "$ART/06-subscription-access-prep.psql.txt"
|
||||
write_json_file "$ART/05-subscription-access-prep.summary.json" "$(python3 - <<'PY' "$sub_uid" "$subscription_group_id" "$MIN_BALANCE" "$SUBSCRIPTION_DAYS" "$sub_key"
|
||||
write_json_file "$ART/05-subscription-access-prep.summary.json" "$(python3 - <<'PY' "$ARTIFACT_REDACTION_SCRIPT" "$sub_uid" "$subscription_group_id" "$MIN_BALANCE" "$SUBSCRIPTION_DAYS" "$sub_key"
|
||||
import json, subprocess, sys
|
||||
sub_uid, group_id, min_balance, subscription_days, sub_key = sys.argv[1:6]
|
||||
redacted = json.loads(subprocess.check_output([sys.executable, 'scripts/artifact_redaction.py', 'redact-key', sub_key], text=True))
|
||||
redaction_script, sub_uid, group_id, min_balance, subscription_days, sub_key = sys.argv[1:7]
|
||||
redacted = json.loads(subprocess.check_output([sys.executable, redaction_script, 'redact-key', sub_key], text=True))
|
||||
print(json.dumps({
|
||||
'subscription_user_id_hash': __import__('hashlib').sha256(sub_uid.encode('utf-8')).hexdigest(),
|
||||
'subscription_group_id': int(group_id),
|
||||
|
||||
@@ -70,11 +70,14 @@ render_remote43_crm_env() {
|
||||
local crm_port="$1"
|
||||
local sqlite_dsn="$2"
|
||||
local admin_token="$3"
|
||||
local sqlite_dsn_q admin_token_q
|
||||
printf -v sqlite_dsn_q '%q' "$sqlite_dsn"
|
||||
printf -v admin_token_q '%q' "$admin_token"
|
||||
|
||||
cat <<EOF
|
||||
SUB2API_CRM_LISTEN_ADDR=127.0.0.1:$crm_port
|
||||
SUB2API_CRM_SQLITE_DSN=$sqlite_dsn
|
||||
SUB2API_CRM_ADMIN_TOKEN=$admin_token
|
||||
SUB2API_CRM_SQLITE_DSN=$sqlite_dsn_q
|
||||
SUB2API_CRM_ADMIN_TOKEN=$admin_token_q
|
||||
SUB2API_CRM_RECONCILE_WORKER_ENABLED=false
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -686,7 +686,11 @@ run_test_remote43_patched_stack_renderers() {
|
||||
assert_contains "$host_env" "DATABASE_HOST=stack-pg"
|
||||
assert_contains "$host_env" "REDIS_HOST=stack-redis"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_LISTEN_ADDR=127.0.0.1:18143"
|
||||
assert_contains "$crm_env" "SUB2API_CRM_SQLITE_DSN="
|
||||
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_TOKEN=crm-token"
|
||||
local sourced_dsn
|
||||
sourced_dsn="$(bash -lc 'set -a; source /dev/stdin; set +a; printf "%s" "$SUB2API_CRM_SQLITE_DSN"' <<<"$crm_env")"
|
||||
[[ "$sourced_dsn" == "file:/tmp/sub2api.db?_foreign_keys=on" ]] || fail "crm env dsn did not survive bash source"
|
||||
assert_contains "$bootstrap" 'rm -f "$DATA_DIR/install.lock" "$DATA_DIR/config.yaml" "$DATA_DIR/.installed"'
|
||||
assert_contains "$bootstrap" '-v "$HOST_BINARY:/app/sub2api:ro"'
|
||||
assert_contains "$bootstrap" '-p "127.0.0.1:$HOST_PORT:$HOST_CONTAINER_PORT"'
|
||||
|
||||
Reference in New Issue
Block a user