You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/03/27 03:20:12 UTC

(skywalking-infra-e2e) branch main updated: feat: add collect-on-failure to copy files from pods/containers before cleanup (#141)

This is an automated email from the ASF dual-hosted git repository.

wusheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-infra-e2e.git


The following commit(s) were added to refs/heads/main by this push:
     new 36221fa  feat: add collect-on-failure to copy files from pods/containers before cleanup (#141)
36221fa is described below

commit 36221fa3e395c63fa68f1fc58efdd2c5e5d10c0f
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Mar 27 11:20:08 2026 +0800

    feat: add collect-on-failure to copy files from pods/containers before cleanup (#141)
    
    ## Summary
    
    - Adds `cleanup.collect` config section to copy files from inside pods (Kind) or containers (Compose) when e2e tests fail, before the environment is destroyed
    - Collection runs independently of `cleanup.on` — has its own `collect.on` condition (always/failure/never)
    - Fires on any phase failure (setup/trigger/verify), tolerates unreachable targets with explicit logging
    - Adds standalone `e2e collect` command for manual debugging with `e2e setup`
    - Also auto-collects `kubectl describe` (Kind) / `docker inspect` (Compose) alongside files
    
    ### Config example
    
    ```yaml
    cleanup:
      on: always
      collect:
        on: failure
        output-dir: /tmp/e2e-collect
        items:
          # Kind
          - namespace: default
            label-selector: app=oap
            paths:
              - /skywalking/logs/
          # Compose
          - service: oap-service
            paths:
              - /skywalking/logs/
    ```
    
    ### Output structure
    
    Files are organized by full source path to avoid collisions:
    - Kind: `output-dir/<namespace>/<pod-name>/<source-path>`
    - Compose: `output-dir/<service-name>/<source-path>`
---
 .github/workflows/e2e-test.yaml                    |   7 +-
 CLAUDE.md                                          | 155 +++++++++++++++++
 Makefile                                           |  16 ++
 .../cleanup.go => commands/collect/collect.go      |  30 +++-
 commands/root.go                                   |   6 +
 commands/run/run.go                                |  31 +++-
 docs/en/setup/Configuration-File.md                |  37 ++++-
 internal/components/cleanup/compose.go             |   8 +-
 internal/components/collector/collector.go         |  58 +++++++
 internal/components/collector/collector_test.go    | 132 +++++++++++++++
 internal/components/collector/compose.go           | 148 +++++++++++++++++
 internal/components/collector/kind.go              | 183 +++++++++++++++++++++
 internal/components/setup/common.go                |   9 -
 internal/components/setup/compose.go               |   2 +-
 internal/config/e2eConfig.go                       |  33 +++-
 internal/config/e2eConfig_test.go                  | 180 ++++++++++++++++++++
 internal/constant/cleanup.go                       |   4 +
 internal/util/utils.go                             |   8 +
 test/e2e/kind/collect-on-failure/deployment.yaml   |  34 ++++
 test/e2e/kind/collect-on-failure/e2e.yaml          |  55 +++++++
 test/e2e/kind/collect-on-failure/expected.yaml     |  18 ++
 21 files changed, 1128 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml
index 50877db..463393e 100644
--- a/.github/workflows/e2e-test.yaml
+++ b/.github/workflows/e2e-test.yaml
@@ -60,9 +60,14 @@ jobs:
         with:
           e2e-file: ./test/e2e/e2e.yaml
 
-      - uses: engineerd/setup-kind@v0.6.2
+      - name: Install KinD
+        uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
         with:
           version: "v0.27.0"
+          install_only: true
 
       - name: Run KinD E2E Test
         run: make e2e-test-kind
+
+      - name: Run KinD Collect-on-Failure E2E Test
+        run: make e2e-test-kind-collect
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..04a5049
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,155 @@
+# CLAUDE.md - Project Guide for skywalking-infra-e2e
+
+## Project Overview
+
+Apache SkyWalking Infra E2E is a CLI tool for end-to-end testing. It orchestrates test environments
+(Kubernetes/Kind or Docker Compose), generates traffic, verifies results, and cleans up.
+
+## Build & Test Commands
+
+```bash
+make all              # clean + lint + test + build
+make test             # run unit tests with coverage
+make lint             # run golangci-lint (auto-installs if missing)
+make build            # build for windows/linux/darwin
+make darwin           # build for macOS only (use your current OS target)
+make e2e-test         # run e2e test with Docker Compose (test/e2e/e2e.yaml)
+make e2e-test-kind    # run e2e test with Kind (test/e2e/kind/e2e.yaml)
+```
+
+- Go module: `github.com/apache/skywalking-infra-e2e`
+- Entry point: `cmd/e2e/main.go`
+- Binary output: `bin/<os>/e2e`
+- Version injected via ldflags at build time
+
+## Architecture
+
+### CLI Commands (Cobra)
+
+| Command       | File                          | Purpose                        |
+|---------------|-------------------------------|--------------------------------|
+| `e2e run`     | `commands/run/run.go`         | Full lifecycle orchestration   |
+| `e2e setup`   | `commands/setup/setup.go`     | Setup env only (debug mode)    |
+| `e2e trigger` | `commands/trigger/trigger.go` | Run trigger only               |
+| `e2e verify`  | `commands/verify/verify.go`   | Run verification only          |
+| `e2e cleanup` | `commands/cleanup/cleanup.go` | Run cleanup only               |
+
+Global flags defined in `commands/root.go`:
+- `-c, --config` (default: `e2e.yaml`)
+- `-v, --verbosity` (debug/info/warn/error)
+- `-w, --work-dir` (default: `~/.skywalking-infra-e2e`)
+- `-l, --log-dir` (default: `~/.skywalking-infra-e2e/logs`)
+
+### Lifecycle (`e2e run`)
+
+```
+setup → trigger → verify → cleanup (deferred)
+```
+
+Cleanup runs via Go `defer` and is controlled by `cleanup.on`:
+- `always` / `success` / `failure` / `never`
+- Default: `success` locally, `always` in CI (`CI=true` env var)
+- Constants in `internal/constant/cleanup.go`
+
+### Environment Modes
+
+Determined by `setup.env` in e2e.yaml (`"kind"` or `"compose"`).
+
+Constants: `constant.Kind` and `constant.Compose` in `internal/constant/`.
+
+**Kind mode** (`internal/components/setup/kind.go`):
+- Creates Kind cluster, loads Docker images, applies K8s manifests
+- Pod log streaming via K8s client-go
+- Port forwarding via SPDY
+- Cleanup: `kind delete cluster` with retry (up to 5x)
+
+**Compose mode** (`internal/components/setup/compose.go`):
+- Uses testcontainers-go for Docker Compose
+- Container log streaming
+- Cleanup: `docker-compose down`
+
+### Configuration
+
+Config struct: `internal/config/e2eConfig.go` → `E2EConfig`
+
+```yaml
+setup:
+  env: kind|compose
+  file: path/to/kind-config.yaml  # or docker-compose.yml
+  kubeconfig: path                # alternative to file (use existing cluster)
+  timeout: 20m
+  steps:
+    - name: step-name
+      path: manifest.yaml         # or command: "shell cmd"
+      wait:
+        - namespace: default
+          resource: pod
+          label-selector: app=foo
+          for: condition=Ready
+  kind:
+    import-images: [image:tag]
+    expose-ports:
+      - namespace: default
+        resource: pod/name
+        port: "8080"
+
+cleanup:
+  on: always|success|failure|never
+
+trigger:
+  action: http
+  interval: 3s
+  times: 5
+  url: http://...
+  method: GET
+
+verify:
+  retry: { count: 10, interval: 10s }
+  fail-fast: true
+  concurrency: false
+  cases:
+    - name: case-name
+      query: "shell command"    # or actual: path/to/file
+      expected: path/to/expected.yaml
+```
+
+### Key Packages
+
+| Package                              | Role                                      |
+|--------------------------------------|-------------------------------------------|
+| `internal/config/`                   | YAML config parsing, global config state  |
+| `internal/components/setup/`         | Kind & Compose setup implementations      |
+| `internal/components/trigger/`       | HTTP trigger action                       |
+| `internal/components/verifier/`      | Test case verification with retry         |
+| `internal/components/cleanup/`       | Kind & Compose cleanup implementations    |
+| `internal/util/`                     | K8s client, Docker helpers, env/log utils |
+| `internal/constant/`                 | Constants for both modes and cleanup       |
+| `internal/logger/`                   | Logrus-based logging                      |
+| `pkg/output/`                        | Test result formatting (YAML/summary)     |
+| `third-party/go/template/`          | Extended Go template functions for verify |
+
+### Test Structure
+
+**Unit tests** (6 files):
+- `internal/config/e2eConfig_test.go`
+- `internal/util/config_test.go`, `utils_test.go`
+- `commands/verify/verify_test.go`
+- `internal/components/verifier/verifier_test.go`
+- `third-party/go/template/funcs_test.go`
+
+**E2E tests** (`test/e2e/`):
+- `e2e.yaml` — Compose-based e2e test
+- `kind/e2e.yaml` — Kind-based e2e test
+- Verify scenarios under `concurrency/` and `non-concurrency/` dirs
+
+### Log Collection
+
+- Logs streamed during setup to `LogDir/namespace/podName.log` (Kind) or `LogDir/serviceName/std.log` (Compose)
+- `internal/util/env_log.go` — `ResourceLogFollower` manages log writers
+- GitHub Actions: log dir defaults to `${runner.temp}/skywalking-infra-e2e/logs`
+- **No existing mechanism to copy arbitrary files from containers on failure**
+
+### GitHub Actions Integration
+
+- `action.yaml` at project root defines the composite action
+- Inputs: e2e-file, log-dir, plus matrix vars for log isolation
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 5512de9..54fd69e 100644
--- a/Makefile
+++ b/Makefile
@@ -118,3 +118,19 @@ e2e-test-kind:
 	$(MAKE) $(GOOS)
 	docker pull busybox:latest
 	./bin/$(GOOS)/$(PROJECT) run -c ./test/e2e/kind/e2e.yaml
+
+.PHONY: e2e-test-kind-collect
+# Run E2E test with KinD to verify collect-on-failure functionality
+# This test intentionally fails verification to trigger file collection.
+# It succeeds if the collected files exist at the expected output directory.
+e2e-test-kind-collect:
+	$(MAKE) $(GOOS)
+	docker pull busybox:latest
+	rm -rf /tmp/e2e-collect-test
+	./bin/$(GOOS)/$(PROJECT) run -c ./test/e2e/kind/collect-on-failure/e2e.yaml || true
+	@echo "Verifying collected files..."
+	@test -d /tmp/e2e-collect-test/default && echo "PASS: output directory created" || (echo "FAIL: output directory not found" && exit 1)
+	@ls /tmp/e2e-collect-test/default/collect-test/describe.txt > /dev/null 2>&1 && echo "PASS: describe.txt collected" || (echo "FAIL: describe.txt not found" && exit 1)
+	@ls /tmp/e2e-collect-test/default/collect-test/test-data/logs > /dev/null 2>&1 && echo "PASS: logs/ collected" || (echo "FAIL: logs/ not found" && exit 1)
+	@ls /tmp/e2e-collect-test/default/collect-test/test-data/debug.txt > /dev/null 2>&1 && echo "PASS: debug.txt collected" || (echo "FAIL: debug.txt not found" && exit 1)
+	@echo "All collect-on-failure checks passed."
diff --git a/internal/constant/cleanup.go b/commands/collect/collect.go
similarity index 58%
copy from internal/constant/cleanup.go
copy to commands/collect/collect.go
index 72a72f4..5e4a72b 100644
--- a/internal/constant/cleanup.go
+++ b/commands/collect/collect.go
@@ -14,13 +14,29 @@
 // KIND, either express or implied.  See the License for the
 // specific language governing permissions and limitations
 // under the License.
-//
 
-package constant
+package collect
+
+import (
+	"fmt"
 
-const (
-	CleanUpOnSuccess = "success"
-	CleanUpOnFailure = "failure"
-	CleanUpAlways    = "always"
-	CleanUpNever     = "never"
+	"github.com/spf13/cobra"
+
+	"github.com/apache/skywalking-infra-e2e/internal/components/collector"
+	"github.com/apache/skywalking-infra-e2e/internal/config"
 )
+
+var Collect = &cobra.Command{
+	Use:   "collect",
+	Short: "Collect files from pods/containers for debugging",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if config.GlobalConfig.Error != nil {
+			return config.GlobalConfig.Error
+		}
+		err := collector.DoCollect(&config.GlobalConfig.E2EConfig)
+		if err != nil {
+			return fmt.Errorf("[Collect] %s", err)
+		}
+		return nil
+	},
+}
diff --git a/commands/root.go b/commands/root.go
index 31e3c8f..a08a95b 100644
--- a/commands/root.go
+++ b/commands/root.go
@@ -24,6 +24,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/apache/skywalking-infra-e2e/commands/cleanup"
+	"github.com/apache/skywalking-infra-e2e/commands/collect"
 	"github.com/apache/skywalking-infra-e2e/commands/run"
 	"github.com/apache/skywalking-infra-e2e/commands/setup"
 	"github.com/apache/skywalking-infra-e2e/commands/trigger"
@@ -66,6 +67,10 @@ var Root = &cobra.Command{
 			return err
 		}
 
+		// Finalize collect config after LogDir is expanded, so any paths that
+		// depend on the log directory see the fully resolved value.
+		config.GlobalConfig.E2EConfig.Cleanup.Collect.Finalize()
+
 		return nil
 	},
 }
@@ -88,6 +93,7 @@ func Execute() error {
 	Root.AddCommand(trigger.Trigger)
 	Root.AddCommand(verify.Verify)
 	Root.AddCommand(cleanup.Cleanup)
+	Root.AddCommand(collect.Collect)
 
 	Root.PersistentFlags().StringVarP(&verbosity, "verbosity", "v", logrus.InfoLevel.String(), "log level (debug, info, warn, error, fatal, panic")
 	Root.PersistentFlags().StringVarP(&util.WorkDir, "work-dir", "w", "~/.skywalking-infra-e2e", "the working directory for skywalking-infra-e2e")
diff --git a/commands/run/run.go b/commands/run/run.go
index e3a9432..6e44fae 100644
--- a/commands/run/run.go
+++ b/commands/run/run.go
@@ -22,6 +22,7 @@ import (
 	"github.com/apache/skywalking-infra-e2e/commands/setup"
 	"github.com/apache/skywalking-infra-e2e/commands/trigger"
 	"github.com/apache/skywalking-infra-e2e/commands/verify"
+	"github.com/apache/skywalking-infra-e2e/internal/components/collector"
 	t "github.com/apache/skywalking-infra-e2e/internal/components/trigger"
 	"github.com/apache/skywalking-infra-e2e/internal/config"
 	"github.com/apache/skywalking-infra-e2e/internal/constant"
@@ -43,7 +44,7 @@ var Run = &cobra.Command{
 	},
 }
 
-func runAccordingE2E() error {
+func runAccordingE2E() (err error) {
 	if config.GlobalConfig.Error != nil {
 		return config.GlobalConfig.Error
 	}
@@ -54,14 +55,24 @@ func runAccordingE2E() error {
 			action.Stop()
 		}
 	}
+
 	// If cleanup.on == Always and there is error in setup step, we should defer cleanup step right now.
 	cleanupOnCondition := config.GlobalConfig.E2EConfig.Cleanup.On
 	if cleanupOnCondition == constant.CleanUpAlways {
 		defer doCleanup(stopAction)
 	}
 
+	// Collection runs independently of cleanup — it has its own collect.on condition.
+	// Registered before setup so it fires even on partial setup failures.
+	// Registered AFTER the Always-cleanup defer so it runs BEFORE cleanup (LIFO),
+	// ensuring files are collected while pods/containers are still alive.
+	// Errors are tolerated — partial setup may leave some pods unreachable.
+	defer func() {
+		doCollect(err)
+	}()
+
 	// setup part
-	err := setup.DoSetupAccordingE2E()
+	err = setup.DoSetupAccordingE2E()
 	if err != nil {
 		return err
 	}
@@ -106,6 +117,22 @@ func runAccordingE2E() error {
 	return nil
 }
 
+// doCollect runs file collection independently of cleanup.
+// It evaluates collect.on against the run error to decide whether to collect.
+func doCollect(runErr error) {
+	collectCfg := config.GlobalConfig.E2EConfig.Cleanup.Collect
+	shouldCollect := (collectCfg.On == constant.CollectAlways) ||
+		(collectCfg.On == constant.CollectOnFailure && runErr != nil)
+	if !shouldCollect || len(collectCfg.Items) == 0 {
+		return
+	}
+	if err := collector.DoCollect(&config.GlobalConfig.E2EConfig); err != nil {
+		logger.Log.Warnf("collect files error: %s", err)
+	} else {
+		logger.Log.Infof("collect files finished successfully")
+	}
+}
+
 func doCleanup(stopAction func()) {
 	if stopAction != nil {
 		stopAction()
diff --git a/docs/en/setup/Configuration-File.md b/docs/en/setup/Configuration-File.md
index 05f4854..587fd6a 100644
--- a/docs/en/setup/Configuration-File.md
+++ b/docs/en/setup/Configuration-File.md
@@ -261,13 +261,48 @@ After the E2E finished, how to clean up the environment.
 ```yaml
 cleanup:
    on: always     # Clean up strategy
+   collect:       # Collect files from pods/containers for debugging
+     on: failure  # When to collect: always|failure|never, default: failure
+     output-dir: /tmp/collect # Where to save the collected files (required)
+     items:
+       # For Kind environment
+       - namespace: default      # Pod namespace
+         label-selector: app=oap # Label selector to find pods
+         resource: pod/oap-0     # Specific pod resource (optional, instead of label-selector)
+         container: oap          # Container name (optional, defaults to first container)
+         paths:                  # Paths in the container to collect
+           - /skywalking/logs/
+       # For Compose environment
+       - service: ui-service     # Compose service name
+         paths:
+           - /var/log/nginx/
 ```
 
 If the `on` option under `cleanup` is not set, it will be automatically set to `always` if there is environment
 variable `CI=true`, which is present on many popular CI services, such as GitHub Actions, CircleCI, etc., otherwise it
 will be set to `success`, so the testing environment can be preserved when tests failed in your local machine.
 
-All available strategies:
+### Collect
+
+The `collect` section allows you to copy files from containers to the local machine before the environment is destroyed. This is useful for debugging failures in CI.
+
+* `on`: When to trigger collection.
+    * `always`: Always collect.
+    * `failure`: Only collect when any step (setup, trigger, verify) fails. Collection also attempts on setup failures — unreachable pods/containers are tolerated and logged.
+    * `never`: Never collect.
+* `output-dir`: **Required.** The local directory to save files. Supports environment variable expansion (e.g. `$SW_INFRA_E2E_LOG_DIR/collect`).
+* `items`: A list of collection tasks.
+    * For **Kind**: Specify `namespace` and either `label-selector` or `resource`. `container` is optional.
+    * For **Compose**: Specify `service`.
+    * `paths`: A list of file or directory paths inside the container.
+
+Collected files are organized by the full source path to avoid collisions:
+* Kind: `output-dir/<namespace>/<pod-name>/<source-path>`
+* Compose: `output-dir/<service-name>/<source-path>`
+
+Additionally, `kubectl describe` (for Kind) or `docker inspect` (for Compose) output is saved automatically alongside collected files.
+
+All available strategies for `cleanup.on`:
 1. `always`: No matter the execution result is success or failure, cleanup will be performed.
 1. `success`: Only when the execution succeeds.
 1. `failure`: Only when the execution failed.
diff --git a/internal/components/cleanup/compose.go b/internal/components/cleanup/compose.go
index c63cb6e..342851c 100644
--- a/internal/components/cleanup/compose.go
+++ b/internal/components/cleanup/compose.go
@@ -19,13 +19,13 @@
 package cleanup
 
 import (
-	"github.com/apache/skywalking-infra-e2e/internal/components/setup"
+	"fmt"
+
 	"github.com/apache/skywalking-infra-e2e/internal/config"
 	"github.com/apache/skywalking-infra-e2e/internal/logger"
+	"github.com/apache/skywalking-infra-e2e/internal/util"
 
 	"github.com/testcontainers/testcontainers-go"
-
-	"fmt"
 )
 
 func ComposeCleanUp(conf *config.E2EConfig) error {
@@ -36,7 +36,7 @@ func ComposeCleanUp(conf *config.E2EConfig) error {
 		return fmt.Errorf("no compose config file was provided")
 	}
 	composeFilePaths := []string{composeFilePath}
-	identifier := setup.GetIdentity()
+	identifier := util.GetIdentity()
 	compose := testcontainers.NewLocalDockerCompose(composeFilePaths, identifier)
 	down := compose.Down()
 	if down.Error != nil {
diff --git a/internal/components/collector/collector.go b/internal/components/collector/collector.go
new file mode 100644
index 0000000..8536330
--- /dev/null
+++ b/internal/components/collector/collector.go
@@ -0,0 +1,58 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package collector
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/apache/skywalking-infra-e2e/internal/config"
+	"github.com/apache/skywalking-infra-e2e/internal/constant"
+	"github.com/apache/skywalking-infra-e2e/internal/logger"
+)
+
+// DoCollect collects files from pods/containers based on the collect config.
+// It dispatches to Kind or Compose collector based on setup.env.
+// Errors are logged but tolerated — partial setup may leave some targets unreachable.
+func DoCollect(e2eConfig *config.E2EConfig) error {
+	collectCfg := &e2eConfig.Cleanup.Collect
+	if len(collectCfg.Items) == 0 {
+		logger.Log.Info("no collect items configured, skipping collection")
+		return nil
+	}
+
+	if collectCfg.OutputDir == "" {
+		return fmt.Errorf("collect output-dir is required when collect items are configured")
+	}
+
+	// Ensure output directory exists
+	if err := os.MkdirAll(collectCfg.OutputDir, os.ModePerm); err != nil {
+		return fmt.Errorf("failed to create collect output directory %s: %v", collectCfg.OutputDir, err)
+	}
+
+	logger.Log.Infof("collecting files to %s", collectCfg.OutputDir)
+
+	switch e2eConfig.Setup.Env {
+	case constant.Kind:
+		return kindCollect(e2eConfig, collectCfg)
+	case constant.Compose:
+		return composeCollect(e2eConfig, collectCfg)
+	default:
+		return fmt.Errorf("unsupported env for collect: %s", e2eConfig.Setup.Env)
+	}
+}
diff --git a/internal/components/collector/collector_test.go b/internal/components/collector/collector_test.go
new file mode 100644
index 0000000..08abdb7
--- /dev/null
+++ b/internal/components/collector/collector_test.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package collector
+
+import (
+	"testing"
+
+	"github.com/apache/skywalking-infra-e2e/internal/config"
+	"github.com/apache/skywalking-infra-e2e/internal/constant"
+)
+
+func TestDoCollect_NoItems(t *testing.T) {
+	cfg := &config.E2EConfig{
+		Setup: config.Setup{Env: constant.Kind},
+		Cleanup: config.Cleanup{
+			Collect: config.CollectConfig{
+				On:    constant.CollectOnFailure,
+				Items: []config.CollectItem{},
+			},
+		},
+	}
+	// Should return nil with no items
+	if err := DoCollect(cfg); err != nil {
+		t.Errorf("DoCollect with no items should return nil, got: %v", err)
+	}
+}
+
+func TestDoCollect_UnsupportedEnv(t *testing.T) {
+	cfg := &config.E2EConfig{
+		Setup: config.Setup{Env: "unsupported"},
+		Cleanup: config.Cleanup{
+			Collect: config.CollectConfig{
+				On:        constant.CollectOnFailure,
+				OutputDir: t.TempDir(),
+				Items: []config.CollectItem{
+					{Service: "test", Paths: []string{"/tmp"}},
+				},
+			},
+		},
+	}
+	err := DoCollect(cfg)
+	if err == nil {
+		t.Error("DoCollect with unsupported env should return error")
+	}
+}
+
+func TestListPods_ResourceFormat(t *testing.T) {
+	tests := []struct {
+		name      string
+		item      config.CollectItem
+		wantPods  int
+		wantName  string
+		wantError bool
+	}{
+		{
+			name:      "Valid pod resource",
+			item:      config.CollectItem{Resource: "pod/oap-0", Namespace: "default"},
+			wantPods:  1,
+			wantName:  "oap-0",
+			wantError: false,
+		},
+		{
+			name:      "Invalid resource format",
+			item:      config.CollectItem{Resource: "deployment/oap", Namespace: "default"},
+			wantPods:  0,
+			wantError: true,
+		},
+		{
+			name:      "No resource or label selector",
+			item:      config.CollectItem{Namespace: "default"},
+			wantPods:  0,
+			wantError: true,
+		},
+		{
+			name:      "Default namespace when empty",
+			item:      config.CollectItem{Resource: "pod/oap-0"},
+			wantPods:  1,
+			wantName:  "oap-0",
+			wantError: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Only test static resource parsing (label-selector requires a real cluster)
+			if tt.item.LabelSelector != "" {
+				t.Skip("skipping label-selector test without cluster")
+			}
+			pods, err := listPods("", &tt.item)
+			if tt.wantError {
+				if err == nil {
+					t.Error("expected error but got nil")
+				}
+				return
+			}
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+				return
+			}
+			if len(pods) != tt.wantPods {
+				t.Errorf("got %d pods, want %d", len(pods), tt.wantPods)
+			}
+			if tt.wantName != "" && len(pods) > 0 && pods[0].name != tt.wantName {
+				t.Errorf("pod name = %v, want %v", pods[0].name, tt.wantName)
+			}
+		})
+	}
+}
+
+func TestComposeCollectItem_NoService(t *testing.T) {
+	err := composeCollectItem("/fake/compose.yml", "test-project", t.TempDir(), &config.CollectItem{
+		Paths: []string{"/tmp"},
+	})
+	if err == nil {
+		t.Error("composeCollectItem with empty service should return error")
+	}
+}
diff --git a/internal/components/collector/compose.go b/internal/components/collector/compose.go
new file mode 100644
index 0000000..09474af
--- /dev/null
+++ b/internal/components/collector/compose.go
@@ -0,0 +1,148 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package collector
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/apache/skywalking-infra-e2e/internal/config"
+	"github.com/apache/skywalking-infra-e2e/internal/constant"
+	"github.com/apache/skywalking-infra-e2e/internal/logger"
+	"github.com/apache/skywalking-infra-e2e/internal/util"
+)
+
+func composeCollect(e2eConfig *config.E2EConfig, collectCfg *config.CollectConfig) error {
+	composeFile := e2eConfig.Setup.GetFile()
+	if composeFile == "" {
+		return fmt.Errorf("compose file not configured in setup.file")
+	}
+	projectName := util.GetIdentity()
+
+	var errs []string
+	for _, item := range collectCfg.Items {
+		if err := composeCollectItem(composeFile, projectName, collectCfg.OutputDir, &item); err != nil {
+			errs = append(errs, fmt.Sprintf("collect item error: %v", err))
+			logger.Log.Warnf("failed to collect item for service %s: %v", item.Service, err)
+		}
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf("some collect items failed:\n%s", strings.Join(errs, "\n"))
+	}
+	return nil
+}
+
+func composeCollectItem(composeFile, projectName, outputDir string, item *config.CollectItem) error {
+	if item.Service == "" {
+		return fmt.Errorf("service name is required for compose collect items")
+	}
+
+	serviceDir := filepath.Join(outputDir, item.Service)
+	if err := os.MkdirAll(serviceDir, os.ModePerm); err != nil {
+		return err
+	}
+
+	// Find container ID using the compose file and project name that setup used
+	containerID, err := findComposeContainer(composeFile, projectName, item.Service)
+	if err != nil {
+		logger.Log.Warnf("failed to find container for service %s: container may not be running yet. %v", item.Service, err)
+		return fmt.Errorf("failed to find container for service %s: %v", item.Service, err)
+	}
+
+	// Collect docker inspect output
+	if err := collectContainerInspect(outputDir, item.Service, containerID); err != nil {
+		logger.Log.Warnf("failed to collect inspect for service %s: container may not be ready. %v", item.Service, err)
+	}
+
+	// Collect specified files
+	var errs []string
+	for _, p := range item.Paths {
+		if err := collectContainerFile(outputDir, item.Service, containerID, p); err != nil {
+			errs = append(errs, fmt.Sprintf("service %s path %s: %v", item.Service, p, err))
+		}
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf("some files failed to collect:\n%s", strings.Join(errs, "\n"))
+	}
+	return nil
+}
+
+// findComposeContainer locates the container ID for a service using the same
+// compose file and project name that setup/cleanup use.
+func findComposeContainer(composeFile, projectName, service string) (string, error) {
+	cmd := fmt.Sprintf("%s -f %s -p %s ps -q %s", constant.ComposeCommand, composeFile, projectName, service)
+	stdout, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		return "", fmt.Errorf("docker compose ps failed: %v, stderr: %s", err, stderr)
+	}
+
+	containerID := strings.TrimSpace(stdout)
+	if containerID == "" {
+		return "", fmt.Errorf("no container found for service %s (project: %s, file: %s)", service, projectName, composeFile)
+	}
+	return containerID, nil
+}
+
+func collectContainerInspect(outputDir, service, containerID string) error {
+	serviceDir := filepath.Join(outputDir, service)
+	if err := os.MkdirAll(serviceDir, os.ModePerm); err != nil {
+		return err
+	}
+
+	cmd := fmt.Sprintf("docker inspect %s", containerID)
+	stdout, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		return fmt.Errorf("docker inspect failed: %v, stderr: %s", err, stderr)
+	}
+
+	inspectFile := filepath.Join(serviceDir, "inspect.json")
+	if err := os.WriteFile(inspectFile, []byte(stdout), 0o644); err != nil {
+		return fmt.Errorf("failed to write inspect output: %v", err)
+	}
+
+	logger.Log.Infof("collected inspect for service %s", service)
+	return nil
+}
+
+func collectContainerFile(outputDir, service, containerID, srcPath string) error {
+	// Preserve the full source path under the service directory to avoid collisions.
+	// e.g. /var/log/nginx/ -> outputDir/serviceName/var/log/nginx/
+	// Strip leading "/" so filepath.Join doesn't discard the prefix.
+	cleanPath := filepath.Clean(srcPath)
+	cleanPath = strings.TrimLeft(cleanPath, string(filepath.Separator))
+	destPath := filepath.Join(outputDir, service, cleanPath)
+	destDir := filepath.Dir(destPath)
+	if err := os.MkdirAll(destDir, os.ModePerm); err != nil {
+		return err
+	}
+
+	cmd := fmt.Sprintf("docker cp %s:%s %s", containerID, srcPath, destPath)
+	_, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		logger.Log.Warnf("failed to collect %s from service %s: container may not be ready, stderr: %s",
+			srcPath, service, stderr)
+		return fmt.Errorf("docker cp failed: %v, stderr: %s", err, stderr)
+	}
+
+	logger.Log.Infof("collected %s from service %s to %s", srcPath, service, destPath)
+	return nil
+}
diff --git a/internal/components/collector/kind.go b/internal/components/collector/kind.go
new file mode 100644
index 0000000..29015b9
--- /dev/null
+++ b/internal/components/collector/kind.go
@@ -0,0 +1,183 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package collector
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/apache/skywalking-infra-e2e/internal/config"
+	"github.com/apache/skywalking-infra-e2e/internal/constant"
+	"github.com/apache/skywalking-infra-e2e/internal/logger"
+	"github.com/apache/skywalking-infra-e2e/internal/util"
+)
+
+func kindCollect(e2eConfig *config.E2EConfig, collectCfg *config.CollectConfig) error {
+	kubeConfigPath := e2eConfig.Setup.GetKubeconfig()
+	if kubeConfigPath == "" {
+		kubeConfigPath = constant.K8sClusterConfigFilePath
+	}
+
+	var errs []string
+	for _, item := range collectCfg.Items {
+		if err := kindCollectItem(kubeConfigPath, collectCfg.OutputDir, &item); err != nil {
+			errs = append(errs, fmt.Sprintf("collect item error: %v", err))
+			logger.Log.Warnf("failed to collect item: %v", err)
+		}
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf("some collect items failed:\n%s", strings.Join(errs, "\n"))
+	}
+	return nil
+}
+
+func kindCollectItem(kubeConfigPath, outputDir string, item *config.CollectItem) error {
+	pods, err := listPods(kubeConfigPath, item)
+	if err != nil {
+		logger.Log.Warnf("failed to list pods (namespace=%s label-selector=%s resource=%s): %v. "+
+			"The cluster or pods may not be ready yet.",
+			item.Namespace, item.LabelSelector, item.Resource, err)
+		return fmt.Errorf("failed to list pods: %v", err)
+	}
+
+	if len(pods) == 0 {
+		logger.Log.Warnf("no pods found for namespace=%s label-selector=%s resource=%s. "+
+			"Pods may not have been created yet due to setup failure.",
+			item.Namespace, item.LabelSelector, item.Resource)
+		return nil
+	}
+
+	var errs []string
+	for _, pod := range pods {
+		// Collect kubectl describe output
+		if err := collectPodDescribe(kubeConfigPath, outputDir, pod.namespace, pod.name); err != nil {
+			logger.Log.Warnf("failed to collect describe for pod %s/%s: %v. Pod may not be ready.", pod.namespace, pod.name, err)
+		}
+
+		// Collect specified files
+		for _, p := range item.Paths {
+			if err := collectPodFile(kubeConfigPath, outputDir, pod.namespace, pod.name, item.Container, p); err != nil {
+				errs = append(errs, fmt.Sprintf("pod %s/%s path %s: %v", pod.namespace, pod.name, p, err))
+			}
+		}
+	}
+
+	if len(errs) > 0 {
+		return fmt.Errorf("some files failed to collect:\n%s", strings.Join(errs, "\n"))
+	}
+	return nil
+}
+
+type podInfo struct {
+	namespace string
+	name      string
+}
+
+func listPods(kubeConfigPath string, item *config.CollectItem) ([]podInfo, error) {
+	namespace := item.Namespace
+	if namespace == "" {
+		namespace = "default"
+	}
+
+	// If a specific resource is given (e.g. pod/oap-xxx), use it directly
+	if item.Resource != "" {
+		parts := strings.SplitN(item.Resource, "/", 2)
+		if len(parts) == 2 && parts[0] == "pod" {
+			return []podInfo{{namespace: namespace, name: parts[1]}}, nil
+		}
+		return nil, fmt.Errorf("unsupported resource format: %s (expected pod/<name>)", item.Resource)
+	}
+
+	// Use label selector to find pods
+	if item.LabelSelector == "" {
+		return nil, fmt.Errorf("either resource or label-selector must be specified")
+	}
+
+	cmd := fmt.Sprintf("kubectl --kubeconfig %s -n %s get pods -l %s -o jsonpath='{.items[*].metadata.name}'",
+		kubeConfigPath, namespace, item.LabelSelector)
+	stdout, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		return nil, fmt.Errorf("kubectl get pods failed: %v, stderr: %s", err, stderr)
+	}
+
+	stdout = strings.Trim(stdout, "'")
+	names := strings.Fields(stdout)
+	pods := make([]podInfo, 0, len(names))
+	for _, name := range names {
+		if name != "" {
+			pods = append(pods, podInfo{namespace: namespace, name: name})
+		}
+	}
+	return pods, nil
+}
+
+func collectPodDescribe(kubeConfigPath, outputDir, namespace, podName string) error {
+	podDir := filepath.Join(outputDir, namespace, podName)
+	if err := os.MkdirAll(podDir, os.ModePerm); err != nil {
+		return err
+	}
+
+	cmd := fmt.Sprintf("kubectl --kubeconfig %s -n %s describe pod %s",
+		kubeConfigPath, namespace, podName)
+	stdout, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		return fmt.Errorf("kubectl describe failed: %v, stderr: %s", err, stderr)
+	}
+
+	descFile := filepath.Join(podDir, "describe.txt")
+	if err := os.WriteFile(descFile, []byte(stdout), 0o644); err != nil {
+		return fmt.Errorf("failed to write describe output: %v", err)
+	}
+
+	logger.Log.Infof("collected describe for pod %s/%s", namespace, podName)
+	return nil
+}
+
+func collectPodFile(kubeConfigPath, outputDir, namespace, podName, container, srcPath string) error {
+	// Preserve the full source path under the pod directory to avoid collisions.
+	// e.g. /skywalking/logs/ -> outputDir/namespace/podName/skywalking/logs/
+	// Strip leading "/" so filepath.Join doesn't discard the prefix.
+	cleanPath := filepath.Clean(srcPath)
+	cleanPath = strings.TrimLeft(cleanPath, string(filepath.Separator))
+	destPath := filepath.Join(outputDir, namespace, podName, cleanPath)
+	destDir := filepath.Dir(destPath)
+	if err := os.MkdirAll(destDir, os.ModePerm); err != nil {
+		return err
+	}
+
+	// Build kubectl cp command
+	// kubectl cp <namespace>/<pod>:<src> <dest> [-c container]
+	src := fmt.Sprintf("%s/%s:%s", namespace, podName, srcPath)
+	cmd := fmt.Sprintf("kubectl --kubeconfig %s cp %s %s", kubeConfigPath, src, destPath)
+	if container != "" {
+		cmd += fmt.Sprintf(" -c %s", container)
+	}
+
+	_, stderr, err := util.ExecuteCommand(cmd)
+	if err != nil {
+		logger.Log.Warnf("failed to collect %s from pod %s/%s: pod/container may not be ready, stderr: %s",
+			srcPath, namespace, podName, stderr)
+		return fmt.Errorf("kubectl cp failed: %v, stderr: %s", err, stderr)
+	}
+
+	logger.Log.Infof("collected %s from pod %s/%s to %s", srcPath, namespace, podName, destPath)
+	return nil
+}
diff --git a/internal/components/setup/common.go b/internal/components/setup/common.go
index 87039d4..fd414d3 100644
--- a/internal/components/setup/common.go
+++ b/internal/components/setup/common.go
@@ -21,7 +21,6 @@ package setup
 import (
 	"context"
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
@@ -196,14 +195,6 @@ func NewTimeout(timeBefore time.Time, timeout time.Duration) time.Duration {
 	return newTimeout
 }
 
-func GetIdentity() string {
-	runID := os.Getenv("GITHUB_RUN_ID")
-	if runID == "" {
-		return "skywalking_e2e"
-	}
-	return runID
-}
-
 func InitLogFollower() {
 	logFollower = util.NewResourceLogFollower(context.Background(), util.LogDir)
 }
diff --git a/internal/components/setup/compose.go b/internal/components/setup/compose.go
index 62d86c0..239f1a2 100644
--- a/internal/components/setup/compose.go
+++ b/internal/components/setup/compose.go
@@ -72,7 +72,7 @@ func ComposeSetup(e2eConfig *config.E2EConfig) error {
 	composeFilePaths := []string{
 		composeConfigPath,
 	}
-	identifier := GetIdentity()
+	identifier := util.GetIdentity()
 	compose := testcontainers.NewLocalDockerCompose(composeFilePaths, identifier)
 
 	// bind wait port
diff --git a/internal/config/e2eConfig.go b/internal/config/e2eConfig.go
index 34271bf..60cb7d1 100644
--- a/internal/config/e2eConfig.go
+++ b/internal/config/e2eConfig.go
@@ -65,7 +65,38 @@ func (s *Setup) GetTimeout() time.Duration {
 }
 
 type Cleanup struct {
-	On string `yaml:"on"`
+	On      string        `yaml:"on"`
+	Collect CollectConfig `yaml:"collect"`
+}
+
+type CollectConfig struct {
+	On        string        `yaml:"on"`         // always|failure|never, default: failure
+	OutputDir string        `yaml:"output-dir"`  // required when items are configured
+	Items     []CollectItem `yaml:"items"`
+}
+
+// Finalize sets default values for CollectConfig.
+func (c *CollectConfig) Finalize() {
+	if c.On == "" {
+		c.On = constant.CollectOnFailure
+	}
+	if c.OutputDir != "" {
+		c.OutputDir = os.ExpandEnv(c.OutputDir)
+		c.OutputDir = util.ExpandFilePath(c.OutputDir)
+		c.OutputDir = util.ResolveAbs(c.OutputDir)
+	}
+}
+
+type CollectItem struct {
+	// Kind mode fields
+	Namespace     string `yaml:"namespace"`
+	LabelSelector string `yaml:"label-selector"`
+	Resource      string `yaml:"resource"`  // e.g. pod/oap-xxx
+	Container     string `yaml:"container"` // optional, defaults to first container
+	// Compose mode fields
+	Service string `yaml:"service"`
+	// Common
+	Paths []string `yaml:"paths"`
 }
 
 type Step struct {
diff --git a/internal/config/e2eConfig_test.go b/internal/config/e2eConfig_test.go
index 5811431..db5a74f 100644
--- a/internal/config/e2eConfig_test.go
+++ b/internal/config/e2eConfig_test.go
@@ -22,7 +22,9 @@ import (
 	"os"
 	"testing"
 
+	"github.com/apache/skywalking-infra-e2e/internal/constant"
 	"github.com/apache/skywalking-infra-e2e/internal/util"
+	"gopkg.in/yaml.v2"
 	"k8s.io/apimachinery/pkg/util/rand"
 )
 
@@ -77,3 +79,181 @@ func TestSetup_GetFile(t *testing.T) {
 		})
 	}
 }
