Add public local bundle writer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
187
pkg/bundle/writer.go
Normal 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
|
||||
}
|
||||
38
pkg/bundle/writer_integration_test.go
Normal file
38
pkg/bundle/writer_integration_test.go
Normal 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
232
pkg/bundle/writer_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user