feat: add redis activity adapter foundation
This commit is contained in:
parent
1785f4505a
commit
9f3a51a7c0
14
deploy/docker-compose.test.yml
Normal file
14
deploy/docker-compose.test.yml
Normal file
@ -0,0 +1,14 @@
|
||||
name: proxy-pool-test
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:8.2-alpine
|
||||
command: ["redis-server", "--appendonly", "no", "--save", ""]
|
||||
ports:
|
||||
- "127.0.0.1:16379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 30
|
||||
start_period: 1s
|
||||
10
go.mod
10
go.mod
@ -2,4 +2,12 @@ module proxy-pool
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
require (
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
)
|
||||
|
||||
22
go.sum
22
go.sum
@ -1,2 +1,24 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
70
internal/adapters/redisactivity/adapter.go
Normal file
70
internal/adapters/redisactivity/adapter.go
Normal file
@ -0,0 +1,70 @@
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"proxy-pool/internal/platform/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidOptions = errors.New("invalid redis activity adapter options")
|
||||
namespacePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Namespace string
|
||||
Credentials credentials.Store
|
||||
OperationTTL time.Duration
|
||||
MaxCandidateScan int
|
||||
CleanupLimit int
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
client redis.Scripter
|
||||
credentials credentials.Store
|
||||
keys keyspace
|
||||
options Options
|
||||
}
|
||||
|
||||
func New(client redis.Scripter, options Options) (*Adapter, error) {
|
||||
options.Namespace = strings.TrimSpace(options.Namespace)
|
||||
if nilInterface(client) || nilInterface(options.Credentials) ||
|
||||
!namespacePattern.MatchString(options.Namespace) || options.OperationTTL <= 0 ||
|
||||
options.MaxCandidateScan <= 0 || options.CleanupLimit <= 0 {
|
||||
return nil, ErrInvalidOptions
|
||||
}
|
||||
return &Adapter{
|
||||
client: client,
|
||||
credentials: options.Credentials,
|
||||
keys: newKeyspace(options.Namespace),
|
||||
options: options,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Format(state fmt.State, _ rune) {
|
||||
if a == nil {
|
||||
_, _ = state.Write([]byte("redisactivity.Adapter<nil>"))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(state, "redisactivity.Adapter{Namespace:%q}", a.options.Namespace)
|
||||
}
|
||||
|
||||
func nilInterface(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return reflected.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
232
internal/adapters/redisactivity/adapter_test.go
Normal file
232
internal/adapters/redisactivity/adapter_test.go
Normal file
@ -0,0 +1,232 @@
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
extractionDomain "proxy-pool/internal/domain/extraction"
|
||||
proxyDomain "proxy-pool/internal/domain/proxy"
|
||||
"proxy-pool/internal/platform/credentials"
|
||||
)
|
||||
|
||||
func TestRunScriptPreservesContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := runScript(ctx, client, redis.NewScript("return 1"), nil)
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, extractionDomain.ErrStoreUnavailable) {
|
||||
t.Fatalf("runScript() error = %v, want only context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidDependenciesAndOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
credentialStore, err := credentials.NewMemoryStore(10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryStore(): %v", err)
|
||||
}
|
||||
valid := Options{
|
||||
Namespace: "test-a", Credentials: credentialStore,
|
||||
OperationTTL: time.Minute, MaxCandidateScan: 2048, CleanupLimit: 128,
|
||||
}
|
||||
var typedNilClient *redis.Client
|
||||
var typedNilCredentials *credentials.MemoryStore
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
client redis.Scripter
|
||||
options Options
|
||||
}{
|
||||
{name: "nil client", options: valid},
|
||||
{name: "typed nil client", client: typedNilClient, options: valid},
|
||||
{name: "nil credentials", client: client, options: withCredentials(valid, nil)},
|
||||
{name: "typed nil credentials", client: client, options: withCredentials(valid, typedNilCredentials)},
|
||||
{name: "empty namespace", client: client, options: withNamespace(valid, "")},
|
||||
{name: "braces in namespace", client: client, options: withNamespace(valid, "tenant{other}")},
|
||||
{name: "colon in namespace", client: client, options: withNamespace(valid, "tenant:other")},
|
||||
{name: "zero operation ttl", client: client, options: withOperationTTL(valid, 0)},
|
||||
{name: "zero candidate scan", client: client, options: withMaxCandidateScan(valid, 0)},
|
||||
{name: "negative cleanup limit", client: client, options: withCleanupLimit(valid, -1)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if adapter, err := New(tt.client, tt.options); err == nil || adapter != nil {
|
||||
t.Fatalf("New() = (%v, %v), want nil adapter and error", adapter, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBuildsClusterSafeKeyspaceAndHashesDynamicTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
credentialStore, err := credentials.NewMemoryStore(10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMemoryStore(): %v", err)
|
||||
}
|
||||
adapter, err := New(client, Options{
|
||||
Namespace: " test-a ", Credentials: credentialStore,
|
||||
OperationTTL: time.Minute, MaxCandidateScan: 2048, CleanupLimit: 128,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New(): %v", err)
|
||||
}
|
||||
|
||||
if got := adapter.keys.records; got != "pp:{activity}:test-a:records" {
|
||||
t.Fatalf("records key = %q", got)
|
||||
}
|
||||
staticKeys := []string{
|
||||
adapter.keys.records, adapter.keys.unique, adapter.keys.idkeys,
|
||||
adapter.keys.expiry, adapter.keys.available, adapter.keys.owners,
|
||||
adapter.keys.ownerExpiry, adapter.keys.epoch, adapter.keys.inventory,
|
||||
}
|
||||
for _, key := range staticKeys {
|
||||
if strings.Count(key, "{activity}") != 1 || strings.Count(key, "{") != 1 || strings.Count(key, "}") != 1 {
|
||||
t.Fatalf("key %q does not contain exactly one fixed hash tag", key)
|
||||
}
|
||||
}
|
||||
|
||||
raw := "tenant:{unsafe}:TOKEN"
|
||||
dynamicKeys := []string{
|
||||
adapter.keys.idempotency(raw, raw),
|
||||
adapter.keys.operation(raw),
|
||||
adapter.keys.protocol(raw),
|
||||
adapter.keys.region(raw),
|
||||
adapter.keys.carrier(raw),
|
||||
adapter.keys.upstream(raw),
|
||||
}
|
||||
for _, key := range dynamicKeys {
|
||||
if strings.Contains(key, raw) || strings.Count(key, "{activity}") != 1 || strings.Count(key, "{") != 1 {
|
||||
t.Fatalf("dynamic key is unsafe: %q", key)
|
||||
}
|
||||
}
|
||||
if got := digestToken(raw); len(got) != 64 || got != digestToken(raw) || strings.Contains(got, raw) {
|
||||
t.Fatalf("digestToken() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyRecordCodecIsDeterministicStrictAndRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
record := proxyRecord{
|
||||
Version: 1, ID: "proxy-a", Scheme: string(proxyDomain.SchemeHTTP),
|
||||
Host: "192.0.2.10", Port: 8080, Username: "user", Password: "top-secret",
|
||||
SourceUpstream: "provider-a", CreatedAtMS: 1_000, ExpiresAtMS: 61_000,
|
||||
UsableUntilMS: 58_000, LastCheckedAtMS: 2_000, LastSuccessAtMS: 2_000,
|
||||
LatencyNS: int64(25 * time.Millisecond), MaxConcurrency: 8,
|
||||
State: string(proxyDomain.StateAvailable), Tags: map[string]string{"region": "cn", "carrier": "ct"},
|
||||
}
|
||||
first, err := encodeProxyRecord(record)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeProxyRecord(): %v", err)
|
||||
}
|
||||
second, err := encodeProxyRecord(record)
|
||||
if err != nil || first != second {
|
||||
t.Fatalf("deterministic encode = (%q, %q, %v)", first, second, err)
|
||||
}
|
||||
decoded, err := decodeProxyRecord(first)
|
||||
if err != nil || decoded.Password != "top-secret" || decoded.State != string(proxyDomain.StateAvailable) {
|
||||
t.Fatalf("decodeProxyRecord() = (%+v, %v)", decoded, err)
|
||||
}
|
||||
if formatted := fmt.Sprintf("%+v", record); strings.Contains(formatted, "top-secret") || !strings.Contains(formatted, "<redacted>") {
|
||||
t.Fatalf("proxyRecord formatting leaked password: %s", formatted)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
strings.Replace(first, `"state":"AVAILABLE"`, `"state":"UNKNOWN"`, 1),
|
||||
strings.Replace(first, `"latencyNs":25000000`, `"latencyNs":-1`, 1),
|
||||
strings.TrimSuffix(first, "}") + `,"unexpected":true}`,
|
||||
}
|
||||
for _, payload := range invalid {
|
||||
if _, err := decodeProxyRecord(payload); err == nil {
|
||||
t.Fatalf("decodeProxyRecord(%s) error = nil", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnershipAndIdempotencyCodecsValidateAndRedact(t *testing.T) {
|
||||
t.Parallel()
|
||||
assignment := ownershipRecord{
|
||||
Version: 1, ProxyID: "proxy-a", WorkerID: "worker-a",
|
||||
Epoch: 2, AssignmentVersion: 3, ExpiresAtMS: 5_000, Draining: true,
|
||||
}
|
||||
payload, err := encodeOwnershipRecord(assignment)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeOwnershipRecord(): %v", err)
|
||||
}
|
||||
if decoded, err := decodeOwnershipRecord(payload); err != nil || decoded != assignment {
|
||||
t.Fatalf("decodeOwnershipRecord() = (%+v, %v)", decoded, err)
|
||||
}
|
||||
if _, err := decodeOwnershipRecord(strings.Replace(payload, `"epoch":2`, `"epoch":0`, 1)); err == nil {
|
||||
t.Fatal("decodeOwnershipRecord() accepted zero epoch")
|
||||
}
|
||||
|
||||
idempotency := idempotencyRecord{
|
||||
Version: 1, RequestDigest: digestToken("request"), ExpiresAtMS: 10_000,
|
||||
Result: extractionDomain.Result{
|
||||
Requested: 1, Returned: 1, ExtractedAt: time.UnixMilli(1_000).UTC(),
|
||||
Items: []extractionDomain.Candidate{{
|
||||
ID: "proxy-a", Protocol: "http", Host: "192.0.2.10", Port: 8080,
|
||||
Username: "user", Password: "top-secret", Upstream: "provider-a", State: extractionDomain.Extracted,
|
||||
ExpiresAt: time.UnixMilli(10_000).UTC(),
|
||||
}},
|
||||
},
|
||||
}
|
||||
payload, err = encodeIdempotencyRecord(idempotency)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeIdempotencyRecord(): %v", err)
|
||||
}
|
||||
decodedID, err := decodeIdempotencyRecord(payload)
|
||||
if err != nil || decodedID.Result.Items[0].Password != "top-secret" {
|
||||
t.Fatalf("decodeIdempotencyRecord() = (%+v, %v)", decodedID, err)
|
||||
}
|
||||
invalidPayloads := []string{
|
||||
strings.Replace(payload, `"port":8080`, `"port":65537`, 1),
|
||||
strings.Replace(payload, `"state":"EXTRACTED","expiresAtMs":10000`, `"state":"EXTRACTED","expiresAtMs":10000,"lastCheckedAtMs":-1`, 1),
|
||||
}
|
||||
for _, invalidPayload := range invalidPayloads {
|
||||
if _, err := decodeIdempotencyRecord(invalidPayload); err == nil {
|
||||
t.Fatalf("decodeIdempotencyRecord(%s) error = nil", invalidPayload)
|
||||
}
|
||||
}
|
||||
if formatted := fmt.Sprintf("%+v", idempotency); strings.Contains(formatted, "top-secret") || !strings.Contains(formatted, "<redacted>") {
|
||||
t.Fatalf("idempotencyRecord formatting leaked password: %s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func withCredentials(options Options, store credentials.Store) Options {
|
||||
options.Credentials = store
|
||||
return options
|
||||
}
|
||||
|
||||
func withNamespace(options Options, namespace string) Options {
|
||||
options.Namespace = namespace
|
||||
return options
|
||||
}
|
||||
|
||||
func withOperationTTL(options Options, ttl time.Duration) Options {
|
||||
options.OperationTTL = ttl
|
||||
return options
|
||||
}
|
||||
|
||||
func withMaxCandidateScan(options Options, limit int) Options {
|
||||
options.MaxCandidateScan = limit
|
||||
return options
|
||||
}
|
||||
|
||||
func withCleanupLimit(options Options, limit int) Options {
|
||||
options.CleanupLimit = limit
|
||||
return options
|
||||
}
|
||||
350
internal/adapters/redisactivity/codec.go
Normal file
350
internal/adapters/redisactivity/codec.go
Normal file
@ -0,0 +1,350 @@
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
extractionDomain "proxy-pool/internal/domain/extraction"
|
||||
proxyDomain "proxy-pool/internal/domain/proxy"
|
||||
)
|
||||
|
||||
var ErrInvalidRecord = errors.New("invalid redis activity record")
|
||||
|
||||
const recordVersion = 1
|
||||
|
||||
type proxyRecord struct {
|
||||
Version int `json:"version"`
|
||||
ID string `json:"id"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Port int64 `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
SourceUpstream string `json:"sourceUpstream"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
UsableUntilMS int64 `json:"usableUntilMs"`
|
||||
LastCheckedAtMS int64 `json:"lastCheckedAtMs,omitempty"`
|
||||
LastSuccessAtMS int64 `json:"lastSuccessAtMs,omitempty"`
|
||||
LatencyNS int64 `json:"latencyNs"`
|
||||
MaxConcurrency int64 `json:"maxConcurrency"`
|
||||
State string `json:"state"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
OwnerWorkerID string `json:"ownerWorkerId,omitempty"`
|
||||
}
|
||||
|
||||
type ownershipRecord struct {
|
||||
Version int `json:"version"`
|
||||
ProxyID string `json:"proxyId"`
|
||||
WorkerID string `json:"workerId"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
AssignmentVersion uint64 `json:"assignmentVersion"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
Draining bool `json:"draining"`
|
||||
}
|
||||
|
||||
type idempotencyRecord struct {
|
||||
Version int
|
||||
RequestDigest string
|
||||
ExpiresAtMS int64
|
||||
Result extractionDomain.Result
|
||||
}
|
||||
|
||||
type idempotencyWire struct {
|
||||
Version int `json:"version"`
|
||||
RequestDigest string `json:"requestDigest"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
Result extractionWire `json:"result"`
|
||||
}
|
||||
|
||||
type extractionWire struct {
|
||||
Requested int `json:"requested"`
|
||||
Returned int `json:"returned"`
|
||||
ExtractedAtMS int64 `json:"extractedAtMs,omitempty"`
|
||||
Items []candidateWire `json:"items"`
|
||||
}
|
||||
|
||||
type candidateWire struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int64 `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Carrier string `json:"carrier,omitempty"`
|
||||
Upstream string `json:"upstream"`
|
||||
OwnerWorkerID string `json:"ownerWorkerId,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
State string `json:"state"`
|
||||
ExpiresAtMS int64 `json:"expiresAtMs"`
|
||||
CheckedAtMS int64 `json:"lastCheckedAtMs,omitempty"`
|
||||
}
|
||||
|
||||
func (record proxyRecord) Format(state fmt.State, _ rune) {
|
||||
_, _ = fmt.Fprintf(state,
|
||||
"redisactivity.proxyRecord{ID:%q, Scheme:%q, Host:%q, Port:%d, Username:%q, Password:<redacted>, State:%q}",
|
||||
record.ID, record.Scheme, record.Host, record.Port, record.Username, record.State,
|
||||
)
|
||||
}
|
||||
|
||||
func (record ownershipRecord) Format(state fmt.State, _ rune) {
|
||||
_, _ = fmt.Fprintf(state,
|
||||
"redisactivity.ownershipRecord{ProxyID:%q, WorkerID:%q, Epoch:%d, Version:%d, Draining:%t}",
|
||||
record.ProxyID, record.WorkerID, record.Epoch, record.AssignmentVersion, record.Draining,
|
||||
)
|
||||
}
|
||||
|
||||
func (record idempotencyRecord) Format(state fmt.State, _ rune) {
|
||||
_, _ = fmt.Fprintf(state,
|
||||
"redisactivity.idempotencyRecord{RequestDigest:%q, Returned:%d, Credentials:<redacted>}",
|
||||
record.RequestDigest, record.Result.Returned,
|
||||
)
|
||||
}
|
||||
|
||||
func encodeProxyRecord(record proxyRecord) (string, error) {
|
||||
if err := validateProxyRecord(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encodeJSON(record)
|
||||
}
|
||||
|
||||
func decodeProxyRecord(payload string) (proxyRecord, error) {
|
||||
var record proxyRecord
|
||||
if err := decodeJSON(payload, &record); err != nil {
|
||||
return proxyRecord{}, err
|
||||
}
|
||||
if err := validateProxyRecord(record); err != nil {
|
||||
return proxyRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func encodeOwnershipRecord(record ownershipRecord) (string, error) {
|
||||
if err := validateOwnershipRecord(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encodeJSON(record)
|
||||
}
|
||||
|
||||
func decodeOwnershipRecord(payload string) (ownershipRecord, error) {
|
||||
var record ownershipRecord
|
||||
if err := decodeJSON(payload, &record); err != nil {
|
||||
return ownershipRecord{}, err
|
||||
}
|
||||
if err := validateOwnershipRecord(record); err != nil {
|
||||
return ownershipRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func encodeIdempotencyRecord(record idempotencyRecord) (string, error) {
|
||||
if err := validateIdempotencyRecord(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
wire := idempotencyWire{
|
||||
Version: record.Version, RequestDigest: record.RequestDigest, ExpiresAtMS: record.ExpiresAtMS,
|
||||
Result: extractionToWire(record.Result),
|
||||
}
|
||||
return encodeJSON(wire)
|
||||
}
|
||||
|
||||
func decodeIdempotencyRecord(payload string) (idempotencyRecord, error) {
|
||||
var wire idempotencyWire
|
||||
if err := decodeJSON(payload, &wire); err != nil {
|
||||
return idempotencyRecord{}, err
|
||||
}
|
||||
if err := validateIdempotencyWire(wire); err != nil {
|
||||
return idempotencyRecord{}, err
|
||||
}
|
||||
record := idempotencyRecord{
|
||||
Version: wire.Version, RequestDigest: wire.RequestDigest, ExpiresAtMS: wire.ExpiresAtMS,
|
||||
Result: extractionFromWire(wire.Result),
|
||||
}
|
||||
if err := validateIdempotencyRecord(record); err != nil {
|
||||
return idempotencyRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func encodeJSON(value any) (string, error) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", errors.Join(ErrInvalidRecord, err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func decodeJSON(payload string, destination any) error {
|
||||
decoder := json.NewDecoder(strings.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return errors.Join(ErrInvalidRecord, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = errors.New("multiple JSON values")
|
||||
}
|
||||
return errors.Join(ErrInvalidRecord, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxyRecord(record proxyRecord) error {
|
||||
if record.Version != recordVersion || record.ID == "" || record.Host == "" ||
|
||||
record.Port <= 0 || record.Port > 65_535 || record.SourceUpstream == "" ||
|
||||
record.CreatedAtMS <= 0 || record.ExpiresAtMS <= 0 || record.UsableUntilMS <= 0 ||
|
||||
record.UsableUntilMS > record.ExpiresAtMS || record.LastCheckedAtMS < 0 ||
|
||||
record.LastSuccessAtMS < 0 || record.LatencyNS < 0 || record.MaxConcurrency < 0 ||
|
||||
!validScheme(record.Scheme) || !validProxyState(record.State) {
|
||||
return ErrInvalidRecord
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOwnershipRecord(record ownershipRecord) error {
|
||||
if record.Version != recordVersion || record.ProxyID == "" || record.WorkerID == "" ||
|
||||
record.Epoch == 0 || record.AssignmentVersion == 0 || record.ExpiresAtMS <= 0 {
|
||||
return ErrInvalidRecord
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIdempotencyRecord(record idempotencyRecord) error {
|
||||
if record.Version != recordVersion {
|
||||
return invalidRecord("unsupported idempotency version")
|
||||
}
|
||||
if !validDigest(record.RequestDigest) {
|
||||
return invalidRecord("invalid request digest")
|
||||
}
|
||||
if record.ExpiresAtMS <= 0 {
|
||||
return invalidRecord("invalid idempotency expiry")
|
||||
}
|
||||
if record.Result.Requested < 0 || record.Result.Returned < 0 ||
|
||||
record.Result.Returned != len(record.Result.Items) || record.Result.Returned > record.Result.Requested {
|
||||
return invalidRecord("invalid extraction counters")
|
||||
}
|
||||
if record.Result.Returned > 0 && record.Result.ExtractedAt.IsZero() {
|
||||
return invalidRecord("missing extraction time")
|
||||
}
|
||||
for _, candidate := range record.Result.Items {
|
||||
if candidate.ID == "" || candidate.Host == "" || candidate.Port == 0 || candidate.Upstream == "" {
|
||||
return invalidRecord("incomplete extraction candidate identity")
|
||||
}
|
||||
if !validScheme(candidate.Protocol) || candidate.State != extractionDomain.Extracted {
|
||||
return invalidRecord("invalid extraction candidate state")
|
||||
}
|
||||
if candidate.ExpiresAt.IsZero() || candidate.ExpiresAt.UnixMilli() <= 0 ||
|
||||
(!candidate.LastCheckedAt.IsZero() && candidate.LastCheckedAt.UnixMilli() <= 0) {
|
||||
return invalidRecord("invalid extraction candidate time")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIdempotencyWire(wire idempotencyWire) error {
|
||||
if wire.Version != recordVersion || !validDigest(wire.RequestDigest) || wire.ExpiresAtMS <= 0 {
|
||||
return invalidRecord("invalid idempotency wire header")
|
||||
}
|
||||
if wire.Result.Requested < 0 || wire.Result.Returned < 0 ||
|
||||
wire.Result.Returned != len(wire.Result.Items) || wire.Result.Returned > wire.Result.Requested {
|
||||
return invalidRecord("invalid extraction wire counters")
|
||||
}
|
||||
if wire.Result.Returned > 0 && wire.Result.ExtractedAtMS <= 0 {
|
||||
return invalidRecord("invalid extraction wire time")
|
||||
}
|
||||
for _, candidate := range wire.Result.Items {
|
||||
if candidate.ID == "" || candidate.Host == "" || candidate.Port <= 0 || candidate.Port > 65_535 ||
|
||||
candidate.Upstream == "" || !validScheme(candidate.Protocol) ||
|
||||
extractionDomain.State(candidate.State) != extractionDomain.Extracted ||
|
||||
candidate.ExpiresAtMS <= 0 || candidate.CheckedAtMS < 0 {
|
||||
return invalidRecord("invalid extraction wire candidate")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidRecord(reason string) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidRecord, reason)
|
||||
}
|
||||
|
||||
func extractionToWire(result extractionDomain.Result) extractionWire {
|
||||
wire := extractionWire{
|
||||
Requested: result.Requested, Returned: result.Returned,
|
||||
Items: make([]candidateWire, 0, len(result.Items)),
|
||||
}
|
||||
if !result.ExtractedAt.IsZero() {
|
||||
wire.ExtractedAtMS = result.ExtractedAt.UnixMilli()
|
||||
}
|
||||
for _, candidate := range result.Items {
|
||||
item := candidateWire{
|
||||
ID: candidate.ID, Protocol: candidate.Protocol, Host: candidate.Host, Port: int64(candidate.Port),
|
||||
Username: candidate.Username, Password: candidate.Password, Region: candidate.Region,
|
||||
Carrier: candidate.Carrier, Upstream: candidate.Upstream, OwnerWorkerID: candidate.OwnerWorkerID,
|
||||
URL: candidate.URL, State: string(candidate.State), ExpiresAtMS: candidate.ExpiresAt.UnixMilli(),
|
||||
}
|
||||
if !candidate.LastCheckedAt.IsZero() {
|
||||
item.CheckedAtMS = candidate.LastCheckedAt.UnixMilli()
|
||||
}
|
||||
wire.Items = append(wire.Items, item)
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
func extractionFromWire(wire extractionWire) extractionDomain.Result {
|
||||
result := extractionDomain.Result{
|
||||
Requested: wire.Requested, Returned: wire.Returned,
|
||||
Items: make([]extractionDomain.Candidate, 0, len(wire.Items)),
|
||||
}
|
||||
if wire.ExtractedAtMS > 0 {
|
||||
result.ExtractedAt = time.UnixMilli(wire.ExtractedAtMS).UTC()
|
||||
}
|
||||
for _, item := range wire.Items {
|
||||
candidate := extractionDomain.Candidate{
|
||||
ID: item.ID, Protocol: item.Protocol, Host: item.Host, Port: uint16(item.Port),
|
||||
Username: item.Username, Password: item.Password, Region: item.Region,
|
||||
Carrier: item.Carrier, Upstream: item.Upstream, OwnerWorkerID: item.OwnerWorkerID,
|
||||
URL: item.URL, State: extractionDomain.State(item.State), ExpiresAt: time.UnixMilli(item.ExpiresAtMS).UTC(),
|
||||
}
|
||||
if item.CheckedAtMS > 0 {
|
||||
candidate.LastCheckedAt = time.UnixMilli(item.CheckedAtMS).UTC()
|
||||
}
|
||||
result.Items = append(result.Items, candidate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validDigest(value string) bool {
|
||||
if len(value) != sha256HexSize {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScheme(value string) bool {
|
||||
switch proxyDomain.Scheme(value) {
|
||||
case proxyDomain.SchemeHTTP, proxyDomain.SchemeHTTPS, proxyDomain.SchemeSOCKS5:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validProxyState(value string) bool {
|
||||
switch proxyDomain.State(value) {
|
||||
case proxyDomain.StateFetched, proxyDomain.StateChecking, proxyDomain.StateAvailable,
|
||||
proxyDomain.StateSuspect, proxyDomain.StateDraining, proxyDomain.StateUnhealthy,
|
||||
proxyDomain.StateExtracted, proxyDomain.StateExpired, proxyDomain.StateRemoved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sha256HexSize = 64
|
||||
80
internal/adapters/redisactivity/keys.go
Normal file
80
internal/adapters/redisactivity/keys.go
Normal file
@ -0,0 +1,80 @@
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const redisKeyPrefix = "pp:{activity}:"
|
||||
|
||||
type keyspace struct {
|
||||
prefix string
|
||||
records string
|
||||
unique string
|
||||
idkeys string
|
||||
expiry string
|
||||
available string
|
||||
owners string
|
||||
ownerExpiry string
|
||||
epoch string
|
||||
inventory string
|
||||
}
|
||||
|
||||
func newKeyspace(namespace string) keyspace {
|
||||
prefix := redisKeyPrefix + namespace
|
||||
return keyspace{
|
||||
prefix: prefix,
|
||||
records: prefix + ":records",
|
||||
unique: prefix + ":unique",
|
||||
idkeys: prefix + ":idkeys",
|
||||
expiry: prefix + ":expiry",
|
||||
available: prefix + ":available",
|
||||
owners: prefix + ":owners",
|
||||
ownerExpiry: prefix + ":owner-expiry",
|
||||
epoch: prefix + ":epoch",
|
||||
inventory: prefix + ":inventory",
|
||||
}
|
||||
}
|
||||
|
||||
func (keys keyspace) idempotency(clientID, idempotencyKey string) string {
|
||||
return keys.prefix + ":idem:" + digestParts(clientID, idempotencyKey)
|
||||
}
|
||||
|
||||
func (keys keyspace) operation(operationID string) string {
|
||||
return keys.prefix + ":op:" + digestToken(operationID)
|
||||
}
|
||||
|
||||
func (keys keyspace) protocol(value string) string {
|
||||
return keys.facet("protocol", value)
|
||||
}
|
||||
|
||||
func (keys keyspace) region(value string) string {
|
||||
return keys.facet("region", value)
|
||||
}
|
||||
|
||||
func (keys keyspace) carrier(value string) string {
|
||||
return keys.facet("carrier", value)
|
||||
}
|
||||
|
||||
func (keys keyspace) upstream(value string) string {
|
||||
return keys.facet("upstream", value)
|
||||
}
|
||||
|
||||
func (keys keyspace) facet(name, value string) string {
|
||||
return keys.prefix + ":" + name + ":" + digestToken(value)
|
||||
}
|
||||
|
||||
func digestToken(value string) string {
|
||||
return digestParts(value)
|
||||
}
|
||||
|
||||
func digestParts(values ...string) string {
|
||||
digest := sha256.New()
|
||||
for _, value := range values {
|
||||
_, _ = digest.Write([]byte(strconv.Itoa(len(value))))
|
||||
_, _ = digest.Write([]byte{':'})
|
||||
_, _ = digest.Write([]byte(value))
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil))
|
||||
}
|
||||
64
internal/adapters/redisactivity/scripts.go
Normal file
64
internal/adapters/redisactivity/scripts.go
Normal file
@ -0,0 +1,64 @@
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
extractionDomain "proxy-pool/internal/domain/extraction"
|
||||
)
|
||||
|
||||
type scriptStatus string
|
||||
|
||||
const (
|
||||
scriptOK scriptStatus = "ok"
|
||||
scriptInvalid scriptStatus = "invalid"
|
||||
scriptNotFound scriptStatus = "not_found"
|
||||
scriptConflict scriptStatus = "conflict"
|
||||
scriptUnavailable scriptStatus = "unavailable"
|
||||
scriptInsufficient scriptStatus = "insufficient"
|
||||
)
|
||||
|
||||
type upsertScriptReply struct {
|
||||
Status scriptStatus `json:"status"`
|
||||
Accepted int `json:"accepted"`
|
||||
Inserted int `json:"inserted"`
|
||||
Refreshed int `json:"refreshed"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
type healthScriptReply struct {
|
||||
Status scriptStatus `json:"status"`
|
||||
Record string `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type extractScriptReply struct {
|
||||
Status scriptStatus `json:"status"`
|
||||
Record string `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type ownershipScriptReply struct {
|
||||
Status scriptStatus `json:"status"`
|
||||
Record string `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type maintenanceScriptReply struct {
|
||||
Status scriptStatus `json:"status"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func runScript(ctx context.Context, client redis.Scripter, script *redis.Script, keys []string, args ...any) (any, error) {
|
||||
result, err := script.Run(ctx, client, keys, args...).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.Join(
|
||||
extractionDomain.ErrStoreUnavailable,
|
||||
fmt.Errorf("run redis activity script: %w", err),
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
99
internal/adapters/redisactivity/testredis_test.go
Normal file
99
internal/adapters/redisactivity/testredis_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
//go:build integration
|
||||
|
||||
package redisactivity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"proxy-pool/internal/platform/credentials"
|
||||
)
|
||||
|
||||
var testNamespaceSequence atomic.Uint64
|
||||
|
||||
type redisTestFixture struct {
|
||||
Adapter *Adapter
|
||||
Client *redis.Client
|
||||
Credentials *credentials.MemoryStore
|
||||
Namespace string
|
||||
}
|
||||
|
||||
func TestRedisFixtureUsesIsolatedNamespace(t *testing.T) {
|
||||
fixture := newRedisTestFixture(t)
|
||||
key := fixture.Adapter.keys.operation("fixture-probe")
|
||||
if err := fixture.Client.Set(t.Context(), key, "ok", time.Minute).Err(); err != nil {
|
||||
t.Fatalf("write isolated fixture key: %v", err)
|
||||
}
|
||||
if value, err := fixture.Client.Get(t.Context(), key).Result(); err != nil || value != "ok" {
|
||||
t.Fatalf("read isolated fixture key = (%q, %v)", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRedisTestFixture(t *testing.T) redisTestFixture {
|
||||
t.Helper()
|
||||
redisURL := os.Getenv("PROXY_POOL_TEST_REDIS_URL")
|
||||
if redisURL == "" {
|
||||
t.Skip("PROXY_POOL_TEST_REDIS_URL is not set")
|
||||
}
|
||||
redisOptions, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse PROXY_POOL_TEST_REDIS_URL: %v", err)
|
||||
}
|
||||
client := redis.NewClient(redisOptions)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
t.Fatalf("ping test Redis: %v", err)
|
||||
}
|
||||
credentialStore, err := credentials.NewMemoryStore(10_000)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
t.Fatalf("NewMemoryStore(): %v", err)
|
||||
}
|
||||
namespace := fmt.Sprintf("it-%d-%d-%d", os.Getpid(), time.Now().UnixNano(), testNamespaceSequence.Add(1))
|
||||
adapter, err := New(client, Options{
|
||||
Namespace: namespace, Credentials: credentialStore,
|
||||
OperationTTL: time.Minute, MaxCandidateScan: 2_048, CleanupLimit: 128,
|
||||
})
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
t.Fatalf("New(): %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cleanupCancel()
|
||||
if err := deleteRedisNamespace(cleanupCtx, client, redisKeyPrefix+namespace+":*"); err != nil {
|
||||
t.Errorf("clean Redis test namespace: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
})
|
||||
return redisTestFixture{
|
||||
Adapter: adapter, Client: client, Credentials: credentialStore, Namespace: namespace,
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRedisNamespace(ctx context.Context, client *redis.Client, pattern string) error {
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, next, err := client.Scan(ctx, cursor, pattern, 128).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if err := client.Unlink(ctx, keys...).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
33
scripts/test-redis.ps1
Normal file
33
scripts/test-redis.ps1
Normal file
@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$composeFile = Join-Path $repositoryRoot "deploy/docker-compose.test.yml"
|
||||
$previousRedisURL = [Environment]::GetEnvironmentVariable("PROXY_POOL_TEST_REDIS_URL", "Process")
|
||||
|
||||
try {
|
||||
docker compose -f $composeFile up -d --wait
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "starting Redis test fixture failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$env:PROXY_POOL_TEST_REDIS_URL = "redis://127.0.0.1:16379/15"
|
||||
Push-Location $repositoryRoot
|
||||
try {
|
||||
go test -count=1 -tags=integration -timeout 60s ./internal/adapters/redisactivity
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Redis integration tests failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -eq $previousRedisURL) {
|
||||
Remove-Item Env:PROXY_POOL_TEST_REDIS_URL -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:PROXY_POOL_TEST_REDIS_URL = $previousRedisURL
|
||||
}
|
||||
docker compose -f $composeFile down --remove-orphans
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user