diff --git a/.github/workflows/acctest-terraform-lint.yml b/.github/workflows/acctest-terraform-lint.yml index a71910b4ecec..7e6fe76403e6 100644 --- a/.github/workflows/acctest-terraform-lint.yml +++ b/.github/workflows/acctest-terraform-lint.yml @@ -43,8 +43,36 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: '1.25' - run: make tools - - run: make tflint \ No newline at end of file + - name: Run tflint on changed files only + run: | + # Get Go files changed in this PR + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'alicloud/*.go' | grep '\.go$' || true) + if [ -z "$CHANGED" ]; then + echo "No Go files changed, skipping tflint" + exit 0 + fi + # Run tflint on entire package, capture output and exit code + TFLINT_EXIT=0 + make tflint 2>&1 | tee /tmp/tflint-output.txt || TFLINT_EXIT=$? + if [ "$TFLINT_EXIT" -ne 0 ]; then + # Filter errors to only changed files using full paths + ERRORS="" + for f in $CHANGED; do + MATCH=$(grep -F "$f" /tmp/tflint-output.txt || true) + if [ -n "$MATCH" ]; then + ERRORS="${ERRORS}${MATCH}"$'\n' + fi + done + if [ -n "$ERRORS" ]; then + echo "tflint errors in changed files:" + echo "$ERRORS" + exit 1 + fi + fi + echo "No tflint errors in changed files" diff --git a/.gitignore b/.gitignore index 5f7b6a33f4f6..a5a35314a65f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ coverage.html coverage.out .qwenignore -.terraflow/ \ No newline at end of file +.terraflow/ + +# VCR cassettes (contain API responses, may include account-specific data) +**/testdata/vcr/ diff --git a/.opencode/skills/provider-add-attribute/SKILL.md b/.opencode/skills/provider-add-attribute/SKILL.md index 6a38ce5c45ef..227cfec9101c 100644 --- a/.opencode/skills/provider-add-attribute/SKILL.md +++ b/.opencode/skills/provider-add-attribute/SKILL.md @@ -68,7 +68,7 @@ make ci-check-quick make ci-check # 3. If step 2 has test failures, debug with specific resource and test case: -make test-resource-debug RESOURCE=alicloud_vpc TESTCASE=TestAccAliCloudVPC_enableIpv6 LOGLEVEL=TRACE LOGFILE=vpc-test.log +make acctest RESOURCE=alicloud_vpc TESTCASE=TestAccAliCloudVPC_enableIpv6 LOGLEVEL=TRACE LOGFILE=vpc-test.log ``` ## Step 5: Commit Code diff --git a/.opencode/skills/provider-resource-acceptance/SKILL.md b/.opencode/skills/provider-resource-acceptance/SKILL.md index 329b3ed1ae4b..174681215929 100644 --- a/.opencode/skills/provider-resource-acceptance/SKILL.md +++ b/.opencode/skills/provider-resource-acceptance/SKILL.md @@ -47,7 +47,7 @@ Execute `provider-resource-review` skill (`load_skills=["provider-resource-revie Pick a **new** `TestAcc*_basic` test case from this branch. If none, pick simplest existing `_basic`. ```bash -make test-resource-debug RESOURCE=alicloud_ TESTCASE= LOGLEVEL=TRACE LOGFILE=-test.log +make acctest RESOURCE=alicloud_ TESTCASE= LOGLEVEL=TRACE LOGFILE=-test.log ``` Fix failures, re-run. Mode A: post errors/fixes to Aone. @@ -55,7 +55,7 @@ Fix failures, re-run. Mode A: post errors/fixes to Aone. ## Step 5: Run All Tests ```bash -make test-resource-debug RESOURCE=alicloud_ +make acctest RESOURCE=alicloud_ ``` For each failure: diff --git a/GNUmakefile b/GNUmakefile index 5485652f5bdb..3435104d6c8a 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -33,10 +33,19 @@ goimports: tools @echo "Done. Processed with up to $(PARALLEL) parallel jobs." # Test a specific resource with debug logs -# Usage: make test-resource-debug RESOURCE=alicloud_vpc TESTCASE=basic LOGLEVEL=TRACE LOGFILE=vpc-test.log -test-resource-debug: +# Usage: +# make acctest RESOURCE=alicloud_vpc TESTCASE=basic +# make acctest RESOURCE=alicloud_vpc TESTCASE=basic LOGLEVEL=TRACE LOGFILE=vpc-test.log +# +# VCR mode (record/replay API interactions): +# make acctest RESOURCE=alicloud_vpc TESTCASE=basic VCR=record # Record to cassette (needs credentials) +# make acctest RESOURCE=alicloud_vpc TESTCASE=basic VCR=replay # Replay from cassette (no credentials) +# make vcr-list # List available cassettes +# make vcr-clean # Remove all cassettes +VCR_DIR ?= testdata/vcr +acctest: @if [ -z "$(RESOURCE)" ]; then \ - echo "Error: RESOURCE is required. Usage: make test-resource-debug RESOURCE=alicloud_vpc"; \ + echo "Error: RESOURCE is required. Usage: make acctest RESOURCE=alicloud_vpc"; \ exit 1; \ fi @RESOURCE_NAME=$$(echo "$(RESOURCE)" | sed 's/^alicloud_//'); \ @@ -52,6 +61,7 @@ test-resource-debug: fi; \ fi; \ echo "Found test file: $$TEST_FILE ($$TEST_TYPE)"; \ + VCR_MODE_FLAG="$(VCR)"; \ LOGLEVEL=$${LOGLEVEL:-DEBUG}; \ LOGFILE_BASE="$(LOGFILE)"; \ PROJECT_ROOT=$$(pwd); \ @@ -95,13 +105,31 @@ test-resource-debug: FAILED_TESTS=""; \ echo "$$SELECTED_TESTS" | while IFS= read -r TEST_NAME; do \ TEST_NUM=$$((TEST_NUM + 1)); \ + VCR_ENV=""; \ + if [ -n "$$VCR_MODE_FLAG" ]; then \ + VCR_CASSETTE_NAME=$$(echo "$$TEST_NAME" | tr '[:upper:]' '[:lower:]'); \ + VCR_CASSETTE="$(VCR_DIR)/$$VCR_CASSETTE_NAME"; \ + if [ "$$VCR_MODE_FLAG" = "record" ]; then \ + mkdir -p alicloud/$(VCR_DIR); \ + VCR_ENV="VCR_MODE=record VCR_PATH=$$VCR_CASSETTE"; \ + elif [ "$$VCR_MODE_FLAG" = "replay" ]; then \ + if [ ! -f "alicloud/$${VCR_CASSETTE}.yaml" ]; then \ + echo "Error: Cassette not found: alicloud/$${VCR_CASSETTE}.yaml"; \ + echo "Run 'make acctest RESOURCE=$(RESOURCE) TESTCASE=... VCR=record' first."; \ + FAILED_TESTS="$$FAILED_TESTS$$TEST_NAME\n"; \ + continue; \ + fi; \ + VCR_ENV="VCR_MODE=replay VCR_PATH=$$VCR_CASSETTE"; \ + fi; \ + fi; \ echo "=================================================="; \ echo "[$$TEST_NUM/$$TEST_COUNT] Running: $$TEST_NAME"; \ + if [ -n "$$VCR_ENV" ]; then echo " VCR: $$VCR_MODE_FLAG → $$VCR_CASSETTE"; fi; \ echo "=================================================="; \ if [ -n "$$LOGFILE_BASE" ]; then \ TEST_API_LOG="$${PROJECT_ROOT}/$${LOGFILE_BASE}-$${TEST_NAME}-api.log"; \ TEST_CONSOLE_LOG="$${PROJECT_ROOT}/$${LOGFILE_BASE}-$${TEST_NAME}-console.log"; \ - if TF_LOG=$$LOGLEVEL TF_LOG_PATH=$$TEST_API_LOG TF_ACC=1 go test -v ./alicloud -run="^$$TEST_NAME\$$" -timeout 360m 2>&1 | tee $$TEST_CONSOLE_LOG; then \ + if env $$VCR_ENV TF_LOG=$$LOGLEVEL TF_LOG_PATH=$$TEST_API_LOG TF_ACC=1 go test -v ./alicloud -run="^$$TEST_NAME\$$" -timeout 360m 2>&1 | tee $$TEST_CONSOLE_LOG; then \ echo "✓ PASSED: $$TEST_NAME"; \ echo " API logs: $$TEST_API_LOG"; \ echo " Console logs: $$TEST_CONSOLE_LOG"; \ @@ -112,7 +140,7 @@ test-resource-debug: FAILED_TESTS="$$FAILED_TESTS$$TEST_NAME\n"; \ fi; \ else \ - if TF_LOG=$$LOGLEVEL TF_ACC=1 go test -v ./alicloud -run="^$$TEST_NAME\$$" -timeout 360m; then \ + if env $$VCR_ENV TF_LOG=$$LOGLEVEL TF_ACC=1 go test -v ./alicloud -run="^$$TEST_NAME\$$" -timeout 360m; then \ echo "✓ PASSED: $$TEST_NAME"; \ else \ echo "✗ FAILED: $$TEST_NAME"; \ @@ -252,7 +280,17 @@ sweep: TF_ACC=1 go test ./alicloud -v -sweep=$(REGION) -sweep-run=$(RESOURCE); \ fi -.PHONY: build test testacc test-resource test-resource-debug vet fmt fmtcheck errcheck test-compile website website-test commit ci-check ci-check-quick minimal-test-set sweep +# List available VCR cassette files +vcr-list: + @echo "Available cassettes:" + @find alicloud/$(VCR_DIR) -name "*.yaml" 2>/dev/null | sed 's|alicloud/$(VCR_DIR)/||;s|\.yaml$$||' | sort | sed 's/^/ /' || echo " (none)" + +# Clean all cassette files +vcr-clean: + @echo "Removing all cassettes in alicloud/$(VCR_DIR)/" + @rm -f alicloud/$(VCR_DIR)/*.yaml + +.PHONY: build test testacc acctest vet fmt fmtcheck errcheck test-compile website website-test commit ci-check ci-check-quick minimal-test-set sweep vcr-list vcr-clean all: mac windows linux diff --git a/alicloud/alicloud_sweeper_test.go b/alicloud/alicloud_sweeper_test.go index 5046ccd7f7d9..8210aea7a573 100644 --- a/alicloud/alicloud_sweeper_test.go +++ b/alicloud/alicloud_sweeper_test.go @@ -1,6 +1,7 @@ package alicloud import ( + "flag" "fmt" "log" "os" @@ -14,6 +15,16 @@ import ( ) func TestMain(m *testing.M) { + if os.Getenv("VCR_PATH") != "" { + // VCR mode: run tests, save cassettes, then exit. + // Cannot use resource.TestMain here because it calls os.Exit + // internally, which would skip the cassette save. + flag.Parse() + exitCode := m.Run() + connectivity.StopVCR() + os.Exit(exitCode) + } + // Default: sweeper support + standard test flow resource.TestMain(m) } diff --git a/alicloud/connectivity/client.go b/alicloud/connectivity/client.go index aeef584820e2..e4c798f8f294 100644 --- a/alicloud/connectivity/client.go +++ b/alicloud/connectivity/client.go @@ -2424,7 +2424,18 @@ func (client *AliyunClient) rpcRequest(method string, apiProductCode string, api } } sdkConfig := client.teaSdkConfig - sdkConfig.SetEndpoint(endpoint) + + // VCR: redirect to local proxy, pass original host via query param + if vcrAddr := VCRLocalAddr(); vcrAddr != "" { + if query == nil { + query = make(map[string]interface{}) + } + query["__vcr_host"] = endpoint + sdkConfig.SetEndpoint(vcrAddr) + sdkConfig.SetProtocol("HTTP") + } else { + sdkConfig.SetEndpoint(endpoint) + } credential, err := client.config.Credential.GetCredential() if err != nil || credential == nil { return nil, fmt.Errorf("get credential failed. Error: %#v", err) diff --git a/alicloud/connectivity/vcr.go b/alicloud/connectivity/vcr.go new file mode 100644 index 000000000000..ec56990d5bde --- /dev/null +++ b/alicloud/connectivity/vcr.go @@ -0,0 +1,471 @@ +package connectivity + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/httptest" + "net/url" + "os" + "regexp" + "sort" + "strconv" + "strings" + "sync" + + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" +) + +// vcrServer is the singleton VCR proxy server, started once per process. +var ( + vcrOnce sync.Once + vcrLocalAddr string // "127.0.0.1:PORT" + vcrRecorder *recorder.Recorder + vcrCleanup func() +) + +// VCRRandSeed returns a deterministic seed derived from VCR_PATH. +// Returns 0 when VCR is not enabled. +func VCRRandSeed() int64 { + p := os.Getenv("VCR_PATH") + if p == "" { + return 0 + } + h := sha256.Sum256([]byte(p)) + return int64(binary.LittleEndian.Uint64(h[:8])) +} + +// VCRRandIntRange is a drop-in replacement for acctest.RandIntRange. +// In VCR mode it returns deterministic values; otherwise falls through +// to a time-seeded source. +var vcrRandOnce sync.Once +var vcrRandSrc *rand.Rand + +func VCRRandIntRange(min, max int) int { + if os.Getenv("VCR_PATH") == "" { + // Not in VCR mode — behave like acctest.RandIntRange + return min + rand.New(rand.NewSource(rand.Int63())).Intn(max-min) + } + vcrRandOnce.Do(func() { + vcrRandSrc = rand.New(rand.NewSource(VCRRandSeed())) + }) + return min + vcrRandSrc.Intn(max-min) +} + +// VCRLocalAddr returns the local VCR proxy address if VCR is enabled, or "". +// On first call it lazily starts the proxy server. +func VCRLocalAddr() string { + if os.Getenv("VCR_PATH") == "" { + return "" + } + vcrOnce.Do(startVCR) + return vcrLocalAddr +} + +// StopVCR stops the VCR recorder and saves the cassette. Call in TestMain or defer. +func StopVCR() { + if vcrCleanup != nil { + vcrCleanup() + } +} + +func startVCR() { + vcrPath := os.Getenv("VCR_PATH") + if vcrPath == "" { + return + } + + mode := os.Getenv("VCR_MODE") + + var transport http.RoundTripper + if mode == "replay" { + // Custom replay transport with sequential consumption + fallback + rt, err := newVcrReplayTransport(vcrPath) + if err != nil { + fmt.Fprintf(os.Stderr, "[VCR] Failed to load cassette: %v\n", err) + return + } + transport = rt + } else { + // Use go-vcr recorder for recording + rec, err := newVCRRecorder(vcrPath) + if err != nil { + fmt.Fprintf(os.Stderr, "[VCR] Failed to create recorder: %v\n", err) + return + } + vcrRecorder = rec + transport = rec + } + + // Start local HTTP reverse proxy backed by VCR + server := httptest.NewServer(&vcrProxyHandler{rec: transport}) + vcrLocalAddr = strings.TrimPrefix(server.URL, "http://") + + // Bypass system HTTP proxy for VCR server (tea SDK does exact host:port match) + noProxy := os.Getenv("NO_PROXY") + if noProxy != "" { + os.Setenv("NO_PROXY", noProxy+","+vcrLocalAddr) + } else { + os.Setenv("NO_PROXY", vcrLocalAddr) + } + + fmt.Fprintf(os.Stderr, "[VCR] Proxy at %s mode=%s cassette=%s\n", vcrLocalAddr, mode, vcrPath) + + vcrCleanup = func() { + server.Close() + if vcrRecorder != nil { + if err := vcrRecorder.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "[VCR] Stop error: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "[VCR] Cassette saved\n") + } + } else { + fmt.Fprintf(os.Stderr, "[VCR] Cassette saved\n") + } + } +} + +// newVCRRecorder creates a go-vcr recorder for recording mode. +func newVCRRecorder(cassettePath string) (*recorder.Recorder, error) { + return recorder.New(cassettePath, []recorder.Option{ + recorder.WithMode(recorder.ModeRecordOnly), + recorder.WithSkipRequestLatency(true), + recorder.WithMatcher(vcrMatcher), + recorder.WithHook(sanitizeCassette, recorder.BeforeSaveHook), + }...) +} + +// ---------- Custom replay transport ---------- + +// vcrReplayTransport replays cassette interactions with sequential consumption. +// When a request matches multiple recorded interactions (e.g. DescribeVpcAttribute +// called before and after ModifyVpcAttribute), it returns them in order. +// If all matching interactions are consumed, it falls back to the last consumed +// match — handling extra calls that weren't in the recording. +type vcrReplayTransport struct { + interactions []*cassette.Interaction + mu sync.Mutex + consumed map[int]bool // interaction index -> consumed +} + +func newVcrReplayTransport(cassettePath string) (*vcrReplayTransport, error) { + cas, err := cassette.Load(cassettePath) + if err != nil { + return nil, err + } + t := &vcrReplayTransport{ + interactions: cas.Interactions, + consumed: make(map[int]bool), + } + return t, nil +} + +func (t *vcrReplayTransport) RoundTrip(r *http.Request) (*http.Response, error) { + t.mu.Lock() + defer t.mu.Unlock() + + // Read body so we can match against it (and restore for potential retries) + var bodyBytes []byte + if r.Body != nil { + bodyBytes, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + // First pass: find first unconsumed matching interaction + lastConsumedMatch := -1 + for idx, interaction := range t.interactions { + // Restore body for each matcher call + if bodyBytes != nil { + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + if !vcrMatcher(r, interaction.Request) { + continue + } + if t.consumed[idx] { + lastConsumedMatch = idx + continue + } + // Found unconsumed match — consume and return + t.consumed[idx] = true + return buildHTTPResponse(interaction), nil + } + + // Fallback: all matching interactions consumed, reuse the last consumed match + if lastConsumedMatch >= 0 { + return buildHTTPResponse(t.interactions[lastConsumedMatch]), nil + } + + return nil, fmt.Errorf("requested interaction not found") +} + +// buildHTTPResponse converts a cassette interaction to an http.Response. +func buildHTTPResponse(i *cassette.Interaction) *http.Response { + header := make(http.Header) + for k, vals := range i.Response.Headers { + for _, v := range vals { + header.Add(k, v) + } + } + return &http.Response{ + Status: i.Response.Status, + StatusCode: i.Response.Code, + Proto: i.Response.Proto, + ProtoMajor: i.Response.ProtoMajor, + ProtoMinor: i.Response.ProtoMinor, + Header: header, + Body: io.NopCloser(strings.NewReader(i.Response.Body)), + ContentLength: i.Response.ContentLength, + } +} + +// ---------- VCR proxy handler ---------- + +type vcrProxyHandler struct { + rec http.RoundTripper +} + +func (h *vcrProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Read body + var bodyBytes []byte + if r.Body != nil { + bodyBytes, _ = io.ReadAll(r.Body) + r.Body.Close() + } + + // Read the original host from the per-request query parameter. + originalHost := r.URL.Query().Get("__vcr_host") + if originalHost == "" { + originalHost = r.Host // fallback + } + + outURL := fmt.Sprintf("https://%s%s", originalHost, r.URL.RequestURI()) + outReq, err := http.NewRequest(r.Method, outURL, bytes.NewReader(bodyBytes)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for k, vv := range r.Header { + // Skip host headers — they contain the local proxy address. + if strings.EqualFold(k, "Host") { + continue + } + for _, v := range vv { + outReq.Header.Add(k, v) + } + } + outReq.Host = originalHost + + resp, err := h.rec.RoundTrip(outReq) + if err != nil { + // Extract Action for better error logging + action := r.URL.Query().Get("Action") + if action == "" { + action = originalHost + r.URL.Path + } + fmt.Fprintf(os.Stderr, "[VCR] %s %s → error: %v\n", r.Method, action, err) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + n, _ := io.Copy(w, resp.Body) + + // Extract Action from query for concise logging + action := r.URL.Query().Get("Action") + if action == "" { + action = r.URL.Path + } + fmt.Fprintf(os.Stderr, "[VCR] %s %s → %d (%d bytes)\n", r.Method, action, resp.StatusCode, n) +} + +// ---------- Matcher ---------- + +func vcrMatcher(r *http.Request, i cassette.Request) bool { + if r.Method != i.Method { + return false + } + reqURL, err := url.Parse(i.URL) + if err != nil { + return r.URL.String() == i.URL + } + // Match host + path + if r.URL.Host != reqURL.Host || r.URL.Path != reqURL.Path { + return false + } + // Match query params (ignoring signature/auth params that change per request) + if filterSignatureParams(r.URL.Query()).Encode() != + filterSignatureParams(reqURL.Query()).Encode() { + return false + } + // Match request body (form-encoded API parameters like VpcId, VpcName, etc.) + // This distinguishes CreateVpc(name=A) from CreateVpc(name=B) and + // DescribeVpc(id=X) from DescribeVpc(id=Y). + // Normalize numbered params (Tag.N.Key etc.) to handle Go map iteration + // non-determinism between recording and replay. + return normalizeNumberedParams(filterSignatureParams(requestFormValues(r))).Encode() == + normalizeNumberedParams(filterSignatureParams(i.Form)).Encode() +} + +// requestFormValues extracts all form values (query string + body) from an +// http.Request, matching go-vcr's cassette.Request.Form behavior. +func requestFormValues(r *http.Request) url.Values { + // Merge query params + body params, same as http.Request.ParseForm() + vals := make(url.Values) + for k, v := range r.URL.Query() { + vals[k] = v + } + if r.Body != nil { + body, err := io.ReadAll(r.Body) + if err == nil { + r.Body = io.NopCloser(bytes.NewReader(body)) + if bodyVals, err := url.ParseQuery(string(body)); err == nil { + for k, v := range bodyVals { + vals[k] = append(vals[k], v...) + } + } + } + } + return vals +} + +// sanitizeCassette removes sensitive data from recorded interactions. +// Follows AWS provider convention: strip auth headers and redact credentials +// in URLs/forms. Response bodies are left intact (cassettes are not committed). +func sanitizeCassette(i *cassette.Interaction) error { + // Request headers + delete(i.Request.Headers, "Authorization") + delete(i.Request.Headers, "X-Acs-Security-Token") + + // Request URL: redact credential parameters + i.Request.URL = redactURLParams(i.Request.URL) + + // Request Form fields + for _, k := range []string{"AccessKeyId", "SecurityToken", "Signature", "SignatureNonce"} { + if _, ok := i.Request.Form[k]; ok { + i.Request.Form[k] = []string{"REDACTED"} + } + } + // Strip VCR-internal parameter + delete(i.Request.Form, "__vcr_host") + + return nil +} + +// redactURLParams replaces sensitive query parameters in a URL string. +func redactURLParams(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + q := u.Query() + for _, k := range []string{"AccessKeyId", "SecurityToken", "Signature", "SignatureNonce"} { + if q.Get(k) != "" { + q.Set(k, "REDACTED") + } + } + // Strip VCR-internal parameter from recorded URL + q.Del("__vcr_host") + u.RawQuery = q.Encode() + return u.String() +} + +// numberedParamRe matches Alibaba Cloud API indexed params like Tag.1.Key, +// Tag.2.Value, Filter.1.Key, etc. Go map iteration is non-deterministic, so +// the same map{"A":"1","B":"2"} might produce Tag.1.Key=A or Tag.1.Key=B +// across runs. This function renumbers them canonically (sorted by values). +var numberedParamRe = regexp.MustCompile(`^(.+)\.(\d+)\.(.+)$`) + +func normalizeNumberedParams(vals url.Values) url.Values { + result := make(url.Values) + + // prefix -> index -> field -> value + type entry struct { + fields map[string]string + } + groups := make(map[string]map[int]*entry) + + for k, v := range vals { + m := numberedParamRe.FindStringSubmatch(k) + if m == nil { + result[k] = v + continue + } + prefix := m[1] + idx, _ := strconv.Atoi(m[2]) + field := m[3] + if groups[prefix] == nil { + groups[prefix] = make(map[int]*entry) + } + if groups[prefix][idx] == nil { + groups[prefix][idx] = &entry{fields: make(map[string]string)} + } + groups[prefix][idx].fields[field] = v[0] + } + + // Sort each group's entries by their field values and renumber + for prefix, items := range groups { + entries := make([]*entry, 0, len(items)) + for _, e := range items { + entries = append(entries, e) + } + sort.Slice(entries, func(i, j int) bool { + return entrySortKey(entries[i].fields) < entrySortKey(entries[j].fields) + }) + for i, e := range entries { + for field, value := range e.fields { + result[fmt.Sprintf("%s.%d.%s", prefix, i+1, field)] = []string{value} + } + } + } + + return result +} + +// entrySortKey builds a canonical string from a map for sorting. +func entrySortKey(fields map[string]string) string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + for _, k := range keys { + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(fields[k]) + sb.WriteByte('&') + } + return sb.String() +} + +func filterSignatureParams(q url.Values) url.Values { + filtered := make(url.Values) + skip := map[string]bool{ + // Auth/signature params (change every request) + "Signature": true, "SignatureNonce": true, + "Timestamp": true, "SecurityToken": true, + "AccessKeyId": true, "SignatureMethod": true, + "SignatureVersion": true, + // Idempotency token (random UUID per apply) + "ClientToken": true, + // VCR internal: original API host passed per-request + "__vcr_host": true, + } + for k, v := range q { + if !skip[k] { + filtered[k] = v + } + } + return filtered +} diff --git a/alicloud/connectivity/vcr_test.go b/alicloud/connectivity/vcr_test.go new file mode 100644 index 000000000000..cb487dc13455 --- /dev/null +++ b/alicloud/connectivity/vcr_test.go @@ -0,0 +1,273 @@ +package connectivity + +import ( + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" +) + +// --- VCRLocalAddr --- + +func TestUnitCommonVCRLocalAddr_Disabled(t *testing.T) { + os.Unsetenv("VCR_PATH") + assert.Equal(t, "", VCRLocalAddr()) +} + +// --- __vcr_host query param filtering --- + +func TestUnitCommonFilterSignatureParams_VCRHost(t *testing.T) { + q := url.Values{ + "Action": {"DescribeVpcs"}, + "__vcr_host": {"vpc.aliyuncs.com"}, + } + filtered := filterSignatureParams(q) + assert.Equal(t, "DescribeVpcs", filtered.Get("Action")) + assert.Empty(t, filtered.Get("__vcr_host"), "__vcr_host should be filtered out") +} + +// --- VCRRandSeed --- + +func TestUnitCommonVCRRandSeed_Disabled(t *testing.T) { + os.Unsetenv("VCR_PATH") + assert.Equal(t, int64(0), VCRRandSeed()) +} + +func TestUnitCommonVCRRandSeed_Deterministic(t *testing.T) { + os.Setenv("VCR_PATH", "testdata/vcr/vpc") + defer os.Unsetenv("VCR_PATH") + + seed1 := VCRRandSeed() + seed2 := VCRRandSeed() + assert.Equal(t, seed1, seed2, "same path should produce same seed") + assert.NotEqual(t, int64(0), seed1) +} + +func TestUnitCommonVCRRandSeed_DifferentPaths(t *testing.T) { + os.Setenv("VCR_PATH", "path/a") + seedA := VCRRandSeed() + + os.Setenv("VCR_PATH", "path/b") + seedB := VCRRandSeed() + os.Unsetenv("VCR_PATH") + + assert.NotEqual(t, seedA, seedB, "different paths should produce different seeds") +} + +// --- vcrMatcher --- + +func TestUnitCommonVCRMatcher_MethodMismatch(t *testing.T) { + r := &http.Request{Method: "GET", URL: mustParseURL("https://vpc.aliyuncs.com/?Action=DescribeVpcs")} + i := cassette.Request{Method: "POST", URL: "https://vpc.aliyuncs.com/?Action=DescribeVpcs"} + assert.False(t, vcrMatcher(r, i)) +} + +func TestUnitCommonVCRMatcher_ExactMatch(t *testing.T) { + r := &http.Request{Method: "POST", URL: mustParseURL("https://vpc.aliyuncs.com/?Action=CreateVpc&RegionId=cn-hangzhou")} + i := cassette.Request{ + Method: "POST", + URL: "https://vpc.aliyuncs.com/?Action=CreateVpc&RegionId=cn-hangzhou", + Form: url.Values{"Action": {"CreateVpc"}, "RegionId": {"cn-hangzhou"}}, + } + assert.True(t, vcrMatcher(r, i)) +} + +func TestUnitCommonVCRMatcher_IgnoresSignatureParams(t *testing.T) { + r := &http.Request{ + Method: "POST", + URL: mustParseURL("https://vpc.aliyuncs.com/?Action=CreateVpc&AccessKeyId=ak1&Signature=sig1&Timestamp=t1&SignatureNonce=n1"), + } + i := cassette.Request{ + Method: "POST", + URL: "https://vpc.aliyuncs.com/?Action=CreateVpc&AccessKeyId=ak2&Signature=sig2&Timestamp=t2&SignatureNonce=n2", + Form: url.Values{"Action": {"CreateVpc"}, "AccessKeyId": {"ak2"}, "Signature": {"sig2"}, "Timestamp": {"t2"}, "SignatureNonce": {"n2"}}, + } + assert.True(t, vcrMatcher(r, i), "should match ignoring signature params") +} + +func TestUnitCommonVCRMatcher_DifferentAction(t *testing.T) { + r := &http.Request{Method: "POST", URL: mustParseURL("https://vpc.aliyuncs.com/?Action=CreateVpc")} + i := cassette.Request{Method: "POST", URL: "https://vpc.aliyuncs.com/?Action=DeleteVpc"} + assert.False(t, vcrMatcher(r, i)) +} + +func TestUnitCommonVCRMatcher_DifferentHost(t *testing.T) { + r := &http.Request{Method: "POST", URL: mustParseURL("https://vpc.aliyuncs.com/?Action=Test")} + i := cassette.Request{Method: "POST", URL: "https://ecs.aliyuncs.com/?Action=Test"} + assert.False(t, vcrMatcher(r, i)) +} + +func TestUnitCommonVCRMatcher_BodyDistinguishes(t *testing.T) { + r := &http.Request{ + Method: "POST", + URL: mustParseURL("https://vpc.aliyuncs.com/?Action=DescribeVpcAttribute"), + Body: io.NopCloser(strings.NewReader("VpcId=vpc-001&RegionId=cn-hangzhou")), + } + // cassette Form = query params + body params (like Go's ParseForm) + iMatch := cassette.Request{ + Method: "POST", + URL: "https://vpc.aliyuncs.com/?Action=DescribeVpcAttribute", + Form: url.Values{"Action": {"DescribeVpcAttribute"}, "VpcId": {"vpc-001"}, "RegionId": {"cn-hangzhou"}}, + } + iDiff := cassette.Request{ + Method: "POST", + URL: "https://vpc.aliyuncs.com/?Action=DescribeVpcAttribute", + Form: url.Values{"Action": {"DescribeVpcAttribute"}, "VpcId": {"vpc-999"}, "RegionId": {"cn-hangzhou"}}, + } + assert.True(t, vcrMatcher(r, iMatch), "same body should match") + // Re-create request since body was consumed + r.Body = io.NopCloser(strings.NewReader("VpcId=vpc-001&RegionId=cn-hangzhou")) + assert.False(t, vcrMatcher(r, iDiff), "different body should not match") +} + +// --- sanitizeCassette --- + +func TestUnitCommonSanitizeCassette_Headers(t *testing.T) { + i := &cassette.Interaction{ + Request: cassette.Request{ + Headers: http.Header{ + "Authorization": {"Bearer secret"}, + "X-Acs-Security-Token": {"token123"}, + "Content-Type": {"application/json"}, + }, + }, + } + err := sanitizeCassette(i) + assert.NoError(t, err) + assert.Empty(t, i.Request.Headers["Authorization"]) + assert.Empty(t, i.Request.Headers["X-Acs-Security-Token"]) + assert.Equal(t, "application/json", i.Request.Headers.Get("Content-Type")) +} + +func TestUnitCommonSanitizeCassette_URL(t *testing.T) { + i := &cassette.Interaction{ + Request: cassette.Request{ + URL: "https://vpc.aliyuncs.com/?AccessKeyId=LTAI123&Action=CreateVpc&SecurityToken=STS_TOKEN&Signature=abc123&SignatureNonce=nonce1", + Headers: http.Header{}, + }, + } + err := sanitizeCassette(i) + assert.NoError(t, err) + + u, _ := url.Parse(i.Request.URL) + q := u.Query() + assert.Equal(t, "REDACTED", q.Get("AccessKeyId")) + assert.Equal(t, "REDACTED", q.Get("SecurityToken")) + assert.Equal(t, "REDACTED", q.Get("Signature")) + assert.Equal(t, "REDACTED", q.Get("SignatureNonce")) + assert.Equal(t, "CreateVpc", q.Get("Action"), "non-sensitive params should be preserved") +} + +func TestUnitCommonSanitizeCassette_FormFields(t *testing.T) { + i := &cassette.Interaction{ + Request: cassette.Request{ + Headers: http.Header{}, + Form: url.Values{ + "AccessKeyId": {"LTAI123"}, + "SecurityToken": {"STS_TOKEN"}, + "Signature": {"abc"}, + "SignatureNonce": {"nonce"}, + "Action": {"CreateVpc"}, + "VpcName": {"my-vpc"}, + }, + }, + } + err := sanitizeCassette(i) + assert.NoError(t, err) + assert.Equal(t, []string{"REDACTED"}, i.Request.Form["AccessKeyId"]) + assert.Equal(t, []string{"REDACTED"}, i.Request.Form["SecurityToken"]) + assert.Equal(t, []string{"REDACTED"}, i.Request.Form["Signature"]) + assert.Equal(t, []string{"REDACTED"}, i.Request.Form["SignatureNonce"]) + assert.Equal(t, []string{"CreateVpc"}, i.Request.Form["Action"]) + assert.Equal(t, []string{"my-vpc"}, i.Request.Form["VpcName"]) +} + +// --- redactURLParams --- + +func TestUnitCommonRedactURLParams_Full(t *testing.T) { + raw := "https://vpc.aliyuncs.com/?AccessKeyId=AK&Action=Test&SecurityToken=ST&Signature=SIG&SignatureNonce=SN" + result := redactURLParams(raw) + u, _ := url.Parse(result) + q := u.Query() + assert.Equal(t, "REDACTED", q.Get("AccessKeyId")) + assert.Equal(t, "REDACTED", q.Get("SecurityToken")) + assert.Equal(t, "Test", q.Get("Action")) +} + +func TestUnitCommonRedactURLParams_NoSensitiveParams(t *testing.T) { + raw := "https://vpc.aliyuncs.com/?Action=Test&RegionId=cn-hangzhou" + result := redactURLParams(raw) + u, _ := url.Parse(result) + q := u.Query() + assert.Equal(t, "Test", q.Get("Action")) + assert.Equal(t, "cn-hangzhou", q.Get("RegionId")) +} + +func TestUnitCommonRedactURLParams_InvalidURL(t *testing.T) { + raw := "://invalid" + assert.Equal(t, raw, redactURLParams(raw)) +} + +// --- filterSignatureParams --- + +func TestUnitCommonFilterSignatureParams(t *testing.T) { + q := url.Values{ + "Action": {"CreateVpc"}, + "RegionId": {"cn-hangzhou"}, + "AccessKeyId": {"AK123"}, + "Signature": {"sig"}, + "SignatureNonce": {"nonce"}, + "Timestamp": {"2026-01-01"}, + "SecurityToken": {"token"}, + "SignatureMethod": {"HMAC-SHA1"}, + "SignatureVersion": {"1.0"}, + } + filtered := filterSignatureParams(q) + assert.Equal(t, "CreateVpc", filtered.Get("Action")) + assert.Equal(t, "cn-hangzhou", filtered.Get("RegionId")) + assert.Empty(t, filtered.Get("AccessKeyId")) + assert.Empty(t, filtered.Get("Signature")) + assert.Empty(t, filtered.Get("Timestamp")) +} + +// --- normalizeNumberedParams --- + +func TestUnitCommonNormalizeNumberedParams(t *testing.T) { + // Tag.1.Key=For, Tag.2.Key=Created should normalize the same as + // Tag.1.Key=Created, Tag.2.Key=For (sorted by values) + a := url.Values{ + "Action": {"TagResources"}, + "Tag.1.Key": {"For"}, + "Tag.1.Value": {"Test"}, + "Tag.2.Key": {"Created"}, + "Tag.2.Value": {"TF"}, + } + b := url.Values{ + "Action": {"TagResources"}, + "Tag.1.Key": {"Created"}, + "Tag.1.Value": {"TF"}, + "Tag.2.Key": {"For"}, + "Tag.2.Value": {"Test"}, + } + assert.Equal(t, normalizeNumberedParams(a).Encode(), normalizeNumberedParams(b).Encode(), + "same tags in different order should normalize identically") + + // Non-numbered params should pass through unchanged + c := url.Values{"Action": {"DescribeVpcs"}, "RegionId": {"cn-hangzhou"}} + assert.Equal(t, c.Encode(), normalizeNumberedParams(c).Encode()) +} + +// --- helpers --- + +func mustParseURL(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + panic(err) + } + return u +} diff --git a/alicloud/resource_alicloud_vpc_test.go b/alicloud/resource_alicloud_vpc_test.go index c4216bfc9322..03e4198063ac 100644 --- a/alicloud/resource_alicloud_vpc_test.go +++ b/alicloud/resource_alicloud_vpc_test.go @@ -16,7 +16,6 @@ import ( util "github.com/alibabacloud-go/tea-utils/service" "github.com/alibabacloud-go/tea/tea" "github.com/aliyun/terraform-provider-alicloud/alicloud/connectivity" - "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/terraform" @@ -144,7 +143,7 @@ func TestAccAliCloudVPC_basic(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(1000000, 9999999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence) resource.Test(t, resource.TestCase{ @@ -299,7 +298,7 @@ func TestAccAliCloudVPC_enableIpv6(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(1000000, 9999999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence1) resource.Test(t, resource.TestCase{ @@ -449,7 +448,7 @@ func TestAccAliCloudVPC_basic1(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence1) resource.Test(t, resource.TestCase{ @@ -517,7 +516,7 @@ func TestAccAliCloudVPC_basic2(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence1) resource.Test(t, resource.TestCase{ @@ -580,7 +579,7 @@ func TestAccAliCloudVPC_isNotDefault(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence1) resource.Test(t, resource.TestCase{ @@ -643,7 +642,7 @@ func TestAccAliCloudVPC_isDefault(t *testing.T) { }, "DescribeVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testAcc%sVpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcBasicDependence1) resource.Test(t, resource.TestCase{ @@ -1152,7 +1151,7 @@ func TestAccAliCloudVpcVpc_basic3113(t *testing.T) { }, "DescribeVpcVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testacc%svpcvpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcVpcBasicDependence3113) resource.Test(t, resource.TestCase{ @@ -1359,7 +1358,7 @@ func TestAccAliCloudVpcVpc_basic3113_twin(t *testing.T) { }, "DescribeVpcVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testacc%svpcvpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcVpcBasicDependence3113) resource.Test(t, resource.TestCase{ @@ -1471,7 +1470,7 @@ func TestAccAliCloudVpcVpc_basic9656(t *testing.T) { }, "DescribeVpcVpc") rac := resourceAttrCheckInit(rc, ra) testAccCheck := rac.resourceAttrMapUpdateSet() - rand := acctest.RandIntRange(10000, 99999) + rand := connectivity.VCRRandIntRange(10000, 99999) name := fmt.Sprintf("tf-testacc%svpcvpc%d", defaultRegionToTest, rand) testAccConfig := resourceTestAccConfigFunc(resourceId, name, AlicloudVpcVpcBasicDependence9656) resource.Test(t, resource.TestCase{ diff --git a/go.mod b/go.mod index 954c4c15800d..3b8dd7a3f566 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/samber/lo v1.49.1 github.com/tidwall/sjson v1.2.5 golang.org/x/tools v0.39.0 + gopkg.in/dnaeon/go-vcr.v4 v4.0.6 gopkg.in/yaml.v3 v3.0.1 ) @@ -193,6 +194,7 @@ require ( go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/go.sum b/go.sum index 95c21fc4a80d..0234f7d27041 100644 --- a/go.sum +++ b/go.sum @@ -998,6 +998,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -1633,6 +1635,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= diff --git a/scripts/local-ci-check.sh b/scripts/local-ci-check.sh index 70dec5e3622a..010e1024481b 100755 --- a/scripts/local-ci-check.sh +++ b/scripts/local-ci-check.sh @@ -782,7 +782,7 @@ else echo -e "${BLUE}▶ Running integration tests for: ${resource}${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - if make -C "$PROJECT_ROOT" test-resource-debug RESOURCE="${resource}"; then + if make -C "$PROJECT_ROOT" acctest RESOURCE="${resource}"; then echo -e "${GREEN}✓ PASSED: Resource Integration Test (${resource})${NC}" PASSED_CHECKS+=("Resource Integration Test (${resource})") else diff --git a/scripts/testing/minimal_test_set_calculator.go b/scripts/testing/minimal_test_set_calculator.go index 46103229c676..102e29226f89 100644 --- a/scripts/testing/minimal_test_set_calculator.go +++ b/scripts/testing/minimal_test_set_calculator.go @@ -351,7 +351,7 @@ func outputSummary(result *MinimalTestSetResult) { } fmt.Printf("\nRun command:\n") - fmt.Printf(" make test-resource-debug RESOURCE=alicloud_%s TESTCASE=\"%s\"\n", + fmt.Printf(" make acctest RESOURCE=alicloud_%s TESTCASE=\"%s\"\n", result.ResourceName, strings.Join(result.MinimalTestSet, "|")) fmt.Println() }