+
+func TestCollectConfig_Parse(t *testing.T) {
+	tests := []struct {
+		name           string
+		yamlInput      string
+		wantOn         string
+		wantOutputDir  string
+		wantItemsCount int
+	}{
+		{
+			name: "Full collect config with Kind items",
+			yamlInput: `
+cleanup:
+  on: always
+  collect:
+    on: failure
+    output-dir: /tmp/collect
+    items:
+      - namespace: default
+        label-selector: app=oap
+        container: oap
+        paths:
+          - /skywalking/logs/
+          - /tmp/dump.hprof
+`,
+			wantOn:         "failure",
+			wantOutputDir:  "/tmp/collect",
+			wantItemsCount: 1,
+		},
+		{
+			name: "Full collect config with Compose items",
+			yamlInput: `
+cleanup:
+  on: always
+  collect:
+    on: always
+    output-dir: /tmp/compose-collect
+    items:
+      - service: oap-service
+        paths:
+          - /skywalking/logs/
+      - service: ui-service
+        paths:
+          - /var/log/nginx/
+`,
+			wantOn:         "always",
+			wantOutputDir:  "/tmp/compose-collect",
+			wantItemsCount: 2,
+		},
+		{
+			name: "Empty collect config",
+			yamlInput: `
+cleanup:
+  on: always
+`,
+			wantOn:         "",
+			wantOutputDir:  "",
+			wantItemsCount: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var cfg E2EConfig
+			if err := yaml.Unmarshal([]byte(tt.yamlInput), &cfg); err != nil {
+				t.Fatalf("failed to unmarshal: %v", err)
+			}
+			if cfg.Cleanup.Collect.On != tt.wantOn {
+				t.Errorf("Collect.On = %v, want %v", cfg.Cleanup.Collect.On, tt.wantOn)
+			}
+			if cfg.Cleanup.Collect.OutputDir != tt.wantOutputDir {
+				t.Errorf("Collect.OutputDir = %v, want %v", cfg.Cleanup.Collect.OutputDir, tt.wantOutputDir)
+			}
+			if len(cfg.Cleanup.Collect.Items) != tt.wantItemsCount {
+				t.Errorf("len(Collect.Items) = %v, want %v", len(cfg.Cleanup.Collect.Items), tt.wantItemsCount)
+			}
+		})
+	}
+}
+
+func TestCollectConfig_Finalize(t *testing.T) {
+	tests := []struct {
+		name          string
+		collect       CollectConfig
+		wantOn        string
+		wantOutputDir string
+	}{
+		{
+			name:          "Defaults when empty",
+			collect:       CollectConfig{},
+			wantOn:        constant.CollectOnFailure,
+			wantOutputDir: "",
+		},
+		{
+			name: "Keeps explicit values",
+			collect: CollectConfig{
+				On:        constant.CollectAlways,
+				OutputDir: "/custom/output",
+			},
+			wantOn:        constant.CollectAlways,
+			wantOutputDir: "/custom/output",
+		},
+		{
+			name: "Expands env var in output-dir",
+			collect: CollectConfig{
+				OutputDir: "$TEST_COLLECT_DIR/collect",
+			},
+			wantOn:        constant.CollectOnFailure,
+			wantOutputDir: "/tmp/test-collect/collect",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.name == "Expands env var in output-dir" {
+				os.Setenv("TEST_COLLECT_DIR", "/tmp/test-collect")
+				defer os.Unsetenv("TEST_COLLECT_DIR")
+			}
+			c := tt.collect
+			c.Finalize()
+			if c.On != tt.wantOn {
+				t.Errorf("Collect.On = %v, want %v", c.On, tt.wantOn)
+			}
+			if c.OutputDir != tt.wantOutputDir {
+				t.Errorf("Collect.OutputDir = %v, want %v", c.OutputDir, tt.wantOutputDir)
+			}
+		})
+	}
+}
+
+func TestCollectItem_KindFields(t *testing.T) {
+	yamlInput := `
+namespace: skywalking
+label-selector: app=oap
+container: oap-container
+resource: pod/oap-0
+paths:
+  - /skywalking/logs/
+  - /tmp/heap-dump.hprof
+`
+	var item CollectItem
+	if err := yaml.Unmarshal([]byte(yamlInput), &item); err != nil {
+		t.Fatalf("failed to unmarshal: %v", err)
+	}
+	if item.Namespace != "skywalking" {
+		t.Errorf("Namespace = %v, want skywalking", item.Namespace)
+	}
+	if item.LabelSelector != "app=oap" {
+		t.Errorf("LabelSelector = %v, want app=oap", item.LabelSelector)
+	}
+	if item.Container != "oap-container" {
+		t.Errorf("Container = %v, want oap-container", item.Container)
+	}
+	if item.Resource != "pod/oap-0" {
+		t.Errorf("Resource = %v, want pod/oap-0", item.Resource)
+	}
+	if len(item.Paths) != 2 {
+		t.Errorf("len(Paths) = %v, want 2", len(item.Paths))
+	}
+}
+
+func TestCollectItem_ComposeFields(t *testing.T) {
+	yamlInput := `
+service: oap-service
+paths:
+  - /skywalking/logs/
+`
+	var item CollectItem
+	if err := yaml.Unmarshal([]byte(yamlInput), &item); err != nil {
+		t.Fatalf("failed to unmarshal: %v", err)
+	}
+	if item.Service != "oap-service" {
+		t.Errorf("Service = %v, want oap-service", item.Service)
+	}
+	if len(item.Paths) != 1 {
+		t.Errorf("len(Paths) = %v, want 1", len(item.Paths))
+	}
+}
diff --git a/internal/constant/cleanup.go b/internal/constant/cleanup.go
index 72a72f4..4e31fd2 100644
--- a/internal/constant/cleanup.go
+++ b/internal/constant/cleanup.go
@@ -23,4 +23,8 @@ const (
 	CleanUpOnFailure = "failure"
 	CleanUpAlways    = "always"
 	CleanUpNever     = "never"
+
+	CollectOnFailure = "failure"
+	CollectAlways    = "always"
+	CollectNever     = "never"
 )
