From 236800d7c63d5001370c421874c475c6db459bbd Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 4 Sep 2024 12:46:50 +0800 Subject: [PATCH 01/14] test: integration test poc --- .../testintegration/pebble_another_test.go | 16 +++ internals/testintegration/pebble_run_test.go | 45 ++++++++ internals/testintegration/utils.go | 105 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 internals/testintegration/pebble_another_test.go create mode 100644 internals/testintegration/pebble_run_test.go create mode 100644 internals/testintegration/utils.go diff --git a/internals/testintegration/pebble_another_test.go b/internals/testintegration/pebble_another_test.go new file mode 100644 index 00000000..b4aee4f4 --- /dev/null +++ b/internals/testintegration/pebble_another_test.go @@ -0,0 +1,16 @@ +//go:build integration + +package testintegration_test + +import ( + "testing" + + . "github.com/canonical/pebble/internals/testintegration" +) + +func TestPebbleSomethingElse(t *testing.T) { + pebbleDir := t.TempDir() + CreateLayer(t, pebbleDir, "001-simple-layer.yaml", DefaultLayerYAML) + _ = PebbleRun(t, pebbleDir) + // do something +} diff --git a/internals/testintegration/pebble_run_test.go b/internals/testintegration/pebble_run_test.go new file mode 100644 index 00000000..22f50dfa --- /dev/null +++ b/internals/testintegration/pebble_run_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package testintegration_test + +import ( + "fmt" + "os" + "testing" + + . "github.com/canonical/pebble/internals/testintegration" +) + +func TestMain(m *testing.M) { + if err := Setup(); err != nil { + fmt.Println("Setup failed with error:", err) + os.Exit(1) + } + + exitVal := m.Run() + os.Exit(exitVal) +} + +func TestPebbleRunWithSimpleLayer(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + demo-service: + override: replace + command: sleep 1000 + startup: enabled +`[1:] + CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) + + logs := PebbleRun(t, pebbleDir) + + expected := []string{ + "Service \"demo-service\" starting", + "Started default services with change", + } + + if foundAll, notFound := AllExpectedKeywordsFoundInLogs(logs, expected); !foundAll { + t.Errorf("Expected keywords not found in logs: %v", notFound) + } +} diff --git a/internals/testintegration/utils.go b/internals/testintegration/utils.go new file mode 100644 index 00000000..95108436 --- /dev/null +++ b/internals/testintegration/utils.go @@ -0,0 +1,105 @@ +//go:build integration + +package testintegration + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +var DefaultLayerYAML string = ` +services: + demo-service: + override: replace + command: sleep 1000 + startup: enabled +`[1:] + +func Setup() error { + cmd := exec.Command("go", "build", "./cmd/pebble") + cmd.Dir = getRootDir() + return cmd.Run() +} + +func getRootDir() string { + wd, _ := os.Getwd() + return filepath.Join(wd, "../../") +} + +func AllExpectedKeywordsFoundInLogs(logs []string, keywords []string) (bool, []string) { + var notFound []string + + for _, keyword := range keywords { + keywordFound := false + for _, log := range logs { + if strings.Contains(log, keyword) { + keywordFound = true + break + } + } + if !keywordFound { + notFound = append(notFound, keyword) + } + } + + return len(notFound) == 0, notFound +} + +func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { + layersDir := filepath.Join(pebbleDir, "layers") + err := os.MkdirAll(layersDir, 0755) + if err != nil { + t.Fatalf("Error creating layers directory: pipe: %v", err) + } + + layerPath := filepath.Join(layersDir, layerFileName) + err = os.WriteFile(layerPath, []byte(layerYAML), 0755) + if err != nil { + t.Fatalf("Error creating layers file: %v", err) + } +} + +func PebbleRun(t *testing.T, pebbleDir string) []string { + cmd := exec.Command("./pebble", "run") + cmd.Dir = getRootDir() + cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir)) + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("Error creating stderr pipe: %v", err) + } + + err = cmd.Start() + defer cmd.Process.Kill() + if err != nil { + t.Fatalf("Error starting 'pebble run': %v", err) + } + + var logs []string + + lastOutputTime := time.Now() + + go func() { + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + lastOutputTime = time.Now() + line := scanner.Text() + logs = append(logs, line) + } + }() + + for { + time.Sleep(100 * time.Millisecond) + if time.Since(lastOutputTime) > 1*time.Second { + break + } + } + + return logs +} From 682c2aa106a176e41a0c16620cca21ad57825b4e Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 4 Sep 2024 13:12:02 +0800 Subject: [PATCH 02/14] test: integration test poc --- internals/testintegration/pebble_run_test.go | 8 +++++++- internals/testintegration/utils.go | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internals/testintegration/pebble_run_test.go b/internals/testintegration/pebble_run_test.go index 22f50dfa..168b55cc 100644 --- a/internals/testintegration/pebble_run_test.go +++ b/internals/testintegration/pebble_run_test.go @@ -29,17 +29,23 @@ services: override: replace command: sleep 1000 startup: enabled + demo-service2: + override: replace + command: sleep 1000 + startup: enabled `[1:] CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) logs := PebbleRun(t, pebbleDir) expected := []string{ + "Started daemon", "Service \"demo-service\" starting", + "Service \"demo-service2\" starting", "Started default services with change", } - if foundAll, notFound := AllExpectedKeywordsFoundInLogs(logs, expected); !foundAll { + if foundAll, notFound := AllKeywordsFoundInLogs(logs, expected); !foundAll { t.Errorf("Expected keywords not found in logs: %v", notFound) } } diff --git a/internals/testintegration/utils.go b/internals/testintegration/utils.go index 95108436..e40cbbf6 100644 --- a/internals/testintegration/utils.go +++ b/internals/testintegration/utils.go @@ -32,7 +32,7 @@ func getRootDir() string { return filepath.Join(wd, "../../") } -func AllExpectedKeywordsFoundInLogs(logs []string, keywords []string) (bool, []string) { +func AllKeywordsFoundInLogs(logs []string, keywords []string) (bool, []string) { var notFound []string for _, keyword := range keywords { From a45bee7deec3280536c6d4c3375f7aabbb2fed1d Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 10 Sep 2024 16:54:32 +0800 Subject: [PATCH 03/14] chore: refactor after discussion --- .../testintegration/pebble_another_test.go | 16 --- internals/testintegration/pebble_run_test.go | 51 ------- internals/testintegration/utils.go | 105 -------------- tests/README.md | 31 ++++ tests/main_test.go | 67 +++++++++ tests/utils.go | 132 ++++++++++++++++++ 6 files changed, 230 insertions(+), 172 deletions(-) delete mode 100644 internals/testintegration/pebble_another_test.go delete mode 100644 internals/testintegration/pebble_run_test.go delete mode 100644 internals/testintegration/utils.go create mode 100644 tests/README.md create mode 100644 tests/main_test.go create mode 100644 tests/utils.go diff --git a/internals/testintegration/pebble_another_test.go b/internals/testintegration/pebble_another_test.go deleted file mode 100644 index b4aee4f4..00000000 --- a/internals/testintegration/pebble_another_test.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build integration - -package testintegration_test - -import ( - "testing" - - . "github.com/canonical/pebble/internals/testintegration" -) - -func TestPebbleSomethingElse(t *testing.T) { - pebbleDir := t.TempDir() - CreateLayer(t, pebbleDir, "001-simple-layer.yaml", DefaultLayerYAML) - _ = PebbleRun(t, pebbleDir) - // do something -} diff --git a/internals/testintegration/pebble_run_test.go b/internals/testintegration/pebble_run_test.go deleted file mode 100644 index 168b55cc..00000000 --- a/internals/testintegration/pebble_run_test.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build integration - -package testintegration_test - -import ( - "fmt" - "os" - "testing" - - . "github.com/canonical/pebble/internals/testintegration" -) - -func TestMain(m *testing.M) { - if err := Setup(); err != nil { - fmt.Println("Setup failed with error:", err) - os.Exit(1) - } - - exitVal := m.Run() - os.Exit(exitVal) -} - -func TestPebbleRunWithSimpleLayer(t *testing.T) { - pebbleDir := t.TempDir() - - layerYAML := ` -services: - demo-service: - override: replace - command: sleep 1000 - startup: enabled - demo-service2: - override: replace - command: sleep 1000 - startup: enabled -`[1:] - CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) - - logs := PebbleRun(t, pebbleDir) - - expected := []string{ - "Started daemon", - "Service \"demo-service\" starting", - "Service \"demo-service2\" starting", - "Started default services with change", - } - - if foundAll, notFound := AllKeywordsFoundInLogs(logs, expected); !foundAll { - t.Errorf("Expected keywords not found in logs: %v", notFound) - } -} diff --git a/internals/testintegration/utils.go b/internals/testintegration/utils.go deleted file mode 100644 index e40cbbf6..00000000 --- a/internals/testintegration/utils.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build integration - -package testintegration - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -var DefaultLayerYAML string = ` -services: - demo-service: - override: replace - command: sleep 1000 - startup: enabled -`[1:] - -func Setup() error { - cmd := exec.Command("go", "build", "./cmd/pebble") - cmd.Dir = getRootDir() - return cmd.Run() -} - -func getRootDir() string { - wd, _ := os.Getwd() - return filepath.Join(wd, "../../") -} - -func AllKeywordsFoundInLogs(logs []string, keywords []string) (bool, []string) { - var notFound []string - - for _, keyword := range keywords { - keywordFound := false - for _, log := range logs { - if strings.Contains(log, keyword) { - keywordFound = true - break - } - } - if !keywordFound { - notFound = append(notFound, keyword) - } - } - - return len(notFound) == 0, notFound -} - -func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { - layersDir := filepath.Join(pebbleDir, "layers") - err := os.MkdirAll(layersDir, 0755) - if err != nil { - t.Fatalf("Error creating layers directory: pipe: %v", err) - } - - layerPath := filepath.Join(layersDir, layerFileName) - err = os.WriteFile(layerPath, []byte(layerYAML), 0755) - if err != nil { - t.Fatalf("Error creating layers file: %v", err) - } -} - -func PebbleRun(t *testing.T, pebbleDir string) []string { - cmd := exec.Command("./pebble", "run") - cmd.Dir = getRootDir() - cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir)) - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - t.Fatalf("Error creating stderr pipe: %v", err) - } - - err = cmd.Start() - defer cmd.Process.Kill() - if err != nil { - t.Fatalf("Error starting 'pebble run': %v", err) - } - - var logs []string - - lastOutputTime := time.Now() - - go func() { - scanner := bufio.NewScanner(stderrPipe) - for scanner.Scan() { - lastOutputTime = time.Now() - line := scanner.Text() - logs = append(logs, line) - } - }() - - for { - time.Sleep(100 * time.Millisecond) - if time.Since(lastOutputTime) > 1*time.Second { - break - } - } - - return logs -} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..623fc890 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,31 @@ +# Pebble Integration Tests + +## Run Tests + +```bash +go test -tags=integration ./tests/ +``` + +## Developing + +### Clean Test Cache + +If you are adding tests and debugging, remember to clean test cache: + +```bash +go clean -testcache && go test -v -tags=integration ./tests/ +``` + +### Visual Studio Code Settings + +For the VSCode Go extention to work properly with files with build tags, add the following: + +```json +{ + "gopls": { + "build.buildFlags": [ + "-tags=integration" + ] + } +} +``` diff --git a/tests/main_test.go b/tests/main_test.go new file mode 100644 index 00000000..387e872a --- /dev/null +++ b/tests/main_test.go @@ -0,0 +1,67 @@ +//go:build integration + +// Copyright (c) 2024 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package tests_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + "time" + + . "github.com/canonical/pebble/tests" +) + +func TestMain(m *testing.M) { + goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble") + if err := goBuild.Run(); err != nil { + fmt.Println("Setup failed with error:", err) + os.Exit(1) + } + + exitVal := m.Run() + os.Exit(exitVal) +} + +func TestPebbleRunNormal(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + demo-service: + override: replace + command: sleep 1000 + startup: enabled + demo-service2: + override: replace + command: sleep 1000 + startup: enabled +`[1:] + + CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) + + logsCh := PebbleRun(t, pebbleDir) + expected := []string{ + "Started daemon", + "Service \"demo-service\" starting", + "Service \"demo-service2\" starting", + "Started default services with change", + } + if err := WaitForLogs(logsCh, expected, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } +} diff --git a/tests/utils.go b/tests/utils.go new file mode 100644 index 00000000..803d2f8a --- /dev/null +++ b/tests/utils.go @@ -0,0 +1,132 @@ +//go:build integration + +// Copyright (c) 2024 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package tests + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { + t.Helper() + + layersDir := filepath.Join(pebbleDir, "layers") + err := os.MkdirAll(layersDir, 0755) + if err != nil { + t.Fatalf("Error creating layers directory: pipe: %v", err) + } + + layerPath := filepath.Join(layersDir, layerFileName) + err = os.WriteFile(layerPath, []byte(layerYAML), 0755) + if err != nil { + t.Fatalf("Error creating layers file: %v", err) + } +} + +func PebbleRun(t *testing.T, pebbleDir string) <-chan string { + t.Helper() + + logsCh := make(chan string) + + cmd := exec.Command("../pebble", "run") + cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir)) + + t.Cleanup(func() { + err := cmd.Process.Signal(os.Interrupt) + if err != nil { + t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) + } + }) + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("Error creating stderr pipe: %v", err) + } + + err = cmd.Start() + if err != nil { + t.Fatalf("Error starting 'pebble run': %v", err) + } + + go func() { + defer close(logsCh) + + scanner := bufio.NewScanner(stderrPipe) + for scanner.Scan() { + line := scanner.Text() + logsCh <- line + } + }() + + return logsCh +} + +func WaitForLogs(logsCh <-chan string, expectedLogs []string, timeout time.Duration) error { + receivedLogs := make(map[string]struct{}) + start := time.Now() + + for { + select { + case log, ok := <-logsCh: + if !ok { + return errors.New("channel closed before all expected logs were received") + } + + for _, expectedLog := range expectedLogs { + if _, ok := receivedLogs[expectedLog]; !ok && containsSubstring(log, expectedLog) { + receivedLogs[expectedLog] = struct{}{} + break + } + } + + allLogsReceived := true + for _, log := range expectedLogs { + if _, ok := receivedLogs[log]; !ok { + allLogsReceived = false + break + } + } + + if allLogsReceived { + return nil + } + + default: + if time.Since(start) > timeout { + missingLogs := []string{} + for _, log := range expectedLogs { + if _, ok := receivedLogs[log]; !ok { + missingLogs = append(missingLogs, log) + } + } + return errors.New("timed out waiting for log: " + strings.Join(missingLogs, ", ")) + } + time.Sleep(100 * time.Millisecond) + } + } +} + +func containsSubstring(s, substr string) bool { + return strings.Contains(s, substr) +} From e0bb83b40d7820330bbe8e38a9f538d617d8410e Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Tue, 10 Sep 2024 17:59:23 +0800 Subject: [PATCH 04/14] chore: add some comments --- tests/main_test.go | 1 + tests/utils.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/main_test.go b/tests/main_test.go index 387e872a..dd704c07 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -26,6 +26,7 @@ import ( . "github.com/canonical/pebble/tests" ) +// TestMain does extra setup before executing tests. func TestMain(m *testing.M) { goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble") if err := goBuild.Run(); err != nil { diff --git a/tests/utils.go b/tests/utils.go index 803d2f8a..b3ea5555 100644 --- a/tests/utils.go +++ b/tests/utils.go @@ -28,6 +28,7 @@ import ( "time" ) +// CreateLayer creates a layer file in the Pebble dir using the name and content given. func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { t.Helper() @@ -44,6 +45,7 @@ func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML } } +// PebbleRun runs the Pebble daemon and returns a channel for logs. func PebbleRun(t *testing.T, pebbleDir string) <-chan string { t.Helper() @@ -82,6 +84,7 @@ func PebbleRun(t *testing.T, pebbleDir string) <-chan string { return logsCh } +// WaitForLogs reads from the channel (returned by PebbleRun) and checks if all expected logs are found within specified timeout duration. func WaitForLogs(logsCh <-chan string, expectedLogs []string, timeout time.Duration) error { receivedLogs := make(map[string]struct{}) start := time.Now() From 574fa49b9e48f2fd706e67fe4932527d1ac9a4f1 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 11 Sep 2024 15:43:52 +0800 Subject: [PATCH 05/14] chore: refactor after discussion and initial review --- tests/README.md | 10 +--- tests/main_test.go | 116 +++++++++++++++++++++++++++----------- tests/run_test.go | 52 +++++++++++++++++ tests/utils.go | 135 --------------------------------------------- 4 files changed, 138 insertions(+), 175 deletions(-) create mode 100644 tests/run_test.go delete mode 100644 tests/utils.go diff --git a/tests/README.md b/tests/README.md index 623fc890..d7526c18 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,19 +3,11 @@ ## Run Tests ```bash -go test -tags=integration ./tests/ +go test -count=1 -tags=integration ./tests/ ``` ## Developing -### Clean Test Cache - -If you are adding tests and debugging, remember to clean test cache: - -```bash -go clean -testcache && go test -v -tags=integration ./tests/ -``` - ### Visual Studio Code Settings For the VSCode Go extention to work properly with files with build tags, add the following: diff --git a/tests/main_test.go b/tests/main_test.go index dd704c07..cbdf6e24 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -14,55 +14,109 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -package tests_test +package tests import ( "fmt" "os" "os/exec" + "path/filepath" "testing" "time" - . "github.com/canonical/pebble/tests" + "github.com/canonical/pebble/internals/servicelog" ) -// TestMain does extra setup before executing tests. +// TestMain builds the pebble binary before running the integration tests. func TestMain(m *testing.M) { goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble") if err := goBuild.Run(); err != nil { - fmt.Println("Setup failed with error:", err) + fmt.Println("Cannot build pebble binary:", err) os.Exit(1) } - exitVal := m.Run() - os.Exit(exitVal) + exitCode := m.Run() + os.Exit(exitCode) } -func TestPebbleRunNormal(t *testing.T) { - pebbleDir := t.TempDir() - - layerYAML := ` -services: - demo-service: - override: replace - command: sleep 1000 - startup: enabled - demo-service2: - override: replace - command: sleep 1000 - startup: enabled -`[1:] - - CreateLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) - - logsCh := PebbleRun(t, pebbleDir) - expected := []string{ - "Started daemon", - "Service \"demo-service\" starting", - "Service \"demo-service2\" starting", - "Started default services with change", +func createLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { + t.Helper() + + layersDir := filepath.Join(pebbleDir, "layers") + err := os.MkdirAll(layersDir, 0o755) + if err != nil { + t.Fatalf("Cannot create layers directory: pipe: %v", err) + } + + layerPath := filepath.Join(layersDir, layerFileName) + err = os.WriteFile(layerPath, []byte(layerYAML), 0o755) + if err != nil { + t.Fatalf("Error creating layers file: %v", err) + } +} + +func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog.Entry { + t.Helper() + + logsCh := make(chan servicelog.Entry) + + cmd := exec.Command("../pebble", append([]string{"run"}, args...)...) + cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) + + t.Cleanup(func() { + err := cmd.Process.Signal(os.Interrupt) + if err != nil { + t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) + } + cmd.Wait() + }) + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + t.Fatalf("Error creating stderr pipe: %v", err) } - if err := WaitForLogs(logsCh, expected, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) + + err = cmd.Start() + if err != nil { + t.Fatalf("Error starting 'pebble run': %v", err) + } + + go func() { + defer close(logsCh) + parser := servicelog.NewParser(stderrPipe, 4*1024) + for parser.Next() { + if err := parser.Err(); err != nil { + t.Errorf("Cannot parse Pebble logs: %v", err) + } + logsCh <- parser.Entry() + } + }() + + return logsCh +} + +func waitForServices(t *testing.T, pebbleDir string, expectedServices []string, timeout time.Duration) { + for _, service := range expectedServices { + waitForService(t, pebbleDir, service, timeout) + } +} + +func waitForService(t *testing.T, pebbleDir string, service string, timeout time.Duration) { + serviceFilePath := filepath.Join(pebbleDir, service) + timeoutCh := time.After(timeout) + ticker := time.NewTicker(time.Millisecond) + for { + select { + case <-timeoutCh: + t.Errorf("timeout waiting for service %s", service) + return + + case <-ticker.C: + stat, err := os.Stat(serviceFilePath) + if err == nil && stat.Mode().IsRegular() { + os.Remove(serviceFilePath) + return + } + } } } diff --git a/tests/run_test.go b/tests/run_test.go new file mode 100644 index 00000000..300c768d --- /dev/null +++ b/tests/run_test.go @@ -0,0 +1,52 @@ +//go:build integration + +// Copyright (c) 2024 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package tests + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPebbleRunNormal(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + svc1: + override: replace + command: {{.svc1Cmd}} + startup: enabled + svc2: + override: replace + command: {{.svc2Cmd}} + startup: enabled +` + svc1Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc1")) + svc2Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc2")) + layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) + layerYAML = strings.Replace(layerYAML, "{{.svc2Cmd}}", svc2Cmd, -1) + + createLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) + + _ = pebbleRun(t, pebbleDir) + + expectedServices := []string{"svc1", "svc2"} + waitForServices(t, pebbleDir, expectedServices, time.Second*3) +} diff --git a/tests/utils.go b/tests/utils.go deleted file mode 100644 index b3ea5555..00000000 --- a/tests/utils.go +++ /dev/null @@ -1,135 +0,0 @@ -//go:build integration - -// Copyright (c) 2024 Canonical Ltd -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License version 3 as -// published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package tests - -import ( - "bufio" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" -) - -// CreateLayer creates a layer file in the Pebble dir using the name and content given. -func CreateLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { - t.Helper() - - layersDir := filepath.Join(pebbleDir, "layers") - err := os.MkdirAll(layersDir, 0755) - if err != nil { - t.Fatalf("Error creating layers directory: pipe: %v", err) - } - - layerPath := filepath.Join(layersDir, layerFileName) - err = os.WriteFile(layerPath, []byte(layerYAML), 0755) - if err != nil { - t.Fatalf("Error creating layers file: %v", err) - } -} - -// PebbleRun runs the Pebble daemon and returns a channel for logs. -func PebbleRun(t *testing.T, pebbleDir string) <-chan string { - t.Helper() - - logsCh := make(chan string) - - cmd := exec.Command("../pebble", "run") - cmd.Env = append(os.Environ(), fmt.Sprintf("PEBBLE=%s", pebbleDir)) - - t.Cleanup(func() { - err := cmd.Process.Signal(os.Interrupt) - if err != nil { - t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) - } - }) - - stderrPipe, err := cmd.StderrPipe() - if err != nil { - t.Fatalf("Error creating stderr pipe: %v", err) - } - - err = cmd.Start() - if err != nil { - t.Fatalf("Error starting 'pebble run': %v", err) - } - - go func() { - defer close(logsCh) - - scanner := bufio.NewScanner(stderrPipe) - for scanner.Scan() { - line := scanner.Text() - logsCh <- line - } - }() - - return logsCh -} - -// WaitForLogs reads from the channel (returned by PebbleRun) and checks if all expected logs are found within specified timeout duration. -func WaitForLogs(logsCh <-chan string, expectedLogs []string, timeout time.Duration) error { - receivedLogs := make(map[string]struct{}) - start := time.Now() - - for { - select { - case log, ok := <-logsCh: - if !ok { - return errors.New("channel closed before all expected logs were received") - } - - for _, expectedLog := range expectedLogs { - if _, ok := receivedLogs[expectedLog]; !ok && containsSubstring(log, expectedLog) { - receivedLogs[expectedLog] = struct{}{} - break - } - } - - allLogsReceived := true - for _, log := range expectedLogs { - if _, ok := receivedLogs[log]; !ok { - allLogsReceived = false - break - } - } - - if allLogsReceived { - return nil - } - - default: - if time.Since(start) > timeout { - missingLogs := []string{} - for _, log := range expectedLogs { - if _, ok := receivedLogs[log]; !ok { - missingLogs = append(missingLogs, log) - } - } - return errors.New("timed out waiting for log: " + strings.Join(missingLogs, ", ")) - } - time.Sleep(100 * time.Millisecond) - } - } -} - -func containsSubstring(s, substr string) bool { - return strings.Contains(s, substr) -} From 6b5e33f2bc057366791458f94fd3de696d16fe5d Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Thu, 12 Sep 2024 22:45:32 +0800 Subject: [PATCH 06/14] tests: add integration tests for pebble run --- tests/main_test.go | 150 ++++++++++++++++++++++++++++++++++++-- tests/run_test.go | 175 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 313 insertions(+), 12 deletions(-) diff --git a/tests/main_test.go b/tests/main_test.go index cbdf6e24..07b8841d 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -17,10 +17,13 @@ package tests import ( + "errors" "fmt" + "net" "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -51,7 +54,16 @@ func createLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML layerPath := filepath.Join(layersDir, layerFileName) err = os.WriteFile(layerPath, []byte(layerYAML), 0o755) if err != nil { - t.Fatalf("Error creating layers file: %v", err) + t.Fatalf("Cannot create layers file: %v", err) + } +} + +func createIdentitiesFile(t *testing.T, pebbleDir string, identitiesFileName string, identitiesYAML string) { + t.Helper() + + identitiesPath := filepath.Join(pebbleDir, identitiesFileName) + if err := os.WriteFile(identitiesPath, []byte(identitiesYAML), 0o755); err != nil { + t.Fatalf("Cannot create layers file: %v", err) } } @@ -71,9 +83,13 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog cmd.Wait() }) + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("Cannot create stdout pipe: %v", err) + } stderrPipe, err := cmd.StderrPipe() if err != nil { - t.Fatalf("Error creating stderr pipe: %v", err) + t.Fatalf("Cannot create stderr pipe: %v", err) } err = cmd.Start() @@ -81,27 +97,100 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog t.Fatalf("Error starting 'pebble run': %v", err) } + done := make(chan struct{}) go func() { defer close(logsCh) - parser := servicelog.NewParser(stderrPipe, 4*1024) - for parser.Next() { - if err := parser.Err(); err != nil { - t.Errorf("Cannot parse Pebble logs: %v", err) + + readLogs := func(parser *servicelog.Parser) { + for parser.Next() { + if err := parser.Err(); err != nil { + t.Errorf("Cannot parse Pebble logs: %v", err) + } + select { + case logsCh <- parser.Entry(): + case <-done: + return + } } - logsCh <- parser.Entry() } + + // Both stderr and stdout are needed, because pebble logs to stderr + // while with "--verbose", services otuput to stdout. + stderrParser := servicelog.NewParser(stderrPipe, 4*1024) + stdoutParser := servicelog.NewParser(stdoutPipe, 4*1024) + + // Channel to signal completion and close logsCh + done := make(chan struct{}) + defer close(done) + + go readLogs(stderrParser) + go readLogs(stdoutParser) + + // Wait for both parsers to finish + <-done + <-done }() return logsCh } +func waitForLogs(logsCh <-chan servicelog.Entry, expectedLogs []string, timeout time.Duration) error { + receivedLogs := make(map[string]struct{}) + + timeoutCh := time.After(timeout) + for { + select { + case log, ok := <-logsCh: + if !ok { + return errors.New("channel closed before all expected logs were received") + } + + for _, expectedLog := range expectedLogs { + if _, ok := receivedLogs[expectedLog]; !ok && containsSubstring(log.Message, expectedLog) { + receivedLogs[expectedLog] = struct{}{} + break + } + } + + allLogsReceived := true + for _, log := range expectedLogs { + if _, ok := receivedLogs[log]; !ok { + allLogsReceived = false + break + } + } + + if allLogsReceived { + return nil + } + + case <-timeoutCh: + missingLogs := []string{} + for _, log := range expectedLogs { + if _, ok := receivedLogs[log]; !ok { + missingLogs = append(missingLogs, log) + } + } + return errors.New("timed out waiting for log: " + strings.Join(missingLogs, ", ")) + } + } +} + +func containsSubstring(s, substr string) bool { + return strings.Contains(s, substr) +} + func waitForServices(t *testing.T, pebbleDir string, expectedServices []string, timeout time.Duration) { + t.Helper() + for _, service := range expectedServices { waitForService(t, pebbleDir, service, timeout) } } func waitForService(t *testing.T, pebbleDir string, service string, timeout time.Duration) { + t.Helper() + serviceFilePath := filepath.Join(pebbleDir, service) timeoutCh := time.After(timeout) ticker := time.NewTicker(time.Millisecond) @@ -120,3 +209,50 @@ func waitForService(t *testing.T, pebbleDir string, service string, timeout time } } } + +func isPortUsedByProcess(t *testing.T, port string, processName string) bool { + t.Helper() + + conn, err := net.Listen("tcp", ":"+port) + if err == nil { + conn.Close() + return false + } + if conn != nil { + conn.Close() + } + + cmd := exec.Command("lsof", "-i", ":"+port) + output, err := cmd.Output() + if err != nil { + t.Errorf("Error running lsof command: %v", err) + return false + } + + outputStr := string(output) + if strings.Contains(outputStr, processName) { + return true + } + + return false +} + +func runPebbleCmdAndCheckOutput(t *testing.T, pebbleDir string, expectedOutput []string, args ...string) { + t.Helper() + + cmd := exec.Command("../pebble", args...) + cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("error executing pebble command: %v", err) + } + + outputStr := string(output) + + for _, expected := range expectedOutput { + if !strings.Contains(outputStr, expected) { + t.Errorf("Expected output %s not found in command output", expected) + } + } +} diff --git a/tests/run_test.go b/tests/run_test.go index 300c768d..c5406199 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -18,28 +18,29 @@ package tests import ( "fmt" + "os" "path/filepath" "strings" "testing" "time" ) -func TestPebbleRunNormal(t *testing.T) { +func TestNormal(t *testing.T) { pebbleDir := t.TempDir() layerYAML := ` services: svc1: override: replace - command: {{.svc1Cmd}} + command: /bin/sh -c "{{.svc1Cmd}}" startup: enabled svc2: override: replace - command: {{.svc2Cmd}} + command: /bin/sh -c "{{.svc2Cmd}}" startup: enabled ` - svc1Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc1")) - svc2Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc2")) + svc1Cmd := fmt.Sprintf("touch %s; sleep 1000", filepath.Join(pebbleDir, "svc1")) + svc2Cmd := fmt.Sprintf("touch %s; sleep 1000", filepath.Join(pebbleDir, "svc2")) layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) layerYAML = strings.Replace(layerYAML, "{{.svc2Cmd}}", svc2Cmd, -1) @@ -50,3 +51,167 @@ services: expectedServices := []string{"svc1", "svc2"} waitForServices(t, pebbleDir, expectedServices, time.Second*3) } + +func TestCreateDirs(t *testing.T) { + tmpDir := t.TempDir() + pebbleDir := filepath.Join(tmpDir, "PEBBLE_HOME") + + logsCh := pebbleRun(t, pebbleDir, "--create-dirs") + expectedLogs := []string{"Started daemon"} + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } + + if _, err := os.Stat(pebbleDir); err != nil { + t.Errorf("pebble run --create-dirs failed: %v", err) + } +} + +func TestHold(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + svc1: + override: replace + command: /bin/sh -c "{{.svc1Cmd}}" + startup: enabled +` + svc1Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc1")) + layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) + + createLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) + + logsCh := pebbleRun(t, pebbleDir, "--hold") + expectedLogs := []string{"Started daemon"} + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } + + // Sleep a second before checking services because immediate check + // can't guarantee that svc1 is not started shortly after the log "Started daemon". + time.Sleep(time.Second) + + _, err := os.Stat(filepath.Join(pebbleDir, "svc1")) + if err == nil { + t.Error("pebble run --hold failed, services are still started") + } else { + if !os.IsNotExist(err) { + t.Errorf("Error checking service %s: %v", "svc1", err) + fmt.Printf("Error checking the file: %v\n", err) + } + } +} + +func TestHttpPort(t *testing.T) { + pebbleDir := t.TempDir() + + port := "4000" + logsCh := pebbleRun(t, pebbleDir, "--http=:"+port) + expectedLogs := []string{"Started daemon"} + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } + + if !isPortUsedByProcess(t, port, "pebble") { + t.Errorf("Pebble is not listening on port %s", port) + } +} + +func TestVerbose(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + svc1: + override: replace + command: /bin/sh -c "{{.svc1Cmd}}" + startup: enabled +` + layersFileName := "001-simple-layer.yaml" + svc1Cmd := fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)) + layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) + + createLayer(t, pebbleDir, layersFileName, layerYAML) + + logsCh := pebbleRun(t, pebbleDir, "--verbose") + expectedLogs := []string{ + "Started daemon", + "services:", + "svc1:", + "override: replace", + "startup: enabled", + "command: /bin/sh -c", + } + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } +} + +func TestArgs(t *testing.T) { + pebbleDir := t.TempDir() + + layerYAML := ` +services: + svc1: + override: replace + command: /bin/sh + startup: enabled +` + layersFileName := "001-simple-layer.yaml" + svc1Cmd := fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)) + layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) + + createLayer(t, pebbleDir, layersFileName, layerYAML) + + logsCh := pebbleRun(t, pebbleDir, "--verbose", + "--args", + "svc1", + "-c", + fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)), + ) + expectedLogs := []string{ + "Started daemon", + "services:", + "svc1:", + "override: replace", + "startup: enabled", + "command: /bin/sh", + } + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } +} + +func TestIdentities(t *testing.T) { + pebbleDir := t.TempDir() + + identitiesYAML := ` +identities: + bob: + access: admin + local: + user-id: 42 + alice: + access: read + local: + user-id: 2000 +` + identitiesFileName := "idents-add.yaml" + createIdentitiesFile(t, pebbleDir, identitiesFileName, identitiesYAML) + + logsCh := pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) + expectedLogs := []string{ + "Started daemon", + "POST /v1/services", + } + if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { + t.Errorf("Error waiting for logs: %v", err) + } + + expectedOutput := []string{"access: admin", "local:", "user-id: 42"} + runPebbleCmdAndCheckOutput(t, pebbleDir, expectedOutput, "identity", "bob") + + expectedOutput = []string{"access: read", "local:", "user-id: 2000"} + runPebbleCmdAndCheckOutput(t, pebbleDir, expectedOutput, "identity", "alice") +} From bf21bdbd69bfa9fe07a5f54fab42d95075ce6044 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Thu, 12 Sep 2024 22:53:15 +0800 Subject: [PATCH 07/14] chore: update integration tests readme --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index d7526c18..b946a288 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,7 +10,7 @@ go test -count=1 -tags=integration ./tests/ ### Visual Studio Code Settings -For the VSCode Go extention to work properly with files with build tags, add the following: +For the VSCode Go and gopls extention to work properly with files containing build tags, add the following: ```json { From e8f37b27905f0b1fc87fe5c51a9a1594d42797d0 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 18 Sep 2024 19:23:49 +0800 Subject: [PATCH 08/14] test: add gha workflow for integration test --- .github/workflows/integration-test.yml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/integration-test.yml diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..cb7fb855 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,29 @@ +name: Integration Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + go: ['1.22'] + + name: Go ${{ matrix.go }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Integration test + run: go test -count=1 -tags=integration ./tests/ From 949a95583cae71f84ab74abfee4bb19e60ea3f5a Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Wed, 18 Sep 2024 22:04:42 +0800 Subject: [PATCH 09/14] chore: refactor after code review --- tests/main_test.go | 145 ++++++++---------------------------- tests/run_test.go | 181 ++++++++++++++++++++++----------------------- 2 files changed, 119 insertions(+), 207 deletions(-) diff --git a/tests/main_test.go b/tests/main_test.go index 07b8841d..f41089ca 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -17,9 +17,7 @@ package tests import ( - "errors" "fmt" - "net" "os" "os/exec" "path/filepath" @@ -42,13 +40,13 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } -func createLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML string) { +func createLayer(t *testing.T, pebbleDir, layerFileName, layerYAML string) { t.Helper() layersDir := filepath.Join(pebbleDir, "layers") err := os.MkdirAll(layersDir, 0o755) if err != nil { - t.Fatalf("Cannot create layers directory: pipe: %v", err) + t.Fatalf("Cannot create layers directory: %v", err) } layerPath := filepath.Join(layersDir, layerFileName) @@ -58,31 +56,15 @@ func createLayer(t *testing.T, pebbleDir string, layerFileName string, layerYAML } } -func createIdentitiesFile(t *testing.T, pebbleDir string, identitiesFileName string, identitiesYAML string) { +func pebbleRun(t *testing.T, pebbleDir string, args ...string) (<-chan servicelog.Entry, <-chan servicelog.Entry) { t.Helper() - identitiesPath := filepath.Join(pebbleDir, identitiesFileName) - if err := os.WriteFile(identitiesPath, []byte(identitiesYAML), 0o755); err != nil { - t.Fatalf("Cannot create layers file: %v", err) - } -} - -func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog.Entry { - t.Helper() - - logsCh := make(chan servicelog.Entry) + stdoutCh := make(chan servicelog.Entry) + stderrCh := make(chan servicelog.Entry) cmd := exec.Command("../pebble", append([]string{"run"}, args...)...) cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) - t.Cleanup(func() { - err := cmd.Process.Signal(os.Interrupt) - if err != nil { - t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) - } - cmd.Wait() - }) - stdoutPipe, err := cmd.StdoutPipe() if err != nil { t.Fatalf("Cannot create stdout pipe: %v", err) @@ -97,17 +79,26 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog t.Fatalf("Error starting 'pebble run': %v", err) } + t.Cleanup(func() { + err := cmd.Process.Signal(os.Interrupt) + if err != nil { + t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) + } + cmd.Wait() + }) + done := make(chan struct{}) go func() { - defer close(logsCh) + defer close(stdoutCh) + defer close(stderrCh) - readLogs := func(parser *servicelog.Parser) { + readLogs := func(parser *servicelog.Parser, ch chan servicelog.Entry) { for parser.Next() { if err := parser.Err(); err != nil { t.Errorf("Cannot parse Pebble logs: %v", err) } select { - case logsCh <- parser.Entry(): + case ch <- parser.Entry(): case <-done: return } @@ -119,125 +110,59 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) <-chan servicelog stderrParser := servicelog.NewParser(stderrPipe, 4*1024) stdoutParser := servicelog.NewParser(stdoutPipe, 4*1024) - // Channel to signal completion and close logsCh - done := make(chan struct{}) - defer close(done) - - go readLogs(stderrParser) - go readLogs(stdoutParser) + go readLogs(stdoutParser, stdoutCh) + go readLogs(stderrParser, stderrCh) // Wait for both parsers to finish <-done <-done }() - return logsCh + return stdoutCh, stderrCh } -func waitForLogs(logsCh <-chan servicelog.Entry, expectedLogs []string, timeout time.Duration) error { - receivedLogs := make(map[string]struct{}) +func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedLog string, timeout time.Duration) { + t.Helper() timeoutCh := time.After(timeout) for { select { case log, ok := <-logsCh: if !ok { - return errors.New("channel closed before all expected logs were received") + t.Error("channel closed before all expected logs were received") } - for _, expectedLog := range expectedLogs { - if _, ok := receivedLogs[expectedLog]; !ok && containsSubstring(log.Message, expectedLog) { - receivedLogs[expectedLog] = struct{}{} - break - } - } - - allLogsReceived := true - for _, log := range expectedLogs { - if _, ok := receivedLogs[log]; !ok { - allLogsReceived = false - break - } - } - - if allLogsReceived { - return nil + if strings.Contains(log.Message, expectedLog) { + return } case <-timeoutCh: - missingLogs := []string{} - for _, log := range expectedLogs { - if _, ok := receivedLogs[log]; !ok { - missingLogs = append(missingLogs, log) - } - } - return errors.New("timed out waiting for log: " + strings.Join(missingLogs, ", ")) + t.Fatalf("timed out after %v waiting for log %s", 3*time.Second, expectedLog) } } } -func containsSubstring(s, substr string) bool { - return strings.Contains(s, substr) -} - -func waitForServices(t *testing.T, pebbleDir string, expectedServices []string, timeout time.Duration) { - t.Helper() - - for _, service := range expectedServices { - waitForService(t, pebbleDir, service, timeout) - } -} - -func waitForService(t *testing.T, pebbleDir string, service string, timeout time.Duration) { +func waitForFile(t *testing.T, file string, timeout time.Duration) { t.Helper() - serviceFilePath := filepath.Join(pebbleDir, service) timeoutCh := time.After(timeout) ticker := time.NewTicker(time.Millisecond) for { select { case <-timeoutCh: - t.Errorf("timeout waiting for service %s", service) - return + t.Fatalf("timeout waiting for file %s", file) case <-ticker.C: - stat, err := os.Stat(serviceFilePath) + stat, err := os.Stat(file) if err == nil && stat.Mode().IsRegular() { - os.Remove(serviceFilePath) + os.Remove(file) return } } } } -func isPortUsedByProcess(t *testing.T, port string, processName string) bool { - t.Helper() - - conn, err := net.Listen("tcp", ":"+port) - if err == nil { - conn.Close() - return false - } - if conn != nil { - conn.Close() - } - - cmd := exec.Command("lsof", "-i", ":"+port) - output, err := cmd.Output() - if err != nil { - t.Errorf("Error running lsof command: %v", err) - return false - } - - outputStr := string(output) - if strings.Contains(outputStr, processName) { - return true - } - - return false -} - -func runPebbleCmdAndCheckOutput(t *testing.T, pebbleDir string, expectedOutput []string, args ...string) { +func runPebbleCommand(t *testing.T, pebbleDir string, args ...string) string { t.Helper() cmd := exec.Command("../pebble", args...) @@ -248,11 +173,5 @@ func runPebbleCmdAndCheckOutput(t *testing.T, pebbleDir string, expectedOutput [ t.Fatalf("error executing pebble command: %v", err) } - outputStr := string(output) - - for _, expected := range expectedOutput { - if !strings.Contains(outputStr, expected) { - t.Errorf("Expected output %s not found in command output", expected) - } - } + return string(output) } diff --git a/tests/run_test.go b/tests/run_test.go index c5406199..77a3d629 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -17,135 +17,130 @@ package tests import ( + "errors" "fmt" + "io/fs" + "net" + "net/http" "os" "path/filepath" - "strings" "testing" "time" ) -func TestNormal(t *testing.T) { +func TestStartupEnabledServices(t *testing.T) { pebbleDir := t.TempDir() - layerYAML := ` + layerYAML := fmt.Sprintf(` services: svc1: override: replace - command: /bin/sh -c "{{.svc1Cmd}}" + command: /bin/sh -c "touch %s; sleep 1000" startup: enabled svc2: override: replace - command: /bin/sh -c "{{.svc2Cmd}}" + command: /bin/sh -c "touch %s; sleep 1000" startup: enabled -` - svc1Cmd := fmt.Sprintf("touch %s; sleep 1000", filepath.Join(pebbleDir, "svc1")) - svc2Cmd := fmt.Sprintf("touch %s; sleep 1000", filepath.Join(pebbleDir, "svc2")) - layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) - layerYAML = strings.Replace(layerYAML, "{{.svc2Cmd}}", svc2Cmd, -1) +`, + filepath.Join(pebbleDir, "svc1"), + filepath.Join(pebbleDir, "svc2"), + ) createLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) - _ = pebbleRun(t, pebbleDir) + _, _ = pebbleRun(t, pebbleDir) - expectedServices := []string{"svc1", "svc2"} - waitForServices(t, pebbleDir, expectedServices, time.Second*3) + waitForFile(t, filepath.Join(pebbleDir, "svc1"), 3*time.Second) + waitForFile(t, filepath.Join(pebbleDir, "svc2"), 3*time.Second) } func TestCreateDirs(t *testing.T) { tmpDir := t.TempDir() - pebbleDir := filepath.Join(tmpDir, "PEBBLE_HOME") + pebbleDir := filepath.Join(tmpDir, "pebble") - logsCh := pebbleRun(t, pebbleDir, "--create-dirs") - expectedLogs := []string{"Started daemon"} - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) - } + _, stderrCh := pebbleRun(t, pebbleDir, "--create-dirs") + waitForLog(t, stderrCh, "Started daemon", 3*time.Second) - if _, err := os.Stat(pebbleDir); err != nil { - t.Errorf("pebble run --create-dirs failed: %v", err) + st, err := os.Stat(pebbleDir) + if err != nil { + t.Fatalf("pebble run --create-dirs didn't create Pebble directory: %v", err) + } + if !st.IsDir() { + t.Fatalf("pebble dir %s is not a directory: %v", pebbleDir, err) } } func TestHold(t *testing.T) { pebbleDir := t.TempDir() - layerYAML := ` + layerYAML := fmt.Sprintf(` services: svc1: override: replace - command: /bin/sh -c "{{.svc1Cmd}}" + command: /bin/sh -c "touch %s; sleep 1000" startup: enabled -` - svc1Cmd := fmt.Sprintf("touch %s ; sleep 1000", filepath.Join(pebbleDir, "svc1")) - layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) - +`, + filepath.Join(pebbleDir, "svc1"), + ) createLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) - logsCh := pebbleRun(t, pebbleDir, "--hold") - expectedLogs := []string{"Started daemon"} - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) - } + _, _ = pebbleRun(t, pebbleDir, "--hold") - // Sleep a second before checking services because immediate check + // Sleep 100 millisecond before checking services because immediate check // can't guarantee that svc1 is not started shortly after the log "Started daemon". - time.Sleep(time.Second) + time.Sleep(100 * time.Millisecond) _, err := os.Stat(filepath.Join(pebbleDir, "svc1")) if err == nil { - t.Error("pebble run --hold failed, services are still started") - } else { - if !os.IsNotExist(err) { - t.Errorf("Error checking service %s: %v", "svc1", err) - fmt.Printf("Error checking the file: %v\n", err) - } + t.Fatal("pebble run --hold failed, services are still started") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Stat returned error other than ErrNotExist: %v", err) } } -func TestHttpPort(t *testing.T) { +func TestHTTPPort(t *testing.T) { pebbleDir := t.TempDir() - port := "4000" - logsCh := pebbleRun(t, pebbleDir, "--http=:"+port) - expectedLogs := []string{"Started daemon"} - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) + port := "61382" + _, stderrCh := pebbleRun(t, pebbleDir, "--http=:"+port) + waitForLog(t, stderrCh, "Started daemon", 3*time.Second) + + conn, err := net.Listen("tcp", ":"+port) + if err == nil { + conn.Close() + t.Fatalf("port %s is not being listened: %v", port, err) + } + if conn != nil { + conn.Close() } - if !isPortUsedByProcess(t, port, "pebble") { - t.Errorf("Pebble is not listening on port %s", port) + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/v1/health", port)) + if err != nil { + t.Fatalf("port %s is not being listened by : %v", port, err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("error checking pebble /v1/health on port %s: %v", port, err) } } func TestVerbose(t *testing.T) { pebbleDir := t.TempDir() + layersFileName := "001-simple-layer.yaml" layerYAML := ` services: svc1: override: replace - command: /bin/sh -c "{{.svc1Cmd}}" + command: /bin/sh -c "echo 'hello world'; sleep 1000" startup: enabled ` - layersFileName := "001-simple-layer.yaml" - svc1Cmd := fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)) - layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) - createLayer(t, pebbleDir, layersFileName, layerYAML) - logsCh := pebbleRun(t, pebbleDir, "--verbose") - expectedLogs := []string{ - "Started daemon", - "services:", - "svc1:", - "override: replace", - "startup: enabled", - "command: /bin/sh -c", - } - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) - } + stdoutCh, stderrCh := pebbleRun(t, pebbleDir, "--verbose") + waitForLog(t, stderrCh, "Started daemon", 3*time.Second) + waitForLog(t, stdoutCh, "hello world", 3*time.Second) } func TestArgs(t *testing.T) { @@ -159,28 +154,16 @@ services: startup: enabled ` layersFileName := "001-simple-layer.yaml" - svc1Cmd := fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)) - layerYAML = strings.Replace(layerYAML, "{{.svc1Cmd}}", svc1Cmd, -1) - createLayer(t, pebbleDir, layersFileName, layerYAML) - logsCh := pebbleRun(t, pebbleDir, "--verbose", + stdoutCh, stderrCh := pebbleRun(t, pebbleDir, "--verbose", "--args", "svc1", "-c", - fmt.Sprintf("cat %s; sleep 1000", filepath.Join(pebbleDir, "layers", layersFileName)), + "echo 'hello world'; sleep 1000", ) - expectedLogs := []string{ - "Started daemon", - "services:", - "svc1:", - "override: replace", - "startup: enabled", - "command: /bin/sh", - } - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) - } + waitForLog(t, stderrCh, "Started daemon", 3*time.Second) + waitForLog(t, stdoutCh, "hello world", 3*time.Second) } func TestIdentities(t *testing.T) { @@ -198,20 +181,30 @@ identities: user-id: 2000 ` identitiesFileName := "idents-add.yaml" - createIdentitiesFile(t, pebbleDir, identitiesFileName, identitiesYAML) - - logsCh := pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) - expectedLogs := []string{ - "Started daemon", - "POST /v1/services", - } - if err := waitForLogs(logsCh, expectedLogs, time.Second*3); err != nil { - t.Errorf("Error waiting for logs: %v", err) + if err := os.WriteFile(filepath.Join(pebbleDir, identitiesFileName), []byte(identitiesYAML), 0o755); err != nil { + t.Fatalf("Cannot write identities file: %v", err) } - expectedOutput := []string{"access: admin", "local:", "user-id: 42"} - runPebbleCmdAndCheckOutput(t, pebbleDir, expectedOutput, "identity", "bob") + _, _ = pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) + time.Sleep(100 * time.Millisecond) + + output := runPebbleCommand(t, pebbleDir, "identity", "bob") + expected := ` +access: admin +local: + user-id: 42 +`[1:] + if output != expected { + t.Fatalf("error checking identities. expected: %s; got: %s", expected, output) + } - expectedOutput = []string{"access: read", "local:", "user-id: 2000"} - runPebbleCmdAndCheckOutput(t, pebbleDir, expectedOutput, "identity", "alice") + output = runPebbleCommand(t, pebbleDir, "identity", "alice") + expected = ` +access: read +local: + user-id: 2000 +`[1:] + if output != expected { + t.Fatalf("error checking identities. expected: %s; got: %s", expected, output) + } } From d54ce7d7e741f72c5e4a7c0401332a8e633a7da6 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Thu, 19 Sep 2024 17:14:59 +0800 Subject: [PATCH 10/14] chore: refactor after code review --- ...gration-test.yml => integration-tests.yml} | 14 ++--- HACKING.md | 7 +++ tests/README.md | 2 + tests/main_test.go | 58 +++++++++---------- tests/run_test.go | 42 +++----------- 5 files changed, 48 insertions(+), 75 deletions(-) rename .github/workflows/{integration-test.yml => integration-tests.yml} (59%) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-tests.yml similarity index 59% rename from .github/workflows/integration-test.yml rename to .github/workflows/integration-tests.yml index cb7fb855..7ee07004 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,4 @@ -name: Integration Test +name: Integration Tests on: push: @@ -9,13 +9,7 @@ on: jobs: test: runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - go: ['1.22'] - - name: Go ${{ matrix.go }} + name: tests steps: - uses: actions/checkout@v4 @@ -23,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go }} + go-version: 1.22 - - name: Integration test + - name: Run tests run: go test -count=1 -tags=integration ./tests/ diff --git a/HACKING.md b/HACKING.md index c421d905..63d0c413 100644 --- a/HACKING.md +++ b/HACKING.md @@ -202,6 +202,13 @@ ok github.com/canonical/pebble/cmd/pebble 0.165s ... ``` +Pebble also has a suite of integration tests for testing things like `pebble run`. To run them, use the "integration" build constraint: + +``` +$ go test -count=1 -tags=integration ./tests/ +ok github.com/canonical/pebble/tests 4.774s +``` + ## Docs We use [`sphinx`](https://www.sphinx-doc.org/en/master/) to build the docs with styles preconfigured by the [Canonical Documentation Starter Pack](https://github.com/canonical/sphinx-docs-starter-pack). diff --git a/tests/README.md b/tests/README.md index b946a288..7df5dd3f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,7 @@ # Pebble Integration Tests +This directory holds a suite of integration tests for end-to-end tests of things like pebble run. They use the standard go test runner, but are only executed if you set the integration build constraint. + ## Run Tests ```bash diff --git a/tests/main_test.go b/tests/main_test.go index f41089ca..8e11a29e 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -56,11 +56,11 @@ func createLayer(t *testing.T, pebbleDir, layerFileName, layerYAML string) { } } -func pebbleRun(t *testing.T, pebbleDir string, args ...string) (<-chan servicelog.Entry, <-chan servicelog.Entry) { +func pebbleRun(t *testing.T, pebbleDir string, args ...string) (stdoutCh chan servicelog.Entry, stderrCh chan servicelog.Entry) { t.Helper() - stdoutCh := make(chan servicelog.Entry) - stderrCh := make(chan servicelog.Entry) + stdoutCh = make(chan servicelog.Entry) + stderrCh = make(chan servicelog.Entry) cmd := exec.Command("../pebble", append([]string{"run"}, args...)...) cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) @@ -79,49 +79,44 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) (<-chan servicelo t.Fatalf("Error starting 'pebble run': %v", err) } + stopStdout := make(chan struct{}, 1) + stopStderr := make(chan struct{}, 1) + t.Cleanup(func() { err := cmd.Process.Signal(os.Interrupt) if err != nil { t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) } cmd.Wait() + stopStdout <- struct{}{} + stopStderr <- struct{}{} }) - done := make(chan struct{}) - go func() { - defer close(stdoutCh) - defer close(stderrCh) - - readLogs := func(parser *servicelog.Parser, ch chan servicelog.Entry) { - for parser.Next() { - if err := parser.Err(); err != nil { - t.Errorf("Cannot parse Pebble logs: %v", err) - } - select { - case ch <- parser.Entry(): - case <-done: - return - } + readLogs := func(parser *servicelog.Parser, ch chan servicelog.Entry, stop <-chan struct{}) { + for parser.Next() { + if err := parser.Err(); err != nil { + t.Errorf("Cannot parse Pebble logs: %v", err) + } + select { + case ch <- parser.Entry(): + case <-stop: + return } } + } - // Both stderr and stdout are needed, because pebble logs to stderr - // while with "--verbose", services otuput to stdout. - stderrParser := servicelog.NewParser(stderrPipe, 4*1024) - stdoutParser := servicelog.NewParser(stdoutPipe, 4*1024) - - go readLogs(stdoutParser, stdoutCh) - go readLogs(stderrParser, stderrCh) + // Both stderr and stdout are needed, because pebble logs to stderr + // while with "--verbose", services output to stdout. + stderrParser := servicelog.NewParser(stderrPipe, 4*1024) + stdoutParser := servicelog.NewParser(stdoutPipe, 4*1024) - // Wait for both parsers to finish - <-done - <-done - }() + go readLogs(stdoutParser, stdoutCh, stopStdout) + go readLogs(stderrParser, stderrCh, stopStderr) return stdoutCh, stderrCh } -func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedLog string, timeout time.Duration) { +func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedService, expectedLog string, timeout time.Duration) { t.Helper() timeoutCh := time.After(timeout) @@ -132,7 +127,7 @@ func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedLog string t.Error("channel closed before all expected logs were received") } - if strings.Contains(log.Message, expectedLog) { + if log.Service == expectedService && strings.Contains(log.Message, expectedLog) { return } @@ -155,7 +150,6 @@ func waitForFile(t *testing.T, file string, timeout time.Duration) { case <-ticker.C: stat, err := os.Stat(file) if err == nil && stat.Mode().IsRegular() { - os.Remove(file) return } } diff --git a/tests/run_test.go b/tests/run_test.go index 77a3d629..e0b9dbb7 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io/fs" - "net" "net/http" "os" "path/filepath" @@ -59,7 +58,7 @@ func TestCreateDirs(t *testing.T) { pebbleDir := filepath.Join(tmpDir, "pebble") _, stderrCh := pebbleRun(t, pebbleDir, "--create-dirs") - waitForLog(t, stderrCh, "Started daemon", 3*time.Second) + waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) st, err := os.Stat(pebbleDir) if err != nil { @@ -104,16 +103,7 @@ func TestHTTPPort(t *testing.T) { port := "61382" _, stderrCh := pebbleRun(t, pebbleDir, "--http=:"+port) - waitForLog(t, stderrCh, "Started daemon", 3*time.Second) - - conn, err := net.Listen("tcp", ":"+port) - if err == nil { - conn.Close() - t.Fatalf("port %s is not being listened: %v", port, err) - } - if conn != nil { - conn.Close() - } + waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) resp, err := http.Get(fmt.Sprintf("http://localhost:%s/v1/health", port)) if err != nil { @@ -139,8 +129,8 @@ services: createLayer(t, pebbleDir, layersFileName, layerYAML) stdoutCh, stderrCh := pebbleRun(t, pebbleDir, "--verbose") - waitForLog(t, stderrCh, "Started daemon", 3*time.Second) - waitForLog(t, stdoutCh, "hello world", 3*time.Second) + waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) + waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) } func TestArgs(t *testing.T) { @@ -162,8 +152,8 @@ services: "-c", "echo 'hello world'; sleep 1000", ) - waitForLog(t, stderrCh, "Started daemon", 3*time.Second) - waitForLog(t, stdoutCh, "hello world", 3*time.Second) + waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) + waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) } func TestIdentities(t *testing.T) { @@ -175,34 +165,20 @@ identities: access: admin local: user-id: 42 - alice: - access: read - local: - user-id: 2000 -` +`[1:] identitiesFileName := "idents-add.yaml" if err := os.WriteFile(filepath.Join(pebbleDir, identitiesFileName), []byte(identitiesYAML), 0o755); err != nil { t.Fatalf("Cannot write identities file: %v", err) } - _, _ = pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) - time.Sleep(100 * time.Millisecond) + _, stderrCh := pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) + waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) output := runPebbleCommand(t, pebbleDir, "identity", "bob") expected := ` access: admin local: user-id: 42 -`[1:] - if output != expected { - t.Fatalf("error checking identities. expected: %s; got: %s", expected, output) - } - - output = runPebbleCommand(t, pebbleDir, "identity", "alice") - expected = ` -access: read -local: - user-id: 2000 `[1:] if output != expected { t.Fatalf("error checking identities. expected: %s; got: %s", expected, output) From e6c29dc5e90c30657b96563a203d3ed0615a0128 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Thu, 19 Sep 2024 17:24:38 +0800 Subject: [PATCH 11/14] chore: refactor after review --- tests/run_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/run_test.go b/tests/run_test.go index e0b9dbb7..9d29a6b3 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -172,7 +172,11 @@ identities: } _, stderrCh := pebbleRun(t, pebbleDir, "--identities="+filepath.Join(pebbleDir, identitiesFileName)) - waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) + + // wait for log "Started daemon" like in other test cases then immediately run `pebble identity` would sometimes + // fail because the identities are not fully seeded. Waiting for the next log "POST /v1/services" can guarantee + // identities are seeded when running the `pebble identity` command without sleeping for a short period of time. + waitForLog(t, stderrCh, "pebble", "POST /v1/services", 3*time.Second) output := runPebbleCommand(t, pebbleDir, "identity", "bob") expected := ` From b2061035c35077e797acfa7e4a50c20659cae827 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Fri, 20 Sep 2024 10:41:48 +0800 Subject: [PATCH 12/14] chore: refactor after review --- tests/README.md | 10 +++++++++- tests/main_test.go | 42 +++++++++++++++++++++++++++++++----------- tests/run_test.go | 10 +++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/tests/README.md b/tests/README.md index 7df5dd3f..362aa1c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,11 +8,19 @@ This directory holds a suite of integration tests for end-to-end tests of things go test -count=1 -tags=integration ./tests/ ``` +The above command will build Pebble first, then run tests with it. + +To use an existing Pebble binary rather than building one, you can explicitly set the flag `-pebblebin`. For example, the following command will use a pre-built Pebble at `/home/ubuntu/pebble`: + +```bash +go test -v -count=1 -tags=integration ./tests -pebblebin=/home/ubuntu/pebble +``` + ## Developing ### Visual Studio Code Settings -For the VSCode Go and gopls extention to work properly with files containing build tags, add the following: +For VSCode Go and the gopls extention to work properly with files containing build tags, add the following: ```json { diff --git a/tests/main_test.go b/tests/main_test.go index 8e11a29e..af0a6a48 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -17,6 +17,7 @@ package tests import ( + "flag" "fmt" "os" "os/exec" @@ -28,18 +29,30 @@ import ( "github.com/canonical/pebble/internals/servicelog" ) -// TestMain builds the pebble binary before running the integration tests. +var pebbleBin = flag.String("pebblebin", "", "Path to the pre-built Pebble binary") + +// TestMain builds the pebble binary of `-pebblebin` flag is not set +// before running the integration tests. func TestMain(m *testing.M) { - goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble") - if err := goBuild.Run(); err != nil { - fmt.Println("Cannot build pebble binary:", err) - os.Exit(1) + flag.Parse() + + if *pebbleBin == "" { + goBuild := exec.Command("go", "build", "-o", "../pebble", "../cmd/pebble") + if err := goBuild.Run(); err != nil { + fmt.Println("Cannot build pebble binary:", err) + os.Exit(1) + } + *pebbleBin = "../pebble" + } else { + // Use the pre-built Pebble binary provided by the pebbleBin flag. + fmt.Println("Using pre-built Pebble binary at:", *pebbleBin) } exitCode := m.Run() os.Exit(exitCode) } +// createLayer creates a layer file with layerYAML under the directory "pebbleDir/layers". func createLayer(t *testing.T, pebbleDir, layerFileName, layerYAML string) { t.Helper() @@ -56,13 +69,15 @@ func createLayer(t *testing.T, pebbleDir, layerFileName, layerYAML string) { } } +// pebbleRun starts the pebble daemon (`pebble run`) with optional arguments +// and returns two channels for standard output and standard error. func pebbleRun(t *testing.T, pebbleDir string, args ...string) (stdoutCh chan servicelog.Entry, stderrCh chan servicelog.Entry) { t.Helper() stdoutCh = make(chan servicelog.Entry) stderrCh = make(chan servicelog.Entry) - cmd := exec.Command("../pebble", append([]string{"run"}, args...)...) + cmd := exec.Command(*pebbleBin, append([]string{"run"}, args...)...) cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) stdoutPipe, err := cmd.StdoutPipe() @@ -79,8 +94,8 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) (stdoutCh chan se t.Fatalf("Error starting 'pebble run': %v", err) } - stopStdout := make(chan struct{}, 1) - stopStderr := make(chan struct{}, 1) + stopStdout := make(chan struct{}) + stopStderr := make(chan struct{}) t.Cleanup(func() { err := cmd.Process.Signal(os.Interrupt) @@ -88,8 +103,8 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) (stdoutCh chan se t.Errorf("Error sending SIGINT/Ctrl+C to pebble: %v", err) } cmd.Wait() - stopStdout <- struct{}{} - stopStderr <- struct{}{} + close(stopStdout) + close(stopStderr) }) readLogs := func(parser *servicelog.Parser, ch chan servicelog.Entry, stop <-chan struct{}) { @@ -116,6 +131,8 @@ func pebbleRun(t *testing.T, pebbleDir string, args ...string) (stdoutCh chan se return stdoutCh, stderrCh } +// waitForLog waits until an expectedLog from an expectedService appears in the logs channel, or fails the test after a +// specified timeout if the expectedLog is still not found. func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedService, expectedLog string, timeout time.Duration) { t.Helper() @@ -137,6 +154,8 @@ func waitForLog(t *testing.T, logsCh <-chan servicelog.Entry, expectedService, e } } +// waitForFile waits until a file exists, or fails the test after a specified timeout +// if the file still doesn't exist. func waitForFile(t *testing.T, file string, timeout time.Duration) { t.Helper() @@ -156,10 +175,11 @@ func waitForFile(t *testing.T, file string, timeout time.Duration) { } } +// runPebbleCommand runs a pebble command and returns the standard output. func runPebbleCommand(t *testing.T, pebbleDir string, args ...string) string { t.Helper() - cmd := exec.Command("../pebble", args...) + cmd := exec.Command(*pebbleBin, args...) cmd.Env = append(os.Environ(), "PEBBLE="+pebbleDir) output, err := cmd.CombinedOutput() diff --git a/tests/run_test.go b/tests/run_test.go index 9d29a6b3..f19f7fa8 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -34,11 +34,11 @@ func TestStartupEnabledServices(t *testing.T) { services: svc1: override: replace - command: /bin/sh -c "touch %s; sleep 1000" + command: /bin/sh -c "touch %s; sleep 10" startup: enabled svc2: override: replace - command: /bin/sh -c "touch %s; sleep 1000" + command: /bin/sh -c "touch %s; sleep 10" startup: enabled `, filepath.Join(pebbleDir, "svc1"), @@ -76,7 +76,7 @@ func TestHold(t *testing.T) { services: svc1: override: replace - command: /bin/sh -c "touch %s; sleep 1000" + command: /bin/sh -c "touch %s; sleep 10" startup: enabled `, filepath.Join(pebbleDir, "svc1"), @@ -123,7 +123,7 @@ func TestVerbose(t *testing.T) { services: svc1: override: replace - command: /bin/sh -c "echo 'hello world'; sleep 1000" + command: /bin/sh -c "echo 'hello world'; sleep 10" startup: enabled ` createLayer(t, pebbleDir, layersFileName, layerYAML) @@ -150,7 +150,7 @@ services: "--args", "svc1", "-c", - "echo 'hello world'; sleep 1000", + "echo 'hello world'; sleep 10", ) waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) From 298c83928eaef0c2b8252dd1eea12e98a75c9bec Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Fri, 20 Sep 2024 10:49:08 +0800 Subject: [PATCH 13/14] chore: add comments for tests --- tests/run_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/run_test.go b/tests/run_test.go index f19f7fa8..c73c3655 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -27,6 +27,8 @@ import ( "time" ) +// TestStartupEnabledServices tests that Pebble will automatically start +// services defined with `startup: enabled`. func TestStartupEnabledServices(t *testing.T) { pebbleDir := t.TempDir() @@ -53,6 +55,8 @@ services: waitForFile(t, filepath.Join(pebbleDir, "svc2"), 3*time.Second) } +// TestCreateDirs tests that Pebble will create the Pebble directory on startup +// with the `--create-dirs` option. func TestCreateDirs(t *testing.T) { tmpDir := t.TempDir() pebbleDir := filepath.Join(tmpDir, "pebble") @@ -69,6 +73,8 @@ func TestCreateDirs(t *testing.T) { } } +// TestHold tests that Pebble will not default services automatically +// with the `--hold` option. func TestHold(t *testing.T) { pebbleDir := t.TempDir() @@ -98,6 +104,8 @@ services: } } +// TestHTTPPort tests that Pebble starts HTTP API listening on this port +// with the `--http` option. func TestHTTPPort(t *testing.T) { pebbleDir := t.TempDir() @@ -115,6 +123,8 @@ func TestHTTPPort(t *testing.T) { } } +// TestVerbose tests that Pebble logs all output from services to stdout +// with the `--verbose` option. func TestVerbose(t *testing.T) { pebbleDir := t.TempDir() @@ -133,6 +143,8 @@ services: waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) } +// TestArgs tests that Pebble provides additional arguments to a service +// with the `--args` option. func TestArgs(t *testing.T) { pebbleDir := t.TempDir() @@ -156,6 +168,8 @@ services: waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) } +// TestIdentities tests that Pebble seeds identities from a file +// with the `--identities` option. func TestIdentities(t *testing.T) { pebbleDir := t.TempDir() From a3ad795eeec2ea471ee19c018c2459faafb206b6 Mon Sep 17 00:00:00 2001 From: Tiexin Guo Date: Mon, 23 Sep 2024 15:08:40 +0800 Subject: [PATCH 14/14] chore: fix leaked sleep services --- tests/run_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/run_test.go b/tests/run_test.go index c73c3655..c98c6937 100644 --- a/tests/run_test.go +++ b/tests/run_test.go @@ -49,7 +49,8 @@ services: createLayer(t, pebbleDir, "001-simple-layer.yaml", layerYAML) - _, _ = pebbleRun(t, pebbleDir) + _, stderrCh := pebbleRun(t, pebbleDir) + waitForLog(t, stderrCh, "pebble", "Started default services", 3*time.Second) waitForFile(t, filepath.Join(pebbleDir, "svc1"), 3*time.Second) waitForFile(t, filepath.Join(pebbleDir, "svc2"), 3*time.Second) @@ -141,6 +142,7 @@ services: stdoutCh, stderrCh := pebbleRun(t, pebbleDir, "--verbose") waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) + waitForLog(t, stderrCh, "pebble", "Started default services", 3*time.Second) } // TestArgs tests that Pebble provides additional arguments to a service @@ -166,6 +168,7 @@ services: ) waitForLog(t, stderrCh, "pebble", "Started daemon", 3*time.Second) waitForLog(t, stdoutCh, "svc1", "hello world", 3*time.Second) + waitForLog(t, stderrCh, "pebble", "Started default services", 3*time.Second) } // TestIdentities tests that Pebble seeds identities from a file