Initial commit

This commit is contained in:
eleksir
2026-03-25 21:46:15 +03:00
commit 0bea45b770
33 changed files with 3000 additions and 0 deletions

View 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: */

View 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: */

View 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: */

View 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: */

View 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: */

View 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: */

View 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
View 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
View 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: */

View 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
View 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: */