diff --git a/internal/util/utils.go b/internal/util/utils.go
index f27dc57..4042f1b 100644
--- a/internal/util/utils.go
+++ b/internal/util/utils.go
@@ -55,6 +55,14 @@ func ReadFileContent(filename string) (string, error) {
 	return "", errors.New("the file does not exist")
 }
 
+func GetIdentity() string {
+	runID := os.Getenv("GITHUB_RUN_ID")
+	if runID == "" {
+		return "skywalking_e2e"
+	}
+	return runID
+}
+
 // ExecuteCommand executes the given command and returns the result.
 func ExecuteCommand(cmd string) (stdout, stderr string, err error) {
 	hookScript, err := hookScript()
diff --git a/test/e2e/kind/collect-on-failure/deployment.yaml b/test/e2e/kind/collect-on-failure/deployment.yaml
new file mode 100644
index 0000000..e6ad9fc
--- /dev/null
+++ b/test/e2e/kind/collect-on-failure/deployment.yaml
@@ -0,0 +1,34 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+apiVersion: v1
+kind: Pod
+metadata:
+  name: collect-test
+  labels:
+    app: collect-test
+spec:
+  containers:
+    - name: test
+      image: busybox:latest
+      imagePullPolicy: Never
+      command:
+        - sh
+        - -c
+        - |
+          mkdir -p /test-data/logs
+          echo "test-log-content" > /test-data/logs/app.log
+          echo "debug-info" > /test-data/debug.txt
+          sleep 3600
diff --git a/test/e2e/kind/collect-on-failure/e2e.yaml b/test/e2e/kind/collect-on-failure/e2e.yaml
new file mode 100644
index 0000000..072bdcc
--- /dev/null
+++ b/test/e2e/kind/collect-on-failure/e2e.yaml
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file tests the collect-on-failure functionality with Kind.
+# It deploys a pod with known files, intentionally fails verification,
+# and expects the collect phase to copy files out before cleanup.
+
+setup:
+  env: kind
+  file: ../kind-cluster.yaml
+  timeout: 10m
+  kind:
+    import-images:
+      - busybox:latest
+  steps:
+    - name: deploy collect-test pod
+      command: kubectl apply -f test/e2e/kind/collect-on-failure/deployment.yaml
+      wait:
+        - namespace: default
+          resource: pod
+          label-selector: app=collect-test
+          for: condition=Ready
+
+cleanup:
+  on: always
+  collect:
+    on: failure
+    output-dir: /tmp/e2e-collect-test
+    items:
+      - namespace: default
+        label-selector: app=collect-test
+        paths:
+          - /test-data/logs/
+          - /test-data/debug.txt
+
+verify:
+  retry:
+    count: 1
+    interval: 1s
+  cases:
+    - name: intentionally-fail-to-trigger-collection
+      query: echo "actual-value"
+      expected: expected.yaml
diff --git a/test/e2e/kind/collect-on-failure/expected.yaml b/test/e2e/kind/collect-on-failure/expected.yaml
new file mode 100644
index 0000000..2ef812d
--- /dev/null
+++ b/test/e2e/kind/collect-on-failure/expected.yaml
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This expected value intentionally does NOT match the actual output,
+# so the verify phase fails and triggers collect-on-failure.
+expected-value-that-does-not-match