diff --git a/internal/agent/logs.go b/internal/agent/logs.go new file mode 100644 index 0000000..42f3179 --- /dev/null +++ b/internal/agent/logs.go @@ -0,0 +1,231 @@ +package agent + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/kairos-io/kairos-agent/v2/pkg/config" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" + v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" + fsutils "github.com/kairos-io/kairos-agent/v2/pkg/utils/fs" + "github.com/kairos-io/kairos-sdk/collector" + "github.com/kairos-io/kairos-sdk/types" +) + +// LogsResult represents the collected logs +type LogsResult struct { + Journal map[string][]byte `yaml:"-"` + Files map[string][]byte `yaml:"-"` +} + +// LogsCollector handles the collection of logs from various sources +type LogsCollector struct { + config *config.Config +} + +// NewLogsCollector creates a new LogsCollector instance +func NewLogsCollector(cfg *config.Config) *LogsCollector { + return &LogsCollector{ + config: cfg, + } +} + +func defaultLogsConfig() *config.LogsConfig { + return &config.LogsConfig{ + Journal: []string{ + "kairos-agent", + "kairos-installer", + "kairos-webui", + "cos-setup-boot", + "cos-setup-fs", + "cos-setup-network", + "cos-setup-reconcile", + "k3s", + "k3s-agent", + "k0scontroller", + "k0sworker", + }, + Files: []string{ + "/var/log/kairos/*.log", + "/var/log/*.log", + "/run/immucore/*.log", + }, + } +} + +// isSystemdAvailable checks if systemd is available on the system +func (lc *LogsCollector) isSystemdAvailable() bool { + // Check for systemctl in common locations + for _, path := range []string{"/sbin/systemctl", "/bin/systemctl", "/usr/sbin/systemctl", "/usr/bin/systemctl"} { + if _, err := lc.config.Fs.Stat(path); err == nil { + lc.config.Logger.Debugf("Found systemd at %s", path) + return true + } + } + lc.config.Logger.Debugf("systemd not found, skipping journal log collection") + return false +} + +// Collect gathers logs based on the configuration stored in the LogsCollector +func (lc *LogsCollector) Collect() (*LogsResult, error) { + result := &LogsResult{ + Journal: make(map[string][]byte), + Files: make(map[string][]byte), + } + + // Define default configuration + logsConfig := defaultLogsConfig() + + // Merge user configuration with defaults + if lc.config.Logs != nil { + logsConfig.Journal = append(logsConfig.Journal, lc.config.Logs.Journal...) + logsConfig.Files = append(logsConfig.Files, lc.config.Logs.Files...) + } + + // Check if systemd is available before collecting journal logs + if lc.isSystemdAvailable() { + // Collect journal logs + for _, service := range logsConfig.Journal { + output, err := lc.config.Runner.Run("journalctl", "-u", service, "--no-pager", "-o", "cat") + if err != nil { + lc.config.Logger.Warnf("Failed to collect journal logs for service %s: %v", service, err) + continue + } + + // Skip services with no journal entries + if len(output) == 0 || string(output) == "-- No entries --" { + lc.config.Logger.Debugf("No journal entries found for service %s, skipping", service) + continue + } + + result.Journal[service] = output + } + } else { + lc.config.Logger.Infof("systemd not available, skipping journal log collection") + } + + // Collect file logs with globbing support + for _, pattern := range logsConfig.Files { + matches, err := lc.globFiles(pattern) + if err != nil { + lc.config.Logger.Warnf("Failed to glob pattern %s: %v", pattern, err) + continue + } + + for _, file := range matches { + content, err := lc.config.Fs.ReadFile(file) + if err != nil { + lc.config.Logger.Warnf("Failed to read file %s: %v", file, err) + continue + } + result.Files[file] = content + } + } + + return result, nil +} + +// CreateTarball creates a compressed tarball from the collected logs +func (lc *LogsCollector) CreateTarball(result *LogsResult, outputPath string) error { + // Create output directory if it doesn't exist + outputDir := filepath.Dir(outputPath) + if err := fsutils.MkdirAll(lc.config.Fs, outputDir, constants.DirPerm); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Create the tarball file + file, err := lc.config.Fs.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create tarball file: %w", err) + } + defer file.Close() + + // Create gzip writer + gw := gzip.NewWriter(file) + defer gw.Close() + + // Create tar writer + tw := tar.NewWriter(gw) + defer tw.Close() + + // Add journal logs to tarball + for service, content := range result.Journal { + header := &tar.Header{ + Name: fmt.Sprintf("journal/%s.log", service), + Mode: constants.FilePerm, + Size: int64(len(content)), + } + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("failed to write journal header: %w", err) + } + if _, err := tw.Write(content); err != nil { + return fmt.Errorf("failed to write journal content: %w", err) + } + } + + // Add file logs to tarball + for filePath, content := range result.Files { + // Remove leading slash and use full path structure + relativePath := strings.TrimPrefix(filePath, "/") + header := &tar.Header{ + Name: fmt.Sprintf("files/%s", relativePath), + Mode: constants.FilePerm, + Size: int64(len(content)), + } + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("failed to write file header: %w", err) + } + if _, err := tw.Write(content); err != nil { + return fmt.Errorf("failed to write file content: %w", err) + } + } + + return nil +} + +// globFiles expands glob patterns to matching files using the standard library +func (lc *LogsCollector) globFiles(pattern string) ([]string, error) { + matches, err := fs.Glob(lc.config.Fs, pattern) + if err != nil { + return nil, err + } + return matches, nil +} + +// ExecuteLogsCommand executes the logs command with the given parameters +func ExecuteLogsCommand(fs v1.FS, logger types.KairosLogger, runner v1.Runner, outputPath string) error { + // Scan for configuration from default locations + cfg, err := config.Scan(collector.Directories(constants.GetUserConfigDirs()...), collector.NoLogs) + if err != nil { + logger.Warnf("Failed to load configuration, using defaults: %v", err) + // Create a minimal config with just the required components + cfg = config.NewConfig( + config.WithFs(fs), + config.WithLogger(logger), + config.WithRunner(runner), + ) + } else { + // Update the scanned config with the provided components + cfg.Fs = fs + cfg.Logger = logger + cfg.Runner = runner + } + + collector := NewLogsCollector(cfg) + + result, err := collector.Collect() + if err != nil { + return fmt.Errorf("failed to collect logs: %w", err) + } + + if err := collector.CreateTarball(result, outputPath); err != nil { + return fmt.Errorf("failed to create tarball: %w", err) + } + + logger.Infof("Logs collected successfully to %s", outputPath) + return nil +} diff --git a/internal/agent/logs_test.go b/internal/agent/logs_test.go new file mode 100644 index 0000000..c32de93 --- /dev/null +++ b/internal/agent/logs_test.go @@ -0,0 +1,614 @@ +package agent + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "path/filepath" + + "github.com/kairos-io/kairos-agent/v2/pkg/config" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" + fsutils "github.com/kairos-io/kairos-agent/v2/pkg/utils/fs" + v1mock "github.com/kairos-io/kairos-agent/v2/tests/mocks" + "github.com/kairos-io/kairos-sdk/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/twpayne/go-vfs/v5/vfst" +) + +var _ = Describe("Logs Command", Label("logs", "cmd"), func() { + var ( + fs *vfst.TestFS + cleanup func() + err error + logger types.KairosLogger + runner *v1mock.FakeRunner + ) + + // Helper function to create a mock systemctl file for systemd detection + createMockSystemctl := func() { + err := fsutils.MkdirAll(fs, "/usr/bin", constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/usr/bin/systemctl", []byte("#!/bin/sh\necho 'mock systemctl'"), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + } + + BeforeEach(func() { + fs, cleanup, err = vfst.NewTestFS(nil) + Expect(err).ToNot(HaveOccurred()) + + logger = types.NewNullLogger() + runner = v1mock.NewFakeRunner() + + // Create mock systemctl for systemd detection in all tests + createMockSystemctl() + }) + + AfterEach(func() { + cleanup() + }) + + Describe("LogsCollector", func() { + var collector *LogsCollector + + BeforeEach(func() { + cfg := config.NewConfig( + config.WithFs(fs), + config.WithLogger(logger), + config.WithRunner(runner), + ) + collector = NewLogsCollector(cfg) + }) + + It("should collect journal logs", func() { + // Mock journalctl command output + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" && len(args) > 0 && args[0] == "-u" { + service := args[1] + return []byte("journal logs for " + service), nil + } + return []byte{}, nil + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"myservice"}, + Files: []string{}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify journalctl was called for both default and user-defined services + // Default services: kairos-agent, kairos-installer, kairos-webui, cos-setup-boot, cos-setup-fs, cos-setup-network, cos-setup-reconcile, k3s, k3s-agent, k0scontroller, k0sworker + // User service: myservice + Expect(runner.CmdsMatch([][]string{ + {"journalctl", "-u", "kairos-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-installer", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-webui", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-boot", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-fs", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-network", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-reconcile", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0scontroller", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0sworker", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "myservice", "--no-pager", "-o", "cat"}, + })).To(BeNil()) + + // Verify that both default and user-defined services are in the result + Expect(result.Journal).To(HaveKey("kairos-agent")) + Expect(result.Journal).To(HaveKey("kairos-installer")) + Expect(result.Journal).To(HaveKey("kairos-webui")) + Expect(result.Journal).To(HaveKey("cos-setup-boot")) + Expect(result.Journal).To(HaveKey("cos-setup-fs")) + Expect(result.Journal).To(HaveKey("cos-setup-network")) + Expect(result.Journal).To(HaveKey("cos-setup-reconcile")) + Expect(result.Journal).To(HaveKey("k3s")) + Expect(result.Journal).To(HaveKey("k3s-agent")) + Expect(result.Journal).To(HaveKey("k0scontroller")) + Expect(result.Journal).To(HaveKey("k0sworker")) + Expect(result.Journal).To(HaveKey("myservice")) + Expect(result.Journal).To(HaveLen(12)) + }) + + It("should collect file logs with globbing", func() { + // Create test files + testFiles := []string{ + "/var/log/test1.log", + "/var/log/test2.log", + "/var/log/subdir/test3.log", + "/var/log/kairos/agent.log", // Default pattern file + } + + for _, file := range testFiles { + err := fsutils.MkdirAll(fs, filepath.Dir(file), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + + err = fs.WriteFile(file, []byte("log content for "+file), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{}, + Files: []string{"/var/log/*.log", "/var/log/subdir/*.log"}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify files were collected (both default and user-defined patterns) + // Default pattern: /var/log/kairos/* (matches agent.log) + // User patterns: /var/log/*.log, /var/log/subdir/*.log + Expect(result.Files).To(HaveLen(4)) + Expect(result.Files).To(HaveKey("/var/log/test1.log")) + Expect(result.Files).To(HaveKey("/var/log/test2.log")) + Expect(result.Files).To(HaveKey("/var/log/subdir/test3.log")) + Expect(result.Files).To(HaveKey("/var/log/kairos/agent.log")) + }) + + It("should handle nested directories correctly", func() { + // Create test files with same basename in different directories + testFiles := []string{ + "/var/log/test.log", + "/var/log/subdir/test.log", // Same basename, different directory + "/var/log/kairos/agent.log", // Default pattern file + } + + for _, file := range testFiles { + err := fsutils.MkdirAll(fs, filepath.Dir(file), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + + err = fs.WriteFile(file, []byte("log content for "+file), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{}, + Files: []string{"/var/log/test.log", "/var/log/subdir/test.log"}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify both files are collected with full paths as keys (including default pattern) + Expect(result.Files).To(HaveKey("/var/log/test.log")) + Expect(result.Files).To(HaveKey("/var/log/subdir/test.log")) + Expect(result.Files).To(HaveKey("/var/log/kairos/agent.log")) + Expect(result.Files).To(HaveLen(3)) + + // Create tarball + tarballPath := "/tmp/logs.tar.gz" + err = collector.CreateTarball(result, tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Read and verify tarball contents + tarballData, err := fs.ReadFile(tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Extract and verify tarball structure + gzr, err := gzip.NewReader(bytes.NewReader(tarballData)) + Expect(err).ToNot(HaveOccurred()) + defer gzr.Close() + + tr := tar.NewReader(gzr) + files := make(map[string]bool) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + Expect(err).ToNot(HaveOccurred()) + files[header.Name] = true + } + + // Verify that all files are preserved with their full directory structure + Expect(files).To(HaveKey("files/var/log/test.log")) + Expect(files).To(HaveKey("files/var/log/subdir/test.log")) + Expect(files).To(HaveKey("files/var/log/kairos/agent.log")) + Expect(files).To(HaveLen(3)) + }) + + It("should create tarball with proper structure", func() { + // Mock journalctl output + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" { + return []byte("journal logs content"), nil + } + return []byte{}, nil + } + + // Create test file + testFile := "/var/log/test.log" + err := fsutils.MkdirAll(fs, filepath.Dir(testFile), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + + err = fs.WriteFile(testFile, []byte("file log content"), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"myservice"}, + Files: []string{testFile}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + + // Create tarball + tarballPath := "/tmp/logs.tar.gz" + err = collector.CreateTarball(result, tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Verify tarball exists and has correct structure + exists, err := fsutils.Exists(fs, tarballPath) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + // Read and verify tarball contents + tarballData, err := fs.ReadFile(tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Extract and verify tarball structure + gzr, err := gzip.NewReader(bytes.NewReader(tarballData)) + Expect(err).ToNot(HaveOccurred()) + defer gzr.Close() + + tr := tar.NewReader(gzr) + files := make(map[string]bool) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + Expect(err).ToNot(HaveOccurred()) + files[header.Name] = true + } + + // Verify expected structure (both default and user-defined) + // Default services: kairos-agent, kairos-installer, kairos-webui, cos-setup-boot, cos-setup-fs, cos-setup-network, cos-setup-reconcile, k3s, k3s-agent, k0scontroller, k0sworker + // User service: myservice + // Default files: /var/log/kairos-*.log (if exists) + // User file: /var/log/test.log + Expect(files).To(HaveKey("journal/kairos-agent.log")) + Expect(files).To(HaveKey("journal/kairos-installer.log")) + Expect(files).To(HaveKey("journal/kairos-webui.log")) + Expect(files).To(HaveKey("journal/cos-setup-boot.log")) + Expect(files).To(HaveKey("journal/cos-setup-fs.log")) + Expect(files).To(HaveKey("journal/cos-setup-network.log")) + Expect(files).To(HaveKey("journal/cos-setup-reconcile.log")) + Expect(files).To(HaveKey("journal/k3s.log")) + Expect(files).To(HaveKey("journal/k3s-agent.log")) + Expect(files).To(HaveKey("journal/k0scontroller.log")) + Expect(files).To(HaveKey("journal/k0sworker.log")) + Expect(files).To(HaveKey("journal/myservice.log")) + Expect(files).To(HaveKey("files/var/log/test.log")) + }) + + It("should handle missing files gracefully", func() { + // Create a default pattern file to ensure it's collected + defaultFile := "/var/log/kairos/agent.log" + err := fsutils.MkdirAll(fs, filepath.Dir(defaultFile), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile(defaultFile, []byte("default log content"), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{}, + Files: []string{"/var/log/nonexistent.log"}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + // Should have collected the default pattern file even though user file doesn't exist + Expect(result.Files).To(HaveLen(1)) + Expect(result.Files).To(HaveKey("/var/log/kairos/agent.log")) + }) + + It("should handle journal service errors gracefully", func() { + // Mock journalctl to return no entries for non-existent service + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" && len(args) > 1 && args[1] == "nonexistentservice" { + return []byte("-- No entries --"), nil + } + if command == "journalctl" { + return []byte("journal logs content"), nil + } + return []byte{}, nil + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"nonexistentservice"}, + Files: []string{}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + // Should have collected from default services but not the non-existent user service + Expect(result.Journal).To(HaveLen(11)) + Expect(result.Journal).To(HaveKey("kairos-agent")) + Expect(result.Journal).To(HaveKey("kairos-installer")) + Expect(result.Journal).To(HaveKey("kairos-webui")) + Expect(result.Journal).To(HaveKey("cos-setup-boot")) + Expect(result.Journal).To(HaveKey("cos-setup-fs")) + Expect(result.Journal).To(HaveKey("cos-setup-network")) + Expect(result.Journal).To(HaveKey("cos-setup-reconcile")) + Expect(result.Journal).To(HaveKey("k3s")) + Expect(result.Journal).To(HaveKey("k3s-agent")) + Expect(result.Journal).To(HaveKey("k0scontroller")) + Expect(result.Journal).To(HaveKey("k0sworker")) + Expect(result.Journal).ToNot(HaveKey("nonexistentservice")) + }) + + It("should handle empty journal output gracefully", func() { + // Mock journalctl to return empty output for user service, normal for defaults + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" && len(args) > 1 && args[1] == "emptyservice" { + return []byte{}, nil + } + if command == "journalctl" { + return []byte("journal logs content"), nil + } + return []byte{}, nil + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"emptyservice"}, + Files: []string{}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + // Should have collected from default services but not the empty user service + Expect(result.Journal).To(HaveLen(11)) + Expect(result.Journal).To(HaveKey("kairos-agent")) + Expect(result.Journal).To(HaveKey("kairos-installer")) + Expect(result.Journal).To(HaveKey("kairos-webui")) + Expect(result.Journal).To(HaveKey("cos-setup-boot")) + Expect(result.Journal).To(HaveKey("cos-setup-fs")) + Expect(result.Journal).To(HaveKey("cos-setup-network")) + Expect(result.Journal).To(HaveKey("cos-setup-reconcile")) + Expect(result.Journal).To(HaveKey("k3s")) + Expect(result.Journal).To(HaveKey("k3s-agent")) + Expect(result.Journal).To(HaveKey("k0scontroller")) + Expect(result.Journal).To(HaveKey("k0sworker")) + Expect(result.Journal).ToNot(HaveKey("emptyservice")) + }) + + It("should not create tarball files for services with no journal entries", func() { + // Mock journalctl to return no entries for one service and content for another + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" && len(args) > 1 && args[1] == "existingservice" { + return []byte("journal logs content"), nil + } + if command == "journalctl" && len(args) > 1 && args[1] == "nonexistentservice" { + return []byte("-- No entries --"), nil + } + if command == "journalctl" { + return []byte("default journal logs"), nil + } + return []byte{}, nil + } + + // Set logs config in the main config + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"existingservice", "nonexistentservice"}, + Files: []string{}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + // Should have collected from default services and the existing user service + Expect(result.Journal).To(HaveLen(12)) + Expect(result.Journal).To(HaveKey("kairos-agent")) + Expect(result.Journal).To(HaveKey("kairos-installer")) + Expect(result.Journal).To(HaveKey("kairos-webui")) + Expect(result.Journal).To(HaveKey("cos-setup-boot")) + Expect(result.Journal).To(HaveKey("cos-setup-fs")) + Expect(result.Journal).To(HaveKey("cos-setup-network")) + Expect(result.Journal).To(HaveKey("cos-setup-reconcile")) + Expect(result.Journal).To(HaveKey("k3s")) + Expect(result.Journal).To(HaveKey("k3s-agent")) + Expect(result.Journal).To(HaveKey("k0scontroller")) + Expect(result.Journal).To(HaveKey("k0sworker")) + Expect(result.Journal).To(HaveKey("existingservice")) + Expect(result.Journal).ToNot(HaveKey("nonexistentservice")) + + // Create tarball and verify contents + tarballPath := "/tmp/logs.tar.gz" + err = collector.CreateTarball(result, tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Read and verify tarball contents + tarballData, err := fs.ReadFile(tarballPath) + Expect(err).ToNot(HaveOccurred()) + + // Extract and verify tarball structure + gzr, err := gzip.NewReader(bytes.NewReader(tarballData)) + Expect(err).ToNot(HaveOccurred()) + defer gzr.Close() + + tr := tar.NewReader(gzr) + files := make(map[string]bool) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + Expect(err).ToNot(HaveOccurred()) + files[header.Name] = true + } + + // Verify that default services and existing user service files are created + Expect(files).To(HaveKey("journal/kairos-agent.log")) + Expect(files).To(HaveKey("journal/kairos-installer.log")) + Expect(files).To(HaveKey("journal/kairos-webui.log")) + Expect(files).To(HaveKey("journal/cos-setup-boot.log")) + Expect(files).To(HaveKey("journal/cos-setup-fs.log")) + Expect(files).To(HaveKey("journal/cos-setup-network.log")) + Expect(files).To(HaveKey("journal/cos-setup-reconcile.log")) + Expect(files).To(HaveKey("journal/k3s.log")) + Expect(files).To(HaveKey("journal/k3s-agent.log")) + Expect(files).To(HaveKey("journal/k0scontroller.log")) + Expect(files).To(HaveKey("journal/k0sworker.log")) + Expect(files).To(HaveKey("journal/existingservice.log")) + Expect(files).ToNot(HaveKey("journal/nonexistentservice.log")) + Expect(files).To(HaveLen(12)) + }) + + It("should use default log sources when no config provided", func() { + // Mock journalctl output + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" { + return []byte("default journal logs"), nil + } + return []byte{}, nil + } + + // Don't set logs config - should use defaults + collector.config.Logs = nil + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have collected from default services + Expect(runner.CmdsMatch([][]string{ + {"journalctl", "-u", "kairos-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-installer", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-webui", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-boot", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-fs", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-network", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-reconcile", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0scontroller", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0sworker", "--no-pager", "-o", "cat"}, + })).To(BeNil()) + }) + + It("should merge user logs config with defaults", func() { + // Mock journalctl output + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" && len(args) > 0 && args[0] == "-u" { + service := args[1] + return []byte("journal logs for " + service), nil + } + return []byte{}, nil + } + + // Set logs config in the main config with user-defined services + collector.config.Logs = &config.LogsConfig{ + Journal: []string{"myservice", "myotherservice"}, + Files: []string{"/var/log/mycustom.log"}, + } + + result, err := collector.Collect() + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify that both default and user-defined services were collected + // Default services: kairos-agent, kairos-installer, kairos-webui, cos-setup-boot, cos-setup-fs, cos-setup-network, cos-setup-reconcile, k3s, k3s-agent, k0scontroller, k0sworker + // User services: myservice, myotherservice + Expect(runner.CmdsMatch([][]string{ + {"journalctl", "-u", "kairos-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-installer", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "kairos-webui", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-boot", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-fs", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-network", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "cos-setup-reconcile", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k3s-agent", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0scontroller", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "k0sworker", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "myservice", "--no-pager", "-o", "cat"}, + {"journalctl", "-u", "myotherservice", "--no-pager", "-o", "cat"}, + })).To(BeNil()) + + // Verify that both default and user-defined files are in the result + Expect(result.Journal).To(HaveKey("kairos-agent")) + Expect(result.Journal).To(HaveKey("kairos-installer")) + Expect(result.Journal).To(HaveKey("kairos-webui")) + Expect(result.Journal).To(HaveKey("cos-setup-boot")) + Expect(result.Journal).To(HaveKey("cos-setup-fs")) + Expect(result.Journal).To(HaveKey("cos-setup-network")) + Expect(result.Journal).To(HaveKey("cos-setup-reconcile")) + Expect(result.Journal).To(HaveKey("k3s")) + Expect(result.Journal).To(HaveKey("k3s-agent")) + Expect(result.Journal).To(HaveKey("k0scontroller")) + Expect(result.Journal).To(HaveKey("k0sworker")) + Expect(result.Journal).To(HaveKey("myservice")) + Expect(result.Journal).To(HaveKey("myotherservice")) + Expect(result.Journal).To(HaveLen(13)) + }) + }) + + Describe("CLI Command", func() { + It("should execute logs command successfully", func() { + // Mock journalctl output + runner.SideEffect = func(command string, args ...string) ([]byte, error) { + if command == "journalctl" { + return []byte("journal logs content"), nil + } + return []byte{}, nil + } + + // Create test config file + configContent := ` +logs: + journal: + - myservice + files: + - /var/log/test.log +` + configPath := "/etc/kairos/config.yaml" + err := fsutils.MkdirAll(fs, filepath.Dir(configPath), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + + err = fs.WriteFile(configPath, []byte(configContent), constants.FilePerm) + Expect(err).ToNot(HaveOccurred()) + + // Create test log file + testFile := "/var/log/test.log" + err = fsutils.MkdirAll(fs, filepath.Dir(testFile), constants.DirPerm) + Expect(err).ToNot(HaveOccurred()) + + // Execute logs command + outputPath := "/tmp/logs.tar.gz" + err = ExecuteLogsCommand(fs, logger, runner, outputPath) + Expect(err).ToNot(HaveOccurred()) + + // Verify output file was created + exists, err := fsutils.Exists(fs, outputPath) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("should handle missing config gracefully", func() { + // Execute logs command without config + outputPath := "/tmp/logs.tar.gz" + err := ExecuteLogsCommand(fs, logger, runner, outputPath) + Expect(err).ToNot(HaveOccurred()) + + // Should still create output with default sources + exists, err := fsutils.Exists(fs, outputPath) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) +}) diff --git a/main.go b/main.go index 0b13df7..10f1237 100644 --- a/main.go +++ b/main.go @@ -1075,6 +1075,46 @@ The validate command expects a configuration file as its only argument. Local fi }, }, }, + { + Name: "logs", + Usage: "Collect logs from the system", + Description: `Collect logs from various sources on the Kairos system and create a compressed tarball. + +The command will collect logs from: +- Journal logs from specified services (default: kairos-agent, systemd, k3s) +- Log files from specified paths with globbing support + +Configuration can be provided in the Kairos config file under the 'logs' section: + +logs: + journal: + - myservice + - myotherservice + files: + - /var/log/mybinary/* + - /var/log/something.log + +The output will be a tarball with logs organized by type (journal/, files/).`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Usage: "Output path for the logs tarball", + Value: "./kairos-logs.tar.gz", + Aliases: []string{"o"}, + }, + }, + Action: func(c *cli.Context) error { + outputPath := c.String("output") + + // Get the filesystem and runner from the config + cfg, err := agentConfig.Scan(collector.Directories(constants.GetUserConfigDirs()...), collector.NoLogs) + if err != nil { + return fmt.Errorf("failed to scan config: %w", err) + } + + return agent.ExecuteLogsCommand(cfg.Fs, cfg.Logger, cfg.Runner, outputPath) + }, + }, } func main() { @@ -1230,11 +1270,10 @@ func moreThanOneEnabled(bools ...bool) bool { } func noneOfEnabled(bools ...bool) bool { - count := 0 for _, b := range bools { if b { - count++ + return false // Found at least one true, so not "none of enabled" } } - return count == 0 + return true // No true values found, so "none of enabled" } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a2ac99..8a4288d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -33,6 +33,12 @@ const ( FilePrefix = "file://" ) +// LogsConfig represents the configuration for log collection +type LogsConfig struct { + Journal []string `yaml:"journal,omitempty"` + Files []string `yaml:"files,omitempty"` +} + type Install struct { Auto bool `yaml:"auto,omitempty"` Reboot bool `yaml:"reboot,omitempty"` @@ -157,6 +163,7 @@ type Config struct { UkiMaxEntries int `yaml:"uki-max-entries,omitempty" mapstructure:"uki-max-entries"` BindPCRs []string `yaml:"bind-pcrs,omitempty" mapstructure:"bind-pcrs"` BindPublicPCRs []string `yaml:"bind-public-pcrs,omitempty" mapstructure:"bind-public-pcrs"` + Logs *LogsConfig `yaml:"logs,omitempty"` } // WriteInstallState writes the state.yaml file to the given state and recovery paths diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index edb8d4e..89fcd9a 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -17,11 +17,12 @@ package config_test import ( "fmt" - sdkTypes "github.com/kairos-io/kairos-sdk/types" "path/filepath" "reflect" "strings" + sdkTypes "github.com/kairos-io/kairos-sdk/types" + pkgConfig "github.com/kairos-io/kairos-agent/v2/pkg/config" "github.com/kairos-io/kairos-agent/v2/pkg/constants" v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" @@ -93,8 +94,8 @@ func structFieldsContainedInOtherStruct(left, right interface{}) { leftTagName := getTagName(leftTypes.Field(i).Tag.Get("yaml")) leftFieldName := leftTypes.Field(i).Name if leftTypes.Field(i).IsExported() { - It(fmt.Sprintf("Checks that the new schema contians the field %s", leftFieldName), func() { - if leftFieldName == "Source" || leftFieldName == "NoUsers" || leftFieldName == "BindPublicPCRs" || leftFieldName == "BindPCRs" { + It(fmt.Sprintf("Checks that the new schema contains the field %s", leftFieldName), func() { + if leftFieldName == "Source" || leftFieldName == "NoUsers" || leftFieldName == "BindPublicPCRs" || leftFieldName == "BindPCRs" || leftFieldName == "Logs" { Skip("Schema not updated yet") } Expect(