From 005faee1680bb0289f3ba160eee380c426e3f150 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:28:19 +0400 Subject: [PATCH] feat(runtime): extend shared runtime utilities --- filesystem/doc.go | 10 +- filesystem/mutation.go | 12 + filesystem/publish.go | 511 ++++++++++++++++++++++++++++++++++++ filesystem/publish_test.go | 192 ++++++++++++++ lifecycle/doc.go | 3 +- lifecycle/lifecycle.go | 23 +- lifecycle/lifecycle_test.go | 50 ++++ log/doc.go | 9 +- log/example_test.go | 16 ++ log/log.go | 46 +++- log/log_test.go | 56 ++++ 11 files changed, 909 insertions(+), 19 deletions(-) create mode 100644 filesystem/publish.go create mode 100644 filesystem/publish_test.go diff --git a/filesystem/doc.go b/filesystem/doc.go index 5a52bb6..389747d 100644 --- a/filesystem/doc.go +++ b/filesystem/doc.go @@ -6,7 +6,9 @@ // Read sources use fs.FS directly, so callers can supply embed.FS, os.DirFS, // fstest.MapFS, or another implementation without an adapter. Open returns an // OS rooted at an existing operating system directory. OS implements fs.FS, -// fs.ReadLinkFS, Copier, Merger, Writer, Remover, and io.Closer. +// fs.ReadLinkFS, Copier, Merger, Writer, Remover, and io.Closer. It also +// publishes regular files and complete directory snapshots through its +// PublishFile and PublishDirectory methods. // // Infrastructure components that directly coordinate filesystem mechanics may // accept only the narrow interface they use, such as Copier or Writer, and @@ -35,4 +37,10 @@ // do not provide atomic replacement or rollback. New regular files follow // os.CopyFS permission semantics; replacing an existing regular file preserves // its destination permissions. +// +// PublishFile prepares a sibling temporary file before replacing its target. +// PublishDirectory prepares a complete sibling tree before replacing its +// target with a backup-and-rename sequence. The latter prevents a partial tree +// from being published on platforms with atomic sibling renames, but it can +// briefly leave the target absent and does not claim power-loss durability. package filesystem diff --git a/filesystem/mutation.go b/filesystem/mutation.go index 13d4859..00b77dd 100644 --- a/filesystem/mutation.go +++ b/filesystem/mutation.go @@ -42,6 +42,18 @@ func (o *OS) Remove(ctx context.Context, name string) error { return o.root.Remove(local) } +// RemoveAll removes name and its contents recursively within the root. Name +// must satisfy fs.ValidPath. A missing name is not an error. If ctx is already +// canceled, RemoveAll returns its error without changing the filesystem. +func (o *OS) RemoveAll(ctx context.Context, name string) error { + local, err := operationName(ctx, "removeall", name) + if err != nil { + return err + } + + return o.root.RemoveAll(local) +} + func operationName(ctx context.Context, op, name string) (string, error) { if err := ctx.Err(); err != nil { return "", err diff --git a/filesystem/publish.go b/filesystem/publish.go new file mode 100644 index 0000000..58c7369 --- /dev/null +++ b/filesystem/publish.go @@ -0,0 +1,511 @@ +package filesystem + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path" + "sort" + "strings" +) + +const ( + publishFileOperation = "publishfile" + publishDirectoryOperation = "publishdirectory" +) + +// File describes a regular file to publish. Mode may contain permission bits +// only; publication applies those permissions exactly rather than through the +// process umask. +type File struct { + Content []byte + Mode fs.FileMode +} + +// Snapshot describes the complete regular-file content of a directory. Keys +// are paths relative to the published directory and must satisfy fs.ValidPath. +// Directories are inferred from file paths. +type Snapshot map[string]File + +// PublishFile replaces name with file through a sibling temporary file. It +// creates missing parent directories and returns false without writing when +// the existing regular file already has the requested content and mode. A +// symbolic-link or non-regular destination returns an error matching +// fs.ErrInvalid. +func (o *OS) PublishFile(ctx context.Context, name string, file File) (bool, error) { + local, err := publicationName(ctx, publishFileOperation, name) + if err != nil { + return false, err + } + if err := validateFile(publishFileOperation, name, file); err != nil { + return false, err + } + + parent := path.Dir(name) + localParent, err := localName(publishFileOperation, parent) + if err != nil { + return false, err + } + if err := o.root.MkdirAll(localParent, 0o777); err != nil { + return false, err + } + + equal, err := o.fileEqual(ctx, local, name, file) + if err != nil { + return false, err + } + if equal { + return false, nil + } + + temporary, output, err := o.createTemporaryFile(parent, ".filesystem-publish-file-", file.Mode) + if err != nil { + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + keepTemporary := true + defer func() { + if keepTemporary { + _ = o.removeAll(temporary) + } + }() + + if _, err := output.Write(file.Content); err != nil { + _ = output.Close() + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + if err := output.Chmod(file.Mode.Perm()); err != nil { + _ = output.Close() + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + if err := output.Sync(); err != nil { + _ = output.Close() + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + if err := output.Close(); err != nil { + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + if err := ctx.Err(); err != nil { + return false, err + } + if err := o.root.Rename(temporary, local); err != nil { + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: err} + } + keepTemporary = false + + return true, nil +} + +// PublishDirectory replaces name with the exact contents of snapshot. Files +// are fully prepared in a sibling staging directory before the destination is +// renamed. On systems where sibling renames are atomic this prevents callers +// from observing a partially written tree, but the two-rename replacement can +// briefly leave name absent. A subsequent call recovers an interrupted backup. +// Concurrent publication to the same name is unsupported. +func (o *OS) PublishDirectory(ctx context.Context, name string, snapshot Snapshot) (bool, error) { + local, err := publicationName(ctx, publishDirectoryOperation, name) + if err != nil { + return false, err + } + files, err := validateSnapshot(snapshot) + if err != nil { + return false, err + } + + parent := path.Dir(name) + localParent, err := localName(publishDirectoryOperation, parent) + if err != nil { + return false, err + } + if err := o.root.MkdirAll(localParent, 0o777); err != nil { + return false, err + } + + marker := publicationMarker(name) + backupName := path.Join(parent, ".filesystem-publish-backup-"+marker) + backup, err := localName(publishDirectoryOperation, backupName) + if err != nil { + return false, err + } + stagePrefix := ".filesystem-publish-stage-" + marker + "-" + if err := o.recoverPublication(ctx, parent, local, backup, stagePrefix); err != nil { + return false, err + } + + equal, exists, err := o.snapshotEqual(ctx, name, snapshot) + if err != nil { + return false, err + } + if equal { + return false, nil + } + + stage, err := o.stageSnapshot(ctx, parent, stagePrefix, name, files, snapshot) + if err != nil { + return false, err + } + keepStage := true + defer func() { + if keepStage { + _ = o.removeAll(stage) + } + }() + + changed, err := o.replaceDirectory(local, backup, stage, name, exists) + if changed { + keepStage = false + } + return changed, err +} + +func (o *OS) stageSnapshot( + ctx context.Context, + parent, stagePrefix, name string, + files []string, + snapshot Snapshot, +) (string, error) { + stage, err := o.createTemporaryDirectory(parent, stagePrefix) + if err != nil { + return "", &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: err} + } + keepStage := true + defer func() { + if keepStage { + _ = o.removeAll(stage) + } + }() + + for _, fileName := range files { + if err := ctx.Err(); err != nil { + return "", err + } + file := snapshot[fileName] + stagedName := path.Join(stage, fileName) + if err := o.writeStagedFile(stagedName, file); err != nil { + return "", &fs.PathError{ + Op: publishDirectoryOperation, + Path: path.Join(name, fileName), + Err: err, + } + } + } + if err := ctx.Err(); err != nil { + return "", err + } + + keepStage = false + return stage, nil +} + +func (o *OS) replaceDirectory(local, backup, stage, name string, exists bool) (bool, error) { + if exists { + if err := o.root.Rename(local, backup); err != nil { + return false, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: err} + } + } + if err := o.root.Rename(stage, local); err != nil { + if exists { + return false, errors.Join( + &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: err}, + o.root.Rename(backup, local), + ) + } + return false, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: err} + } + if exists { + if err := o.removeAll(backup); err != nil { + return true, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: err} + } + } + return true, nil +} + +func publicationName(ctx context.Context, operation, name string) (string, error) { + local, err := operationName(ctx, operation, name) + if err != nil { + return "", err + } + if name == "." { + return "", &fs.PathError{Op: operation, Path: name, Err: fs.ErrInvalid} + } + return local, nil +} + +func validateFile(operation, name string, file File) error { + if file.Mode&^fs.ModePerm != 0 { + return &fs.PathError{Op: operation, Path: name, Err: fs.ErrInvalid} + } + return nil +} + +func validateSnapshot(snapshot Snapshot) ([]string, error) { + files := make([]string, 0, len(snapshot)) + for name, file := range snapshot { + if !fs.ValidPath(name) || name == "." { + return nil, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: fs.ErrInvalid} + } + if err := validateFile(publishDirectoryOperation, name, file); err != nil { + return nil, err + } + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if _, conflict := snapshot[parent]; conflict { + return nil, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: fs.ErrInvalid} + } + } + files = append(files, name) + } + sort.Strings(files) + return files, nil +} + +func (o *OS) fileEqual(ctx context.Context, local, name string, expected File) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + info, err := o.root.Lstat(local) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, &fs.PathError{Op: publishFileOperation, Path: name, Err: fs.ErrInvalid} + } + if info.Mode().Perm() != expected.Mode.Perm() { + return false, nil + } + content, err := o.root.ReadFile(local) + if err != nil { + return false, err + } + return bytes.Equal(content, expected.Content), nil +} + +func (o *OS) snapshotEqual(ctx context.Context, name string, expected Snapshot) (bool, bool, error) { + info, err := o.Lstat(name) + if errors.Is(err, fs.ErrNotExist) { + return false, false, nil + } + if err != nil { + return false, false, err + } + if !info.IsDir() { + return false, true, &fs.PathError{Op: publishDirectoryOperation, Path: name, Err: fs.ErrInvalid} + } + + seen := make(map[string]struct{}, len(expected)) + err = fs.WalkDir(o.fsys, name, func(entryName string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + return o.compareSnapshotEntry(ctx, name, entryName, entry, expected, seen) + }) + if errors.Is(err, errSnapshotDifferent) { + return false, true, nil + } + if err != nil { + return false, true, err + } + return len(seen) == len(expected), true, nil +} + +func (o *OS) compareSnapshotEntry( + ctx context.Context, + rootName, entryName string, + entry fs.DirEntry, + expected Snapshot, + seen map[string]struct{}, +) error { + if err := ctx.Err(); err != nil { + return err + } + if entryName == rootName { + return nil + } + + relative := strings.TrimPrefix(entryName, rootName+"/") + if entry.IsDir() { + if !snapshotHasDirectory(expected, relative) { + return errSnapshotDifferent + } + return nil + } + if entry.Type()&fs.ModeType != 0 { + return errSnapshotDifferent + } + file, ok := expected[relative] + if !ok { + return errSnapshotDifferent + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != file.Mode.Perm() { + return errSnapshotDifferent + } + content, err := fs.ReadFile(o.fsys, entryName) + if err != nil { + return err + } + if !bytes.Equal(content, file.Content) { + return errSnapshotDifferent + } + seen[relative] = struct{}{} + return nil +} + +var errSnapshotDifferent = errors.New("snapshot differs") + +func snapshotHasDirectory(snapshot Snapshot, directory string) bool { + prefix := directory + "/" + for name := range snapshot { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + +func (o *OS) createTemporaryFile(parent, prefix string, mode fs.FileMode) (string, *os.File, error) { + for range 100 { + suffix, err := randomSuffix() + if err != nil { + return "", nil, err + } + name := path.Join(parent, prefix+suffix) + local, err := localName(publishFileOperation, name) + if err != nil { + return "", nil, err + } + file, err := o.root.OpenFile(local, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode.Perm()) + if errors.Is(err, fs.ErrExist) { + continue + } + return local, file, err + } + return "", nil, fs.ErrExist +} + +func (o *OS) createTemporaryDirectory(parent, prefix string) (string, error) { + for range 100 { + suffix, err := randomSuffix() + if err != nil { + return "", err + } + name := path.Join(parent, prefix+suffix) + local, err := localName(publishDirectoryOperation, name) + if err != nil { + return "", err + } + err = o.root.Mkdir(local, 0o700) + if errors.Is(err, fs.ErrExist) { + continue + } + return local, err + } + return "", fs.ErrExist +} + +func randomSuffix() (string, error) { + var buffer [8]byte + if _, err := rand.Read(buffer[:]); err != nil { + return "", err + } + return hex.EncodeToString(buffer[:]), nil +} + +func publicationMarker(name string) string { + sum := sha256.Sum256([]byte(name)) + return hex.EncodeToString(sum[:8]) +} + +func (o *OS) writeStagedFile(local string, file File) error { + parent := path.Dir(local) + if err := o.root.MkdirAll(parent, 0o777); err != nil { + return err + } + output, err := o.root.OpenFile(local, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode.Perm()) + if err != nil { + return err + } + if _, err := output.Write(file.Content); err != nil { + _ = output.Close() + return err + } + if err := output.Chmod(file.Mode.Perm()); err != nil { + _ = output.Close() + return err + } + if err := output.Sync(); err != nil { + _ = output.Close() + return err + } + return output.Close() +} + +func (o *OS) recoverPublication( + ctx context.Context, + parent, target, backup, stagePrefix string, +) error { + if err := ctx.Err(); err != nil { + return err + } + targetExists, err := o.exists(target) + if err != nil { + return err + } + backupExists, err := o.exists(backup) + if err != nil { + return err + } + if backupExists && !targetExists { + if err := o.root.Rename(backup, target); err != nil { + return err + } + backupExists = false + } + if backupExists { + if err := o.removeAll(backup); err != nil { + return err + } + } + + entries, err := fs.ReadDir(o.fsys, parent) + if err != nil { + return err + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + if strings.HasPrefix(entry.Name(), stagePrefix) { + stage := path.Join(parent, entry.Name()) + localStage, err := localName(publishDirectoryOperation, stage) + if err != nil { + return err + } + if err := o.removeAll(localStage); err != nil { + return err + } + } + } + return nil +} + +func (o *OS) exists(local string) (bool, error) { + _, err := o.root.Lstat(local) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return err == nil, err +} + +func (o *OS) removeAll(local string) error { + return o.root.RemoveAll(local) +} diff --git a/filesystem/publish_test.go b/filesystem/publish_test.go new file mode 100644 index 0000000..b742209 --- /dev/null +++ b/filesystem/publish_test.go @@ -0,0 +1,192 @@ +package filesystem_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/devctllabs/go-libs/filesystem" + "github.com/stretchr/testify/require" +) + +func TestOSPublishFileCreatesParentsAndSkipsUnchangedContent(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + changed, err := disk.PublishFile(context.Background(), "nested/file.txt", filesystem.File{ + Content: []byte("first"), + Mode: 0o640, + }) + require.NoError(t, err) + require.True(t, changed) + require.FileExists(t, filepath.Join(root, "nested", "file.txt")) + requireFile(t, root, "nested/file.txt", "first", 0o640) + + changed, err = disk.PublishFile(context.Background(), "nested/file.txt", filesystem.File{ + Content: []byte("first"), + Mode: 0o640, + }) + require.NoError(t, err) + require.False(t, changed) + + changed, err = disk.PublishFile(context.Background(), "nested/file.txt", filesystem.File{ + Content: []byte("second"), + Mode: 0o600, + }) + require.NoError(t, err) + require.True(t, changed) + requireFile(t, root, "nested/file.txt", "second", 0o600) +} + +func TestOSPublishFileRejectsSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("unchanged"), 0o600)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "file.txt"))) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + _, err = disk.PublishFile(context.Background(), "file.txt", filesystem.File{ + Content: []byte("changed"), + Mode: 0o600, + }) + require.ErrorIs(t, err, fs.ErrInvalid) + + content, err := os.ReadFile(outside) + require.NoError(t, err) + require.Equal(t, "unchanged", string(content)) +} + +func TestOSPublishDirectoryReplacesTheWholeSnapshot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "generated", "obsolete"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "generated", "obsolete", "old.txt"), []byte("old"), 0o600)) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + snapshot := filesystem.Snapshot{ + "README.md": {Content: []byte("generated\n"), Mode: 0o644}, + "cmd/tool/main.go": {Content: []byte("package main\n"), Mode: 0o600}, + } + changed, err := disk.PublishDirectory(context.Background(), "generated", snapshot) + require.NoError(t, err) + require.True(t, changed) + require.NoDirExists(t, filepath.Join(root, "generated", "obsolete")) + requireFile(t, root, "generated/README.md", "generated\n", 0o644) + requireFile(t, root, "generated/cmd/tool/main.go", "package main\n", 0o600) + + changed, err = disk.PublishDirectory(context.Background(), "generated", snapshot) + require.NoError(t, err) + require.False(t, changed) + + changed, err = disk.PublishDirectory(context.Background(), "generated", filesystem.Snapshot{}) + require.NoError(t, err) + require.True(t, changed) + entries, err := os.ReadDir(filepath.Join(root, "generated")) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestOSPublishDirectoryRejectsInvalidSnapshotBeforeMutation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "generated"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "generated", "existing.txt"), []byte("keep"), 0o600)) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + _, err = disk.PublishDirectory(context.Background(), "generated", filesystem.Snapshot{ + "../escape.txt": {Content: []byte("bad"), Mode: 0o600}, + }) + require.ErrorIs(t, err, fs.ErrInvalid) + requireFile(t, root, "generated/existing.txt", "keep", 0o600) +} + +func TestOSPublishDirectoryRejectsFileAndParentConflict(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + _, err = disk.PublishDirectory(context.Background(), "generated", filesystem.Snapshot{ + "a": {Content: []byte("file"), Mode: 0o600}, + "a-middle": {Content: []byte("file"), Mode: 0o600}, + "a/b.txt": {Content: []byte("nested"), Mode: 0o600}, + }) + require.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestOSPublicationHonorsCanceledContextBeforeMutation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = disk.PublishFile(ctx, "file.txt", filesystem.File{Content: []byte("content"), Mode: 0o600}) + require.ErrorIs(t, err, context.Canceled) + _, err = disk.PublishDirectory(ctx, "generated", filesystem.Snapshot{ + "file.txt": {Content: []byte("content"), Mode: 0o600}, + }) + require.ErrorIs(t, err, context.Canceled) + require.NoFileExists(t, filepath.Join(root, "file.txt")) + require.NoDirExists(t, filepath.Join(root, "generated")) +} + +func TestOSRemoveAllRemovesTreeAndHonorsContext(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "nested", "child"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "nested", "child", "file.txt"), []byte("content"), 0o600)) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, disk.RemoveAll(canceled, "nested"), context.Canceled) + require.DirExists(t, filepath.Join(root, "nested")) + + require.NoError(t, disk.RemoveAll(context.Background(), "nested")) + require.NoDirExists(t, filepath.Join(root, "nested")) +} + +func requireFile(t *testing.T, root, name, content string, mode fs.FileMode) { + t.Helper() + + fullName := filepath.Join(root, filepath.FromSlash(name)) + data, err := os.ReadFile(fullName) + require.NoError(t, err) + require.Equal(t, content, string(data)) + info, err := os.Stat(fullName) + require.NoError(t, err) + require.Equal(t, mode, info.Mode().Perm()) +} diff --git a/lifecycle/doc.go b/lifecycle/doc.go index 3ceb16b..0fd07d8 100644 --- a/lifecycle/doc.go +++ b/lifecycle/doc.go @@ -3,5 +3,6 @@ // The caller owns signal handling, dependency construction, and configuration. Tasks must either // honor their context or be stopped by Config.Shutdown. Run calls shutdown before waiting for all // tasks, so servers whose Serve method returns only after Shutdown are supported without extra -// goroutines in application code. +// goroutines in application code. One-shot commands can use Shutdown directly to run the same +// fresh, bounded cleanup convention without starting lifecycle tasks. package lifecycle diff --git a/lifecycle/lifecycle.go b/lifecycle/lifecycle.go index 778d1f5..4215546 100644 --- a/lifecycle/lifecycle.go +++ b/lifecycle/lifecycle.go @@ -37,7 +37,7 @@ func Run(ctx context.Context, cfg Config) error { } if ctx.Err() != nil { - return errors.Join(parentError(ctx), shutdown(ctx, cfg)) + return errors.Join(parentError(ctx), Shutdown(ctx, cfg.ShutdownTimeout, cfg.Shutdown)) } group, runCtx := errgroup.WithContext(ctx) @@ -57,7 +57,7 @@ func Run(ctx context.Context, cfg Config) error { } <-runCtx.Done() - shutdownErr := shutdown(ctx, cfg) + shutdownErr := Shutdown(ctx, cfg.ShutdownTimeout, cfg.Shutdown) runErr := group.Wait() return errors.Join(runErr, parentError(ctx), shutdownErr) } @@ -93,10 +93,23 @@ func validate(ctx context.Context, cfg Config) error { return nil } -func shutdown(ctx context.Context, cfg Config) error { - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout) +// Shutdown calls shutdown with a fresh context that preserves values from ctx, +// ignores its cancellation, and has the supplied positive timeout. It returns +// the callback error unchanged. +func Shutdown(ctx context.Context, timeout time.Duration, shutdown func(context.Context) error) error { + if ctx == nil { + return errors.New("lifecycle: context must not be nil") + } + if timeout <= 0 { + return errors.New("lifecycle: shutdown timeout must be positive") + } + if shutdown == nil { + return errors.New("lifecycle: shutdown must not be nil") + } + + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) defer cancel() - return cfg.Shutdown(shutdownCtx) + return shutdown(shutdownCtx) } func parentError(ctx context.Context) error { diff --git a/lifecycle/lifecycle_test.go b/lifecycle/lifecycle_test.go index 1f946b2..dd358d2 100644 --- a/lifecycle/lifecycle_test.go +++ b/lifecycle/lifecycle_test.go @@ -60,6 +60,56 @@ func TestRunValidatesConfigBeforeStartingAnything(t *testing.T) { } } +func TestShutdownValidatesInputBeforeCallingCallback(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + callback := func(context.Context) error { + calls.Add(1) + return nil + } + + require.Error(t, lifecycle.Shutdown(nil, time.Second, callback)) //nolint:staticcheck // Nil verifies input validation. + require.Error(t, lifecycle.Shutdown(context.Background(), 0, callback)) + require.Error(t, lifecycle.Shutdown(context.Background(), -time.Second, callback)) + require.Error(t, lifecycle.Shutdown(context.Background(), time.Second, nil)) + require.Zero(t, calls.Load()) +} + +func TestShutdownUsesFreshBoundedContextAndPreservesValues(t *testing.T) { + t.Parallel() + + type contextKey string + const key contextKey = "request" + parent, cancel := context.WithCancel(context.WithValue(context.Background(), key, "value")) + cancel() + + err := lifecycle.Shutdown(parent, time.Second, func(ctx context.Context) error { + require.NoError(t, ctx.Err()) + require.Equal(t, "value", ctx.Value(key)) + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.WithinDuration(t, time.Now().Add(time.Second), deadline, 100*time.Millisecond) + return nil + }) + require.NoError(t, err) +} + +func TestShutdownReturnsCallbackErrorAndEnforcesTimeout(t *testing.T) { + t.Parallel() + + callbackErr := errors.New("close resource") + require.ErrorIs(t, lifecycle.Shutdown(context.Background(), time.Second, func(context.Context) error { + return callbackErr + }), callbackErr) + + err := lifecycle.Shutdown(context.Background(), time.Millisecond, func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + func TestRunTreatsTaskReturningNilAsUnexpectedStop(t *testing.T) { t.Parallel() diff --git a/log/doc.go b/log/doc.go index 66906d3..71fb380 100644 --- a/log/doc.go +++ b/log/doc.go @@ -1,9 +1,10 @@ -// Package log constructs JSON-encoded zap loggers for application diagnostics. +// Package log constructs JSON or console zap loggers for application diagnostics. // -// New always uses zap's production JSON encoder with ISO 8601 timestamps. Log +// New uses zap's production encoder configuration with ISO 8601 timestamps. +// JSON is the default; WithEncoding selects console layout when needed. Log // entries go to stderr by default, and enabling stacktraces adds them only to -// Error-level and higher entries. The package does not provide a plain-text -// mode or install a global zap logger. +// Error-level and higher entries. The package does not install a global zap +// logger. // // # Integration // diff --git a/log/example_test.go b/log/example_test.go index e5d81c6..f13a417 100644 --- a/log/example_test.go +++ b/log/example_test.go @@ -33,3 +33,19 @@ func ExampleNew() { outputLogger.Printf("level=%s message=%s service=%s", entry.Level, entry.Message, entry.Service) // Output: level=info message=started service=api } + +func ExampleWithEncoding() { + var output bytes.Buffer + logger := applog.New( + zapcore.InfoLevel, + false, + applog.WithEncoding(applog.EncodingConsole), + applog.WithOutput(&output), + ) + logger.Info("started") + + fields := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte{'\t'}) + outputLogger := stdlog.New(os.Stdout, "", 0) + outputLogger.Printf("level=%s message=%s", fields[1], fields[2]) + // Output: level=info message=started +} diff --git a/log/log.go b/log/log.go index 12cc940..205061b 100644 --- a/log/log.go +++ b/log/log.go @@ -9,9 +9,20 @@ import ( ) type config struct { - output io.Writer + output io.Writer + encoding Encoding } +// Encoding selects the zap encoder used for log entries. +type Encoding string + +const ( + // EncodingJSON writes one JSON object per log entry. + EncodingJSON Encoding = "json" + // EncodingConsole writes production fields in zap's console layout. + EncodingConsole Encoding = "console" +) + // Option configures a logger created by New. Callers use the provided With... // functions rather than implementing Option directly. type Option func(*config) @@ -27,13 +38,25 @@ func WithOutput(output io.Writer) Option { } } -// New constructs a JSON logger that emits entries at level and above. It uses -// ISO 8601 timestamps and writes to stderr unless WithOutput overrides the -// destination. When stacktrace is true, Error-level and higher entries include -// a stacktrace. New ignores nil options and does not replace zap's global -// loggers. +// WithEncoding selects the log entry encoding. Unsupported values use JSON. +func WithEncoding(encoding Encoding) Option { + return func(cfg *config) { + switch encoding { + case EncodingConsole: + cfg.encoding = EncodingConsole + default: + cfg.encoding = EncodingJSON + } + } +} + +// New constructs a logger that emits entries at level and above. It defaults +// to JSON, uses ISO 8601 timestamps, and writes to stderr unless WithOutput +// overrides the destination. When stacktrace is true, Error-level and higher +// entries include a stacktrace. New ignores nil options and does not replace +// zap's global loggers. func New(level zapcore.Level, stacktrace bool, options ...Option) *zap.Logger { - cfg := config{output: os.Stderr} + cfg := config{output: os.Stderr, encoding: EncodingJSON} for _, option := range options { if option != nil { option(&cfg) @@ -43,8 +66,15 @@ func New(level zapcore.Level, stacktrace bool, options ...Option) *zap.Logger { encoderCfg := zap.NewProductionEncoderConfig() encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + var encoder zapcore.Encoder + if cfg.encoding == EncodingConsole { + encoder = zapcore.NewConsoleEncoder(encoderCfg) + } else { + encoder = zapcore.NewJSONEncoder(encoderCfg) + } + core := zapcore.NewCore( - zapcore.NewJSONEncoder(encoderCfg), + encoder, zapcore.Lock(zapcore.AddSync(cfg.output)), level, ) diff --git a/log/log_test.go b/log/log_test.go index 9a93696..e082e93 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -34,6 +34,62 @@ func TestNewWritesJSONToConfiguredOutput(t *testing.T) { require.NoError(t, err) } +func TestNewWritesConfiguredEncoding(t *testing.T) { + t.Parallel() + tests := []struct { + name string + encoding log.Encoding + assert func(*testing.T, []byte) + }{ + { + name: "json", + encoding: log.EncodingJSON, + assert: func(t *testing.T, output []byte) { + t.Helper() + entries := decodeEntries(t, output) + require.Len(t, entries, 1) + require.Equal(t, "started", entries[0]["msg"]) + }, + }, + { + name: "console", + encoding: log.EncodingConsole, + assert: func(t *testing.T, output []byte) { + t.Helper() + require.Contains(t, string(output), "\tinfo\tstarted\t") + require.Contains(t, string(output), `"service": "api"`) + }, + }, + { + name: "unsupported falls back to json", + encoding: log.Encoding("yaml"), + assert: func(t *testing.T, output []byte) { + t.Helper() + entries := decodeEntries(t, output) + require.Len(t, entries, 1) + require.Equal(t, "started", entries[0]["msg"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + + logger := log.New( + zapcore.InfoLevel, + false, + log.WithOutput(&output), + log.WithEncoding(tt.encoding), + ) + logger.Info("started", zap.String("service", "api")) + + tt.assert(t, output.Bytes()) + }) + } +} + func TestNewFiltersEntriesBelowLevel(t *testing.T) { t.Parallel() var output bytes.Buffer