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