Add public local bundle writer

This commit is contained in:
2026-06-01 20:56:58 +00:00
parent bb68cb6602
commit 04557f610d
11 changed files with 522 additions and 16 deletions

View File

@@ -3,6 +3,7 @@
//
// A source bundle is a local directory containing a manifest.json file and the
// files listed by that manifest. This package owns the public manifest model,
// digest calculation, path validation, manifest parsing, manifest building, and
// local bundle validation used by Go producer applications.
// digest calculation, path validation, manifest parsing, manifest building,
// local bundle writing, and local bundle validation used by Go producer
// applications.
package bundle

View File

@@ -38,3 +38,36 @@ func ExampleBuildManifest() {
// report.txt
// 7
}
func ExampleWriteBundle() {
sourceRoot, err := os.MkdirTemp("", "distributor-source-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(sourceRoot)
outputRoot := filepath.Join(os.TempDir(), "distributor-bundle-example")
defer os.RemoveAll(outputRoot)
if err := os.WriteFile(filepath.Join(sourceRoot, "report.txt"), []byte("report\n"), 0o600); err != nil {
panic(err)
}
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: outputRoot,
ID: "reports.example",
Created: time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC),
Files: []bundle.BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.txt"), Path: "report.txt"},
},
Overwrite: true,
})
if err != nil {
panic(err)
}
fmt.Println(manifest.ID)
fmt.Println(manifest.Files[0].Path)
// Output:
// reports.example
// report.txt
}

View File

@@ -31,3 +31,16 @@ type BuildOptions struct {
type WriteManifestOptions struct {
Overwrite bool
}
type BundleFile struct {
SourcePath string
Path string
}
type WriteBundleOptions struct {
Root string
ID string
Created time.Time
Files []BundleFile
Overwrite bool
}

187
pkg/bundle/writer.go Normal file
View File

@@ -0,0 +1,187 @@
package bundle
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
func WriteBundle(opts WriteBundleOptions) (Manifest, error) {
root, err := cleanBundleRoot(opts.Root)
if err != nil {
return Manifest{}, err
}
if opts.ID == "" {
return Manifest{}, fmt.Errorf("id is required")
}
if len(opts.Files) == 0 {
return Manifest{}, fmt.Errorf("files is required")
}
parent := filepath.Dir(root)
if err := os.MkdirAll(parent, 0o755); err != nil {
return Manifest{}, fmt.Errorf("create bundle parent: %w", err)
}
if !opts.Overwrite {
if _, err := os.Lstat(root); err == nil {
return Manifest{}, fmt.Errorf("bundle root %q already exists", root)
} else if !errors.Is(err, os.ErrNotExist) {
return Manifest{}, fmt.Errorf("stat bundle root: %w", err)
}
}
tempRoot, err := os.MkdirTemp(parent, "."+filepath.Base(root)+"-*.tmp")
if err != nil {
return Manifest{}, fmt.Errorf("create bundle temp root: %w", err)
}
removeTemp := true
defer func() {
if removeTemp {
_ = os.RemoveAll(tempRoot)
}
}()
paths, err := copyBundleFiles(tempRoot, opts.Files)
if err != nil {
return Manifest{}, err
}
manifest, err := BuildManifest(BuildOptions{
Root: tempRoot,
ID: opts.ID,
Created: opts.Created,
Files: paths,
})
if err != nil {
return Manifest{}, err
}
if err := WriteManifest(tempRoot, manifest, WriteManifestOptions{}); err != nil {
return Manifest{}, err
}
if err := ValidateBundle(tempRoot, manifest); err != nil {
return Manifest{}, err
}
if err := promoteBundleRoot(tempRoot, root, opts.Overwrite); err != nil {
return Manifest{}, err
}
removeTemp = false
return manifest, nil
}
func cleanBundleRoot(root string) (string, error) {
if root == "" {
return "", fmt.Errorf("root is required")
}
return filepath.Clean(root), nil
}
func copyBundleFiles(root string, files []BundleFile) ([]string, error) {
paths := make([]string, 0, len(files))
seen := make(map[string]struct{}, len(files))
for index, file := range files {
if file.SourcePath == "" {
return nil, fmt.Errorf("files[%d].source_path is required", index)
}
if err := ValidateSourcePath(file.Path); err != nil {
return nil, fmt.Errorf("files[%d].path: %w", index, err)
}
if _, exists := seen[file.Path]; exists {
return nil, fmt.Errorf("files[%d].path duplicates %q", index, file.Path)
}
seen[file.Path] = struct{}{}
if err := copyBundleFile(root, file); err != nil {
return nil, fmt.Errorf("files[%d]: %w", index, err)
}
paths = append(paths, file.Path)
}
return paths, nil
}
func copyBundleFile(root string, file BundleFile) error {
info, err := os.Lstat(file.SourcePath)
if err != nil {
return fmt.Errorf("stat source %q: %w", file.SourcePath, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("source %q must be a regular file", file.SourcePath)
}
destination := filepath.Join(root, filepath.FromSlash(file.Path))
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
source, err := os.Open(file.SourcePath)
if err != nil {
return fmt.Errorf("open source %q: %w", file.SourcePath, err)
}
defer source.Close()
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o600
}
target, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return fmt.Errorf("create destination %q: %w", file.Path, err)
}
if _, err := io.Copy(target, source); err != nil {
_ = target.Close()
return fmt.Errorf("copy to destination %q: %w", file.Path, err)
}
if err := target.Close(); err != nil {
return fmt.Errorf("close destination %q: %w", file.Path, err)
}
return nil
}
func promoteBundleRoot(tempRoot, root string, overwrite bool) error {
if !overwrite {
if _, err := os.Lstat(root); err == nil {
return fmt.Errorf("bundle root %q already exists", root)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat bundle root: %w", err)
}
if err := os.Rename(tempRoot, root); err != nil {
return fmt.Errorf("promote bundle root: %w", err)
}
return nil
}
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
if err := os.Rename(tempRoot, root); err != nil {
return fmt.Errorf("promote bundle root: %w", err)
}
return nil
} else if err != nil {
return fmt.Errorf("stat bundle root: %w", err)
}
backupRoot, err := reserveSiblingPath(filepath.Dir(root), "."+filepath.Base(root)+"-backup-*.tmp")
if err != nil {
return err
}
if err := os.Rename(root, backupRoot); err != nil {
return fmt.Errorf("move existing bundle root: %w", err)
}
if err := os.Rename(tempRoot, root); err != nil {
restoreErr := os.Rename(backupRoot, root)
if restoreErr != nil {
return fmt.Errorf("promote bundle root: %w; restore existing bundle root: %v", err, restoreErr)
}
return fmt.Errorf("promote bundle root: %w", err)
}
_ = os.RemoveAll(backupRoot)
return nil
}
func reserveSiblingPath(parent, pattern string) (string, error) {
path, err := os.MkdirTemp(parent, pattern)
if err != nil {
return "", fmt.Errorf("reserve backup path: %w", err)
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("reserve backup path: %w", err)
}
return path, nil
}

