Initial commit
This commit is contained in:
53
internal/backer/getFilesFromDirectories.go
Normal file
53
internal/backer/getFilesFromDirectories.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package backer implements simple backup server.
|
||||
package backer
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"backer/internal/log"
|
||||
)
|
||||
|
||||
// isExcluded checks if path matches any of the configured exclude patterns.
|
||||
func isExcluded(path string) bool {
|
||||
for _, re := range excludePatterns {
|
||||
if re.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFilesFromDirectories makes a file list of given directories.
|
||||
func GetFilesFromDirectories(directories []string) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
for _, dir := range directories {
|
||||
err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error { //nolint: revive
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: %v", path, err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExcluded(path) {
|
||||
log.Debugf("Excluding: %s", path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, path)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Failed to walk directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
269
internal/backer/getFilesFromDirectories_test.go
Normal file
269
internal/backer/getFilesFromDirectories_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// normalizePath converts backslashes to forward slashes for cross-platform consistency.
|
||||
func normalizePath(p string) string {
|
||||
return strings.ReplaceAll(p, "\\", "/")
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
||||
func TestGetFilesFromDirectories(t *testing.T) {
|
||||
var (
|
||||
input = Config{
|
||||
Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"},
|
||||
}
|
||||
expectedData = []string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/foo/empty_dir",
|
||||
"../../test_data/test1/foo/hello_breakfast.txt",
|
||||
"../../test_data/test1/foo/mydir",
|
||||
"../../test_data/test1/foo/mydir/myfile.txt",
|
||||
"../../test_data/test1/foo/some_text.txt",
|
||||
"../../test_data/test1/bar",
|
||||
"../../test_data/test1/bar/goodbye.txt",
|
||||
"../../test_data/test1/bar/goodbye.txt/text.txt",
|
||||
"../../test_data/test1/bar/hello.txt",
|
||||
}
|
||||
)
|
||||
|
||||
output, err := GetFilesFromDirectories(input.Directories)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
outputLen := len(output)
|
||||
expectedDataLen := len(expectedData)
|
||||
|
||||
if outputLen != expectedDataLen {
|
||||
t.Errorf("length of output of GetFilesFromDirectories() does not match length of expected data: %d vs %d", outputLen, expectedDataLen)
|
||||
}
|
||||
|
||||
for _, filepath := range output {
|
||||
normalized := normalizePath(filepath)
|
||||
if !slices.Contains(expectedData, normalized) {
|
||||
t.Errorf("expectedData data does not contain string from output: %s", filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesSingleDir tests with a single directory.
|
||||
func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{"../../test_data/test1/foo"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Error("Expected non-empty output")
|
||||
}
|
||||
|
||||
// Should contain the directory itself
|
||||
if !slices.Contains(output, "../../test_data/test1/foo") {
|
||||
t.Error("Output should contain the root directory")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesEmptyDir tests with an empty directory.
|
||||
func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
||||
// Create temp empty dir
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain at least the directory itself
|
||||
if len(output) != 1 {
|
||||
t.Errorf("Expected 1 entry (the directory itself), got %d", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesNonExistent tests with non-existent directory.
|
||||
func TestGetFilesFromDirectoriesNonExistent(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{"/nonexistent/path/that/does/not/exist"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error (logs warning instead), got %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("Expected empty output for non-existent directory, got %d files", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesMultipleDirs tests multiple directories.
|
||||
func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain entries from both directories
|
||||
hasFoo := slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == "../../test_data/test1/foo" || s == "../../test_data/test1/foo/hello_breakfast.txt"
|
||||
})
|
||||
hasBar := slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == "../../test_data/test1/bar" || s == "../../test_data/test1/bar/hello.txt"
|
||||
})
|
||||
|
||||
if !hasFoo {
|
||||
t.Error("Output should contain entries from foo directory")
|
||||
}
|
||||
if !hasBar {
|
||||
t.Error("Output should contain entries from bar directory")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesSymlink tests symlink handling.
|
||||
func TestGetFilesFromDirectoriesSymlink(t *testing.T) {
|
||||
// Create temp dir with symlink
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := tmpDir + "/target.txt"
|
||||
symlinkFile := tmpDir + "/link.txt"
|
||||
|
||||
// Create target file
|
||||
if err := os.WriteFile(targetFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := os.Symlink(targetFile, symlinkFile); err != nil {
|
||||
t.Skip("Symlinks not supported, skipping test")
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Should contain the symlink
|
||||
if !slices.ContainsFunc(output, func(s string) bool {
|
||||
return s == symlinkFile
|
||||
}) {
|
||||
t.Error("Output should contain the symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExcluded(t *testing.T) {
|
||||
// Save original excludePatterns and restore after test
|
||||
originalPatterns := excludePatterns
|
||||
defer func() { excludePatterns = originalPatterns }()
|
||||
|
||||
// Test empty patterns
|
||||
excludePatterns = nil
|
||||
if isExcluded("/some/path") {
|
||||
t.Error("Expected no exclusion with empty patterns")
|
||||
}
|
||||
|
||||
// Test matching pattern
|
||||
excludePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.tmp$`),
|
||||
regexp.MustCompile(`/node_modules/`),
|
||||
}
|
||||
if !isExcluded("/tmp/foo.tmp") {
|
||||
t.Error("Expected exclusion for .tmp file")
|
||||
}
|
||||
if !isExcluded("/project/node_modules/package") {
|
||||
t.Error("Expected exclusion for node_modules path")
|
||||
}
|
||||
if isExcluded("/tmp/foo.txt") {
|
||||
t.Error("Expected no exclusion for .txt file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilesFromDirectoriesWithExcludes(t *testing.T) {
|
||||
// Save original excludePatterns and restore after test
|
||||
originalPatterns := excludePatterns
|
||||
defer func() { excludePatterns = originalPatterns }()
|
||||
|
||||
// Create a temporary directory with some files
|
||||
tmpDir := t.TempDir()
|
||||
// Create files that should be included
|
||||
err := os.WriteFile(tmpDir+"/keep.txt", []byte("keep"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create files that should be excluded
|
||||
err = os.WriteFile(tmpDir+"/skip.tmp", []byte("skip"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a subdirectory with file
|
||||
subDir := tmpDir + "/sub"
|
||||
err = os.Mkdir(subDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.WriteFile(subDir+"/file.txt", []byte("file"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a .tmp file in subdirectory
|
||||
err = os.WriteFile(subDir+"/skip.tmp", []byte("skip"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set exclude pattern for .tmp files
|
||||
excludePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.tmp$`),
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Expect entries: tmpDir, keep.txt, subDir, sub/file.txt
|
||||
// Should NOT contain any .tmp files
|
||||
expected := []string{
|
||||
tmpDir,
|
||||
tmpDir + "/keep.txt",
|
||||
subDir,
|
||||
subDir + "/file.txt",
|
||||
}
|
||||
|
||||
// Normalize paths for cross-platform comparison
|
||||
normalizedOutput := make([]string, len(output))
|
||||
for i, p := range output {
|
||||
normalizedOutput[i] = normalizePath(p)
|
||||
}
|
||||
normalizedExpected := make([]string, len(expected))
|
||||
for i, p := range expected {
|
||||
normalizedExpected[i] = normalizePath(p)
|
||||
}
|
||||
|
||||
if len(normalizedOutput) != len(normalizedExpected) {
|
||||
t.Errorf("Expected %d entries, got %d: %v", len(normalizedExpected), len(normalizedOutput), normalizedOutput)
|
||||
}
|
||||
for _, exp := range normalizedExpected {
|
||||
found := slices.Contains(normalizedOutput, exp)
|
||||
if !found {
|
||||
t.Errorf("Expected entry %q not found", exp)
|
||||
}
|
||||
}
|
||||
// Ensure no .tmp files appear
|
||||
for _, path := range normalizedOutput {
|
||||
if strings.HasSuffix(path, ".tmp") {
|
||||
t.Errorf("Excluded file %q appeared in output", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
14
internal/backer/globals.go
Normal file
14
internal/backer/globals.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
// C contains the global configuration loaded from config file.
|
||||
C Config
|
||||
// excludePatterns contains compiled regex patterns from C.ExcludePatterns.
|
||||
excludePatterns []*regexp.Regexp
|
||||
)
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
148
internal/backer/loadConfig.go
Normal file
148
internal/backer/loadConfig.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"backer/internal/log"
|
||||
|
||||
hjson "github.com/hjson/hjson-go"
|
||||
)
|
||||
|
||||
// LoadConfig loads config file to global variable C.
|
||||
func LoadConfig(path string) error {
|
||||
var tmp map[string]any
|
||||
|
||||
buf, err := os.ReadFile(path)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
if err = hjson.Unmarshal(buf, &tmp); err != nil {
|
||||
return fmt.Errorf("Unable to parse config: %w", err)
|
||||
}
|
||||
|
||||
buf, err = json.Marshal(tmp)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to convert config: %w", err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(buf, &C); err != nil {
|
||||
return fmt.Errorf("Failed to decode config file: %w", err)
|
||||
}
|
||||
|
||||
// Address: default and no range validation needed.
|
||||
if C.Address == "" {
|
||||
C.Address = "0.0.0.0"
|
||||
|
||||
log.Warnf("Config option address is not set, fallback to %s", C.Address)
|
||||
}
|
||||
|
||||
// Port: set default, then validate range.
|
||||
if C.Port == 0 {
|
||||
C.Port = 8086
|
||||
|
||||
log.Warnf("Config option port is not set, fallback to %d", C.Port)
|
||||
}
|
||||
|
||||
if C.Port < 1 || C.Port > 65535 {
|
||||
return fmt.Errorf("Port must be between 1 and 65535, got %d", C.Port)
|
||||
}
|
||||
|
||||
// Location: default and no range validation needed.
|
||||
if C.Location == "" {
|
||||
C.Location = "/archive"
|
||||
|
||||
log.Warnf("Config option location is not set, fallback to %s", C.Location)
|
||||
}
|
||||
|
||||
// Log: default only.
|
||||
if C.Log == "" {
|
||||
log.Warn("Config option log is not set, fallback to stderr")
|
||||
}
|
||||
|
||||
// LogLevel: default and no range validation needed.
|
||||
if C.LogLevel == "" {
|
||||
C.LogLevel = "info"
|
||||
|
||||
log.Warnf("Config option loglevel is not set, fallback to %s", C.LogLevel)
|
||||
}
|
||||
|
||||
// Directories: required, then validate existence.
|
||||
if len(C.Directories) == 0 {
|
||||
return errors.New("You should configure at least one directory to backup!") //nolint: revive
|
||||
}
|
||||
|
||||
for _, dir := range C.Directories {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("Backup directory does not exist: %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// BackupTimeout: set default, then validate range.
|
||||
if C.BackupTimeout == 0 {
|
||||
C.BackupTimeout = 60
|
||||
|
||||
log.Warnf("Config option backup_timeout is not set, fallback to %d minutes", C.BackupTimeout)
|
||||
}
|
||||
|
||||
if C.BackupTimeout < 1 || C.BackupTimeout > 1440 {
|
||||
return fmt.Errorf("Backup_timeout must be between 1 and 1440 minutes, got %d", C.BackupTimeout)
|
||||
}
|
||||
|
||||
// CompressionLevel: set default, then validate range.
|
||||
if C.CompressionLevel == 0 {
|
||||
C.CompressionLevel = 9
|
||||
|
||||
log.Warnf("Config option compression_level is not set, fallback to %d", C.CompressionLevel)
|
||||
}
|
||||
|
||||
if C.CompressionLevel < 1 || C.CompressionLevel > 9 {
|
||||
return fmt.Errorf("Compression_level must be between 1 and 9, got %d", C.CompressionLevel)
|
||||
}
|
||||
|
||||
// HTTPS: validate cert/key when HTTPS enabled.
|
||||
if !C.NoHTTPS {
|
||||
switch {
|
||||
case C.Cert == "" && C.Key == "":
|
||||
return errors.New("Config option nohttps set to false, but neither cert nor key options set")
|
||||
case C.Cert == "":
|
||||
return errors.New("Config option nohttps set to false, but no cert option set")
|
||||
case C.Key == "":
|
||||
return errors.New("Config option nohttps set to false, but no key option set")
|
||||
}
|
||||
}
|
||||
|
||||
// User: required.
|
||||
if C.User == "" {
|
||||
return errors.New("Option user is not set")
|
||||
}
|
||||
|
||||
// Password: required.
|
||||
if C.Password == "" {
|
||||
return errors.New("Option password is not set")
|
||||
}
|
||||
|
||||
// Compile exclude patterns.
|
||||
excludePatterns = make([]*regexp.Regexp, 0, len(C.ExcludePatterns))
|
||||
|
||||
for _, pattern := range C.ExcludePatterns {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("Invalid exclude pattern %q: %v", pattern, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
excludePatterns = append(excludePatterns, re)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
223
internal/backer/loadConfig_test.go
Normal file
223
internal/backer/loadConfig_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
|
||||
var (
|
||||
testConfigPath = "./../../test_data/test_config.json"
|
||||
expectedData = Config{
|
||||
Address: "127.0.0.1",
|
||||
Port: 8086,
|
||||
Directories: []string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
},
|
||||
Location: "/archive",
|
||||
}
|
||||
)
|
||||
|
||||
err := LoadConfig(testConfigPath)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.Address != expectedData.Address {
|
||||
t.Errorf("C.Address should contain %s but contains %s", expectedData.Address, C.Address)
|
||||
}
|
||||
|
||||
if C.Port != expectedData.Port {
|
||||
t.Errorf("C.Port should contain %d but containc %d", expectedData.Port, C.Port)
|
||||
}
|
||||
|
||||
if C.Location != expectedData.Location {
|
||||
t.Errorf("C.StreamLocation should contain %s but contains %s", expectedData.Location, C.Location)
|
||||
}
|
||||
|
||||
outputDirectoriesLen := len(C.Directories)
|
||||
expectedDataDirectoriesLen := len(expectedData.Directories)
|
||||
|
||||
if outputDirectoriesLen != expectedDataDirectoriesLen {
|
||||
t.Errorf("length of C.Directories must be %d but is %d", expectedDataDirectoriesLen, outputDirectoriesLen)
|
||||
}
|
||||
|
||||
for _, str := range expectedData.Directories {
|
||||
if !slices.Contains(C.Directories, str) {
|
||||
t.Errorf("C.Directories data does not contain expected string: %s", str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingUser tests that config fails when user is missing.
|
||||
func TestLoadConfigMissingUser(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing user, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingPassword tests that config fails when password is missing.
|
||||
func TestLoadConfigMissingPassword(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMissingDirectories tests that config fails when directories is missing.
|
||||
func TestLoadConfigMissingDirectories(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test"
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing directories, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigInvalidPort tests that config fails with invalid port.
|
||||
func TestLoadConfigInvalidPort(t *testing.T) {
|
||||
config := `{
|
||||
"port": 99999,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid port, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigNonExistentDirectory tests that config fails with non-existent directory.
|
||||
func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["/nonexistent/path"]
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent directory, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigHTTPSWithoutCert tests that config fails when HTTPS enabled but no cert.
|
||||
func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"nohttps": false
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTPS without cert, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigNoHTTPS tests that config succeeds with nohttps=true and no cert.
|
||||
func TestLoadConfigNoHTTPS(t *testing.T) {
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"nohttps": true
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error with nohttps=true, got %v", err)
|
||||
}
|
||||
|
||||
if !C.NoHTTPS {
|
||||
t.Error("C.NoHTTPS should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigDefaultValues tests default values are set correctly.
|
||||
func TestLoadConfigDefaultValues(t *testing.T) {
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
"directories": ["../../test_data/test1/foo"],
|
||||
"cert": "../../test_data/example.crt",
|
||||
"key": "../../test_data/example.key"
|
||||
}`
|
||||
tmpFile := writeTempConfig(t, config)
|
||||
|
||||
err := LoadConfig(tmpFile)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.Address != "0.0.0.0" {
|
||||
t.Errorf("C.Address should default to 0.0.0.0, got %s", C.Address)
|
||||
}
|
||||
|
||||
if C.Port != 8086 {
|
||||
t.Errorf("C.Port should default to 8086, got %d", C.Port)
|
||||
}
|
||||
|
||||
if C.Location != "/archive" {
|
||||
t.Errorf("C.Location should default to /archive, got %s", C.Location)
|
||||
}
|
||||
|
||||
if C.LogLevel != "info" {
|
||||
t.Errorf("C.LogLevel should default to info, got %s", C.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTempConfig is a helper to write a temp config file for testing.
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "test_config_*.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
// Reset global config before loading
|
||||
C = Config{}
|
||||
excludePatterns = nil
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
192
internal/backer/newServer.go
Normal file
192
internal/backer/newServer.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backer/internal/log"
|
||||
)
|
||||
|
||||
// withServerHeader wraps a handler to add Server header to all responses.
|
||||
func withServerHeader(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", "backer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NewServer starts new server instance with given config file.
|
||||
func NewServer(configPath string) (*http.Server, error) {
|
||||
if err := LoadConfig(configPath); err != nil {
|
||||
return nil, fmt.Errorf("Failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if err := log.Init(C.LogLevel, C.Log); err != nil {
|
||||
log.Errorf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc(C.Location, func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
// No auth? Good bye.
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Both username AND password must match. Fuck De Morgan and his bullshit laws - we explicitly require
|
||||
// that the whole condition fails. That *is* the point.
|
||||
//nolint:staticcheck // QF1001: De Morgan's law is irrelevant here.
|
||||
if !(username == C.User && password == C.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := getClientIP(r)
|
||||
backupStart := time.Now()
|
||||
|
||||
log.Infof("Backup started: client=%s user=%s", clientIP, username)
|
||||
|
||||
ctx := r.Context()
|
||||
files, err := GetFilesFromDirectories(C.Directories)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get files: %v", err)
|
||||
|
||||
w.Header().Set("Server", "backer")
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tarGz := CreateTarGzStream(ctx, files)
|
||||
|
||||
defer func() {
|
||||
if err := tarGz.Close(); err != nil {
|
||||
log.Errorf("Failed to close tar.gz reader: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("backup-%s.tar.gz", timestamp)
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
bytesWritten, err := io.Copy(w, tarGz)
|
||||
duration := time.Since(backupStart)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("Backup failed: client=%s user=%s duration=%s error=%v", clientIP, username, duration, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d duration=%s", clientIP, username, len(files), bytesWritten, duration)
|
||||
})
|
||||
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeWithContext executes fn in a goroutine and returns its result.
|
||||
// If the context is canceled before fn completes, ctx.Err() is returned.
|
||||
// fn should perform I/O that will unblock when the underlying pipe is closed
|
||||
// on context cancellation (e.g., writes to a pipe writer). Panics in fn are
|
||||
// recovered and returned as errors.
|
||||
func writeWithContext(ctx context.Context, fn func() error) error {
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
default:
|
||||
done <- fn()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
||||
// context cancellation. If the context is canceled, the copy stops and returns
|
||||
// ctx.Err(). Panics are recovered and returned as errors.
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err error) { //nolint:nonamedreturns
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := src.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
return nil
|
||||
}
|
||||
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request.
|
||||
// It checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr.
|
||||
func getClientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.Split(xff, ",")[0]
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if colon := strings.LastIndex(ip, ":"); colon != -1 {
|
||||
return ip[:colon]
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
374
internal/backer/newServer_test.go
Normal file
374
internal/backer/newServer_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewServerSuccess tests successful server creation.
|
||||
func TestNewServerSuccess(t *testing.T) {
|
||||
C = Config{} // Reset global config.
|
||||
|
||||
server, err := NewServer("./../../test_data/test_config.json")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if server == nil {
|
||||
t.Fatal("Expected non-nil server")
|
||||
}
|
||||
|
||||
if server.Addr != "127.0.0.1:8086" {
|
||||
t.Errorf("Expected address '127.0.0.1:8086', got '%s'", server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewServerInvalidConfig tests server creation with invalid config.
|
||||
func TestNewServerInvalidConfig(t *testing.T) {
|
||||
C = Config{} // Reset global config.
|
||||
|
||||
_, err := NewServer("/nonexistent/config.json")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid config path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerNoAuth tests that requests without auth are rejected.
|
||||
func TestHandlerNoAuth(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create a handler that mimics the server's handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !(username == C.User && password == C.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
authHeader := w.Header().Get("WWW-Authenticate")
|
||||
if !strings.Contains(authHeader, "Basic") {
|
||||
t.Errorf("Expected WWW-Authenticate header with Basic, got '%s'", authHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerWrongCredentials tests that requests with wrong credentials are rejected.
|
||||
func TestHandlerWrongCredentials(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
req.SetBasicAuth("wronguser", "wrongpass")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !(username == C.User && password == C.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerCorrectCredentials tests that requests with correct credentials succeed.
|
||||
func TestHandlerCorrectCredentials(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "/archive", nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !(username == C.User && password == C.Password) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerArchiveStream tests that the handler streams a valid tar.gz archive.
|
||||
func TestHandlerArchiveStream(t *testing.T) {
|
||||
C = Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8086,
|
||||
Location: "/archive",
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
Directories: []string{"../../test_data/test1/foo"},
|
||||
}
|
||||
excludePatterns = nil
|
||||
|
||||
// Create a test server with the actual handler logic
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || !(username == C.User && password == C.Password) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
files, err := GetFilesFromDirectories(C.Directories)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tarGz := CreateTarGzStream(ctx, files)
|
||||
defer tarGz.Close()
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=archive.tar")
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
io.Copy(w, tarGz)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Make authenticated request
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
req.SetBasicAuth("testuser", "testpass")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify it's a valid tar.gz
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
foundFile := false
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(header.Name, "hello_breakfast.txt") {
|
||||
foundFile = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundFile {
|
||||
t.Error("Expected to find hello_breakfast.txt in archive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextSuccess tests successful write with context.
|
||||
func TestWriteWithContextSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
called := false
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("Expected function to be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextError tests write with context returning error.
|
||||
func TestWriteWithContextError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testErr := io.ErrUnexpectedEOF
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
return testErr
|
||||
})
|
||||
|
||||
if err != testErr {
|
||||
t.Errorf("Expected %v, got %v", testErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithContextCancellation tests write with cancelled context.
|
||||
func TestWriteWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err := writeWithContext(ctx, func() error {
|
||||
t.Error("Function should not be called with cancelled context")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyWithContextSuccess tests successful copy with context.
|
||||
func TestCopyWithContextSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := strings.NewReader("hello world")
|
||||
dst := &strings.Builder{}
|
||||
|
||||
err := copyWithContext(ctx, dst, src)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if dst.String() != "hello world" {
|
||||
t.Errorf("Expected 'hello world', got '%s'", dst.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyWithContextCancellation tests copy with cancelled context.
|
||||
func TestCopyWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create a reader with a lot of data that will take time to copy
|
||||
data := make([]byte, 1024*1024) // 1MB
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
src := strings.NewReader(string(data))
|
||||
dst := &strings.Builder{}
|
||||
|
||||
// Start copy in goroutine
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- copyWithContext(ctx, dst, src)
|
||||
}()
|
||||
|
||||
// Cancel context immediately
|
||||
cancel()
|
||||
|
||||
err := <-errCh
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWithContextPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := writeWithContext(ctx, func() error {
|
||||
panic("test panic")
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Expected error from panic, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("Expected panic error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyWithContextPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Use a reader that panics on Read
|
||||
panicReader := &panicReader{}
|
||||
dst := &strings.Builder{}
|
||||
err := copyWithContext(ctx, dst, panicReader)
|
||||
if err == nil {
|
||||
t.Error("Expected error from panic, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "panic") {
|
||||
t.Errorf("Expected panic error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type panicReader struct{}
|
||||
|
||||
func (pr *panicReader) Read(p []byte) (n int, err error) {
|
||||
panic("read panic")
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
23
internal/backer/types.go
Normal file
23
internal/backer/types.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package backer
|
||||
|
||||
type (
|
||||
// Config main config struct.
|
||||
Config struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
NoHTTPS bool `json:"nohttps"`
|
||||
Location string `json:"location"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Log string `json:"log"`
|
||||
LogLevel string `json:"loglevel"`
|
||||
Directories []string `json:"directories"`
|
||||
BackupTimeout int `json:"backup_timeout"` // Timeout in minutes for backup streaming.
|
||||
CompressionLevel int `json:"compression_level"` // Gzip compression level (1-9, default 9).
|
||||
ExcludePatterns []string `json:"exclude_patterns"` // Regex patterns to exclude from backup.
|
||||
}
|
||||
)
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
177
internal/backer/utils.go
Normal file
177
internal/backer/utils.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"backer/internal/log"
|
||||
)
|
||||
|
||||
// CreateTarGzStream takes a list of files and returns a reader that reads from a tar archive containing these files.
|
||||
func CreateTarGzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
var pipeErr error // Track if we close pipe with error.
|
||||
|
||||
// Helper to close pipe with error, only once.
|
||||
closePipeWithError := func(err error) {
|
||||
if pipeErr != nil {
|
||||
return // Already closed with error.
|
||||
}
|
||||
|
||||
pipeErr = err
|
||||
pw.CloseWithError(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Only do normal close if we haven't closed with error.
|
||||
if pipeErr == nil {
|
||||
pw.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
gw, err := gzip.NewWriterLevel(pw, C.CompressionLevel)
|
||||
if err != nil {
|
||||
closePipeWithError(fmt.Errorf("gzip writer error: %w", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Only close gzip writer if pipe is still open.
|
||||
if pipeErr == nil {
|
||||
if err := gw.Close(); err != nil {
|
||||
// Ignore pipe closed errors - reader may have closed early.
|
||||
if !isPipeClosedError(err) {
|
||||
log.Errorf("Failed to close gzip writer: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
defer func() {
|
||||
// Only close tar writer if pipe is still open.
|
||||
if pipeErr == nil {
|
||||
if err := tw.Close(); err != nil {
|
||||
// Ignore pipe closed errors - reader may have closed early.
|
||||
if !isPipeClosedError(err) {
|
||||
log.Errorf("Failed to close tar writer: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, fpath := range filepaths {
|
||||
var (
|
||||
err error
|
||||
linkTarget string
|
||||
)
|
||||
|
||||
// Check for cancellation early.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
closePipeWithError(ctx.Err())
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
log.Debugf("Adding file: %s", fpath)
|
||||
|
||||
st, err := os.Lstat(fpath)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(fpath)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: readlink failed: %s", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip sockets explicitly (they cannot be backed up).
|
||||
if st.Mode()&os.ModeSocket != 0 {
|
||||
log.Debugf("Skipping socket: %s", fpath)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(st, linkTarget)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: tar header failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
header.Format = tar.FormatGNU
|
||||
header.Name = strings.TrimPrefix(filepath.Clean(fpath), string(filepath.Separator))
|
||||
|
||||
mode := st.Mode()
|
||||
|
||||
// Write header with cancellation awareness.
|
||||
if err := writeWithContext(ctx, func() error {
|
||||
return tw.WriteHeader(header)
|
||||
}); err != nil {
|
||||
closePipeWithError(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Only stream file contents for regular files.
|
||||
if mode.IsRegular() {
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: open failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = copyWithContext(ctx, tw, f)
|
||||
|
||||
if e := f.Close(); e != nil {
|
||||
log.Warnf("Failed to close file %s: %v", fpath, e)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: copy failed: %v", fpath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
|
||||
return pr
|
||||
}
|
||||
|
||||
// isPipeClosedError checks if the error is due to a closed pipe.
|
||||
func isPipeClosedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for the specific pipe closed error message.
|
||||
return err.Error() == "io: read/write on closed pipe" ||
|
||||
err.Error() == "io: read on closed pipe"
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
359
internal/backer/utils_test.go
Normal file
359
internal/backer/utils_test.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file.
|
||||
func TestCreateTarGzStreamBasic(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{testFile})
|
||||
defer reader.Close()
|
||||
|
||||
// Decompress and read
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Read the file entry
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Name != testFile && filepath.Base(header.Name) != "test.txt" {
|
||||
t.Errorf("Expected file name 'test.txt', got '%s'", header.Name)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
t.Errorf("Expected regular file type, got %d", header.Typeflag)
|
||||
}
|
||||
|
||||
// Read content
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file content: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != string(content) {
|
||||
t.Errorf("Expected content '%s', got '%s'", content, data)
|
||||
}
|
||||
|
||||
// Should be no more entries
|
||||
if _, err := tarReader.Next(); err != io.EOF {
|
||||
t.Error("Expected EOF after single file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamMultipleFiles tests with multiple files.
|
||||
func TestCreateTarGzStreamMultipleFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create multiple files
|
||||
files := []string{
|
||||
filepath.Join(tmpDir, "file1.txt"),
|
||||
filepath.Join(tmpDir, "file2.txt"),
|
||||
filepath.Join(tmpDir, "file3.txt"),
|
||||
}
|
||||
|
||||
for i, f := range files {
|
||||
if err := os.WriteFile(f, []byte{byte('0' + i)}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, files)
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
count := 0
|
||||
for {
|
||||
_, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamDirectory tests directory handling.
|
||||
func TestCreateTarGzStreamDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
if err := os.Mkdir(subDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{subDir})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeDir {
|
||||
t.Errorf("Expected directory type, got %d", header.Typeflag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamSymlink tests symlink handling.
|
||||
func TestCreateTarGzStreamSymlink(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
targetFile := filepath.Join(tmpDir, "target.txt")
|
||||
symlinkFile := filepath.Join(tmpDir, "link.txt")
|
||||
|
||||
if err := os.WriteFile(targetFile, []byte("target content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(targetFile, symlinkFile); err != nil {
|
||||
t.Skip("Symlinks not supported, skipping test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{symlinkFile})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeSymlink {
|
||||
t.Errorf("Expected symlink type, got %c", header.Typeflag)
|
||||
}
|
||||
|
||||
if header.Linkname != targetFile {
|
||||
t.Errorf("Expected linkname '%s', got '%s'", targetFile, header.Linkname)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamCancellation tests context cancellation.
|
||||
func TestCreateTarGzStreamCancellation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a file
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel before reading
|
||||
cancel()
|
||||
|
||||
reader := CreateTarGzStream(ctx, []string{testFile})
|
||||
defer reader.Close()
|
||||
|
||||
// Read all - should complete without panic
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
// Error is expected due to cancellation
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
|
||||
// We should get some data or an error, but not panic
|
||||
_ = data
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamNonExistentFile tests handling of non-existent files.
|
||||
func TestCreateTarGzStreamNonExistentFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{"/nonexistent/path/file.txt"})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Should get EOF immediately since the file doesn't exist and is skipped
|
||||
_, err = tarReader.Next()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF for non-existent file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamEmptyList tests with empty file list.
|
||||
func TestCreateTarGzStreamEmptyList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Should get EOF immediately for empty list
|
||||
_, err = tarReader.Next()
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected EOF for empty list, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamNestedFiles tests with nested directory structure.
|
||||
func TestCreateTarGzStreamNestedFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create nested structure
|
||||
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
|
||||
if err := os.MkdirAll(nestedDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nestedFile := filepath.Join(nestedDir, "deep.txt")
|
||||
if err := os.WriteFile(nestedFile, []byte("deep content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// Pass the nested file directly, not the root
|
||||
reader := CreateTarGzStream(ctx, []string{nestedFile})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
foundDeepFile := false
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
if filepath.Base(header.Name) == "deep.txt" {
|
||||
foundDeepFile = true
|
||||
|
||||
// Verify content
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read content: %v", err)
|
||||
}
|
||||
if string(data) != "deep content" {
|
||||
t.Errorf("Expected 'deep content', got '%s'", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDeepFile {
|
||||
t.Error("Expected to find deep.txt in archive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTarGzStreamDeviceFile tests that device files are included in the archive.
|
||||
func TestCreateTarGzStreamDeviceFile(t *testing.T) {
|
||||
// Use /dev/null as a test device file (character device).
|
||||
devNull := "/dev/null"
|
||||
st, err := os.Lstat(devNull)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping device test: %v", err)
|
||||
}
|
||||
if st.Mode()&os.ModeCharDevice == 0 {
|
||||
t.Skipf("Skipping: %s is not a character device", devNull)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader := CreateTarGzStream(ctx, []string{devNull})
|
||||
defer reader.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Read the device entry
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeChar {
|
||||
t.Errorf("Expected character device type, got %c", header.Typeflag)
|
||||
}
|
||||
|
||||
// Check that the name matches the file path (without leading slash)
|
||||
expectedName := devNull
|
||||
if expectedName[0] == '/' {
|
||||
expectedName = expectedName[1:]
|
||||
}
|
||||
if header.Name != expectedName {
|
||||
t.Errorf("Expected header name %q, got %q", expectedName, header.Name)
|
||||
}
|
||||
|
||||
// Ensure there are no more entries
|
||||
if _, err := tarReader.Next(); err != io.EOF {
|
||||
t.Error("Expected EOF after device entry")
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
148
internal/log/main.go
Normal file
148
internal/log/main.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package log implements logging facility as golang slog module wrapper.
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Log contains *[os.File] pointing to log file descriptor.
|
||||
Log *os.File
|
||||
)
|
||||
|
||||
// Init sets up logger stuff.
|
||||
// level can be error, warn, info, debug; if something other is supplied, info level is selected.
|
||||
func Init(level, filename string) error {
|
||||
var (
|
||||
err error
|
||||
loglevel slog.Level
|
||||
)
|
||||
|
||||
if filename != "" {
|
||||
Log, err = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to open log file: %w", err)
|
||||
}
|
||||
} else {
|
||||
Log = os.Stderr
|
||||
}
|
||||
|
||||
// no panic, no trace.
|
||||
switch level {
|
||||
case "error":
|
||||
loglevel = slog.LevelError
|
||||
|
||||
case "warn":
|
||||
loglevel = slog.LevelWarn
|
||||
|
||||
case "info":
|
||||
loglevel = slog.LevelInfo
|
||||
|
||||
case "debug":
|
||||
loglevel = slog.LevelDebug
|
||||
|
||||
default:
|
||||
loglevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
// Use the ReplaceAttr function on the handler options
|
||||
// to be able to replace any single attribute in the log output.
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { //nolint: revive
|
||||
// Check that we are handling the time key.
|
||||
if a.Key != slog.TimeKey {
|
||||
return a
|
||||
}
|
||||
|
||||
t := a.Value.Time()
|
||||
|
||||
// Change the value from a time.Time to a String
|
||||
// where the string has the correct time format.
|
||||
a.Value = slog.StringValue(t.Format(time.DateTime))
|
||||
|
||||
return a
|
||||
},
|
||||
|
||||
Level: loglevel,
|
||||
}
|
||||
|
||||
slog.SetDefault(
|
||||
slog.New(
|
||||
slog.NewTextHandler(
|
||||
Log,
|
||||
opts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Error passes message directly to [slog.Error()].
|
||||
func Error(message string) {
|
||||
slog.Error(message)
|
||||
}
|
||||
|
||||
// Errorf logs message on error log level and allows to supply arguments in printf() style.
|
||||
func Errorf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Warn passes message directly to [slog.Warn()].
|
||||
func Warn(message string) {
|
||||
slog.Warn(message)
|
||||
}
|
||||
|
||||
// Warnf logs message on warn log level and allows to supply arguments in printf() style.
|
||||
func Warnf(format string, a ...any) {
|
||||
slog.Warn(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Info passes message directly to [slog.Info()].
|
||||
func Info(message string) {
|
||||
slog.Info(message)
|
||||
}
|
||||
|
||||
// Infof logs message on info log level and allows to supply arguments in printf() style.
|
||||
func Infof(format string, a ...any) {
|
||||
slog.Info(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Debug passes message directly to [slog.Debug()].
|
||||
func Debug(message string) {
|
||||
slog.Debug(message)
|
||||
}
|
||||
|
||||
// Debugf logs message on debug log level and allows to supply arguments in printf() style.
|
||||
func Debugf(format string, a ...any) {
|
||||
slog.Debug(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Fatal passes message directly to [slog.Error()] and quits with status code 1.
|
||||
func Fatal(message string) {
|
||||
slog.Error(message)
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fatalf logs message on error log level and allows to supply arguments in printf() style. After that it quits with
|
||||
// status code 1.
|
||||
func Fatalf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Close closes log file descriptor.
|
||||
func Close() {
|
||||
if err := Log.Close(); err != nil {
|
||||
msg := fmt.Sprintln("Failed to close log file:", err)
|
||||
slog.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
Reference in New Issue
Block a user