View File

@@ -0,0 +1,38 @@
package bundle_test
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/distributor/internal/app"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
func TestWriteBundleOutputValidatesThroughDistributor(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
if err := os.WriteFile(filepath.Join(sourceRoot, "report.md"), []byte("# Report\n"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: outputRoot,
ID: "reports.distributor",
Files: []bundle.BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
}); err != nil {
t.Fatalf("WriteBundle() error = %v", err)
}
var stdout bytes.Buffer
if err := app.Validate(context.Background(), app.ValidateOptions{Path: outputRoot, Stdout: &stdout}); err != nil {
t.Fatalf("app.Validate() error = %v", err)
}
if got, want := stdout.String(), "Validated 1 bundle(s)\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}

232
pkg/bundle/writer_test.go Normal file
View File

@@ -0,0 +1,232 @@
package bundle
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestWriteBundleCreatesCompleteBundle(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
writeFile(t, sourceRoot, "report.md", "# Report\n")
writeFile(t, sourceRoot, "summary.txt", "Summary\n")
created := time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC)
manifest, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.writer",
Created: created,
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "summary.txt"), Path: "nested/summary.txt"},
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
})
if err != nil {
t.Fatalf("WriteBundle() error = %v", err)
}
if !manifest.Created.Equal(created) {
t.Fatalf("created = %s, want %s", manifest.Created, created)
}
if paths := manifestPaths(manifest); !reflect.DeepEqual(paths, []string{"nested/summary.txt", "report.md"}) {
t.Fatalf("paths = %v, want caller order", paths)
}
if got := readFile(t, outputRoot, "nested/summary.txt"); got != "Summary\n" {
t.Fatalf("nested summary = %q", got)
}
if got := readFile(t, outputRoot, "report.md"); got != "# Report\n" {
t.Fatalf("report = %q", got)
}
loaded, err := LoadManifest(outputRoot)
if err != nil {
t.Fatalf("LoadManifest() error = %v", err)
}
if !reflect.DeepEqual(loaded, manifest) {
t.Fatalf("loaded manifest = %#v, want %#v", loaded, manifest)
}
if err := ValidateBundle(outputRoot, loaded); err != nil {
t.Fatalf("ValidateBundle() error = %v", err)
}
}
func TestWriteBundleDefaultsCreated(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
writeFile(t, sourceRoot, "report.md", "# Report\n")
before := time.Now().UTC()
manifest, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.created",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
})
if err != nil {
t.Fatalf("WriteBundle() error = %v", err)
}
after := time.Now().UTC()
if manifest.Created.Before(before) || manifest.Created.After(after) {
t.Fatalf("created = %s, want between %s and %s", manifest.Created, before, after)
}
}
func TestWriteBundleRejectsDuplicatePaths(t *testing.T) {
sourceRoot := t.TempDir()
writeFile(t, sourceRoot, "report.md", "# Report\n")
_, err := WriteBundle(WriteBundleOptions{
Root: filepath.Join(t.TempDir(), "bundle"),
ID: "reports.duplicate",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
})
if err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("WriteBundle() error = %v, want duplicate path error", err)
}
}
func TestWriteBundleRejectsSymlinkSource(t *testing.T) {
sourceRoot := t.TempDir()
writeFile(t, sourceRoot, "target.md", "# Report\n")
linkPath := filepath.Join(sourceRoot, "link.md")
if err := os.Symlink("target.md", linkPath); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
_, err := WriteBundle(WriteBundleOptions{
Root: filepath.Join(t.TempDir(), "bundle"),
ID: "reports.symlink",
Files: []BundleFile{
{SourcePath: linkPath, Path: "report.md"},
},
})
if err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("WriteBundle() error = %v, want regular file error", err)
}
}
func TestWriteBundleDoesNotOverwriteByDefault(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
writeFile(t, sourceRoot, "report.md", "old\n")
if _, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.old",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
}); err != nil {
t.Fatalf("initial WriteBundle() error = %v", err)
}
writeFile(t, sourceRoot, "report.md", "new\n")
_, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.new",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
})
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("WriteBundle() error = %v, want exists error", err)
}
if got := readFile(t, outputRoot, "report.md"); got != "old\n" {
t.Fatalf("report = %q, want old content", got)
}
}
func TestWriteBundleOverwritesExistingRoot(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
writeFile(t, sourceRoot, "old.md", "old\n")
if _, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.old",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "old.md"), Path: "old.md"},
},
}); err != nil {
t.Fatalf("initial WriteBundle() error = %v", err)
}
writeFile(t, sourceRoot, "new.md", "new\n")
manifest, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.new",
Overwrite: true,
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "new.md"), Path: "new.md"},
},
})
if err != nil {
t.Fatalf("WriteBundle(overwrite) error = %v", err)
}
if manifest.ID != "reports.new" {
t.Fatalf("manifest id = %q, want reports.new", manifest.ID)
}
if _, err := os.Stat(filepath.Join(outputRoot, "old.md")); !os.IsNotExist(err) {
t.Fatalf("old file stat error = %v, want not exist", err)
}
if got := readFile(t, outputRoot, "new.md"); got != "new\n" {
t.Fatalf("new file = %q", got)
}
if err := ValidateBundle(outputRoot, manifest); err != nil {
t.Fatalf("ValidateBundle() error = %v", err)
}
}
func TestWriteBundleKeepsExistingRootWhenReplacementBuildFails(t *testing.T) {
sourceRoot := t.TempDir()
outputRoot := filepath.Join(t.TempDir(), "bundle")
writeFile(t, sourceRoot, "report.md", "old\n")
oldManifest, err := WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.old",
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
},
})
if err != nil {
t.Fatalf("initial WriteBundle() error = %v", err)
}
_, err = WriteBundle(WriteBundleOptions{
Root: outputRoot,
ID: "reports.new",
Overwrite: true,
Files: []BundleFile{
{SourcePath: filepath.Join(sourceRoot, "missing.md"), Path: "report.md"},
},
})
if err == nil {
t.Fatal("WriteBundle(overwrite) error = nil, want missing source error")
}
loaded, err := LoadManifest(outputRoot)
if err != nil {
t.Fatalf("LoadManifest() error = %v", err)
}
if !reflect.DeepEqual(loaded, oldManifest) {
t.Fatalf("loaded manifest = %#v, want old manifest %#v", loaded, oldManifest)
}
if got := readFile(t, outputRoot, "report.md"); got != "old\n" {
t.Fatalf("report = %q, want old content", got)
}
}
func readFile(t *testing.T, root, relative string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relative)))
if err != nil {
t.Fatalf("read %s: %v", relative, err)
}
return string(data)
}