Add manifest creation command
This commit is contained in:
28
docs/cli.md
28
docs/cli.md
@@ -16,12 +16,14 @@ distributor version [--format text|json]
|
|||||||
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||||
distributor validate [--format text|json] <path>
|
distributor validate [--format text|json] <path>
|
||||||
distributor inspect [--format text|json] <path>
|
distributor inspect [--format text|json] <path>
|
||||||
|
distributor manifest create <bundle-path> --id <bundle-id> [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
||||||
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
|
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
|
||||||
- `validate`: validates a local source bundle directory or a local tree containing source bundles.
|
- `validate`: validates a local source bundle directory or a local tree containing source bundles.
|
||||||
- `inspect`: validates local source bundles and prints normalized bundle metadata.
|
- `inspect`: validates local source bundles and prints normalized bundle metadata.
|
||||||
|
- `manifest create`: creates `manifest.json` for a local source bundle directory.
|
||||||
|
|
||||||
`validate` and `inspect` accept local paths only. `run` executes `local`, `ssh`, and `s3` backends.
|
`validate` and `inspect` accept local paths only. `run` executes `local`, `ssh`, and `s3` backends.
|
||||||
|
|
||||||
@@ -45,6 +47,13 @@ Output-producing subcommands:
|
|||||||
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
||||||
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
||||||
|
|
||||||
|
`manifest create` flags:
|
||||||
|
|
||||||
|
- `--id <bundle-id>`: source bundle id. Required.
|
||||||
|
- `--file <path>`: bundle-relative file to include. Repeatable. If omitted, files are scanned recursively.
|
||||||
|
- `--created <time>`: RFC3339 source created timestamp. If omitted, the current UTC time is used.
|
||||||
|
- `--overwrite`: replace an existing `manifest.json`.
|
||||||
|
|
||||||
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path; omitting the path returns a required-path error.
|
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path; omitting the path returns a required-path error.
|
||||||
|
|
||||||
## Common workflows
|
## Common workflows
|
||||||
@@ -61,6 +70,22 @@ Inspect a source bundle:
|
|||||||
go run ./cmd/distributor inspect examples/source-bundle
|
go run ./cmd/distributor inspect examples/source-bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Create a manifest for a local producer bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor manifest create <bundle-path> --id <bundle-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a manifest with explicit file order:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor manifest create <bundle-path> \
|
||||||
|
--id <bundle-id> \
|
||||||
|
--created 2026-06-01T11:00:00Z \
|
||||||
|
--file report.md \
|
||||||
|
--file summary.txt
|
||||||
|
```
|
||||||
|
|
||||||
Preview local publication without writing:
|
Preview local publication without writing:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -129,10 +154,11 @@ Command-specific JSON results:
|
|||||||
- `version`: application name and version.
|
- `version`: application name and version.
|
||||||
- `validate`: bundle count and discovered bundle identifiers.
|
- `validate`: bundle count and discovered bundle identifiers.
|
||||||
- `inspect`: bundle path, id, created timestamp, digest, file count, total size, and manifest file records.
|
- `inspect`: bundle path, id, created timestamp, digest, file count, total size, and manifest file records.
|
||||||
|
- `manifest create`: manifest path, bundle root, id, created timestamp, digest, file count, and file records.
|
||||||
- `run`: dry-run status, pipeline summaries, destination action records, output records, final counters, warnings, and partial failure records.
|
- `run`: dry-run status, pipeline summaries, destination action records, output records, final counters, warnings, and partial failure records.
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
Use `validate` before publication when a producer has written a new bundle. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
Use `manifest create` when a local producer has written bundle files but not `manifest.json`. Use `validate` before publication when a producer has written a new bundle. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
||||||
|
|
||||||
For symptom-oriented recovery steps, see [troubleshooting](troubleshooting.md). For destination state and retry behavior, see [operations](operations.md). For config fields and defaults, see [configuration](config.md).
|
For symptom-oriented recovery steps, see [troubleshooting](troubleshooting.md). For destination state and retry behavior, see [operations](operations.md). For config fields and defaults, see [configuration](config.md).
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ if err != nil {
|
|||||||
|
|
||||||
Use `BuildManifest` and `WriteManifest` when a producer already wrote all bundle files into the final root. `BuildManifest` can preserve an explicit file order, or `Scan: true` can recursively include regular files under `Root` in deterministic slash-path order. Scan mode includes dotfiles, excludes files named `manifest.json` or `.distributor.json`, and rejects symlinks.
|
Use `BuildManifest` and `WriteManifest` when a producer already wrote all bundle files into the final root. `BuildManifest` can preserve an explicit file order, or `Scan: true` can recursively include regular files under `Root` in deterministic slash-path order. Scan mode includes dotfiles, excludes files named `manifest.json` or `.distributor.json`, and rejects symlinks.
|
||||||
|
|
||||||
|
Shell producers can create the same manifest through the CLI after writing bundle files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor manifest create <bundle-path> --id reports.example.2026-05-30
|
||||||
|
go run ./cmd/distributor validate <bundle-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use repeated `--file` flags to preserve a specific file order. If no `--file` flags are provided, the command scans the bundle directory recursively using the same filtering rules as `pkg/bundle.BuildManifest`.
|
||||||
|
|
||||||
## Dry Runs
|
## Dry Runs
|
||||||
|
|
||||||
`--dry-run` loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH `known_hosts` entries.
|
`--dry-run` loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH `known_hosts` entries.
|
||||||
|
|||||||
128
internal/app/manifest.go
Normal file
128
internal/app/manifest.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManifestCreateOptions struct {
|
||||||
|
Root string
|
||||||
|
ID string
|
||||||
|
Created string
|
||||||
|
Files []string
|
||||||
|
Overwrite bool
|
||||||
|
Stdout io.Writer
|
||||||
|
OutputFormat OutputFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
func ManifestCreate(ctx context.Context, options ManifestCreateOptions) error {
|
||||||
|
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if options.Root == "" {
|
||||||
|
return fmt.Errorf("manifest create command requires a bundle path")
|
||||||
|
}
|
||||||
|
if options.ID == "" {
|
||||||
|
return fmt.Errorf("manifest create command requires --id")
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := parseOptionalCreated(options.Created)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files := normalizeManifestFiles(options.Files)
|
||||||
|
buildOptions := producerbundle.BuildOptions{
|
||||||
|
Root: options.Root,
|
||||||
|
ID: options.ID,
|
||||||
|
Created: created,
|
||||||
|
Files: files,
|
||||||
|
Scan: len(files) == 0,
|
||||||
|
}
|
||||||
|
manifest, err := producerbundle.BuildManifest(buildOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := producerbundle.WriteManifest(options.Root, manifest, producerbundle.WriteManifestOptions{Overwrite: options.Overwrite}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loaded, err := producerbundle.LoadManifest(options.Root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := producerbundle.ValidateBundle(options.Root, loaded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := manifestCreateResultFromManifest(options.Root, loaded)
|
||||||
|
if IsJSONOutput(options.OutputFormat) {
|
||||||
|
return WriteJSONEnvelope(options.Stdout, "manifest create", true, nil, result, nil)
|
||||||
|
}
|
||||||
|
if options.Stdout != nil {
|
||||||
|
_, err = fmt.Fprintf(options.Stdout, "created %s\nbundle: %s\nfiles: %d\ndigest: %s\n", producerbundle.ManifestName, result.ID, result.FileCount, result.Digest)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalCreated(value string) (time.Time, error) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
created, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("created must be RFC3339: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManifestFiles(files []string) []string {
|
||||||
|
normalized := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
normalized = append(normalized, filepath.ToSlash(filepath.Clean(strings.ReplaceAll(file, "\\", string(filepath.Separator)))))
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestCreateResult struct {
|
||||||
|
ManifestPath string `json:"manifest_path"`
|
||||||
|
Root string `json:"root"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
Files []manifestCreateFileResult `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestCreateFileResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestCreateResultFromManifest(root string, manifest producerbundle.Manifest) manifestCreateResult {
|
||||||
|
result := manifestCreateResult{
|
||||||
|
ManifestPath: filepath.ToSlash(filepath.Join(root, producerbundle.ManifestName)),
|
||||||
|
Root: filepath.ToSlash(root),
|
||||||
|
ID: manifest.ID,
|
||||||
|
Created: manifest.Created.Format(time.RFC3339),
|
||||||
|
Digest: manifest.Digest,
|
||||||
|
FileCount: len(manifest.Files),
|
||||||
|
Files: make([]manifestCreateFileResult, 0, len(manifest.Files)),
|
||||||
|
}
|
||||||
|
for _, file := range manifest.Files {
|
||||||
|
result.Files = append(result.Files, manifestCreateFileResult{
|
||||||
|
Path: file.Path,
|
||||||
|
SHA256: file.SHA256,
|
||||||
|
Size: file.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
139
internal/cli/manifest.go
Normal file
139
internal/cli/manifest.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func manifestCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
printManifestHelp(stdout)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "create":
|
||||||
|
return manifestCreateCommand(ctx, args[1:], stdout, stderr)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(stderr, "%s: manifest unknown command %q\n\n", app.Name, args[0])
|
||||||
|
printManifestHelp(stderr)
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestCreateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
if hasHelp(args) {
|
||||||
|
printManifestCreateHelp(stdout)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
flags := flag.NewFlagSet("manifest create", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
id := flags.String("id", "", "source bundle id")
|
||||||
|
created := flags.String("created", "", "source created timestamp")
|
||||||
|
overwrite := flags.Bool("overwrite", false, "replace an existing manifest.json")
|
||||||
|
formatFlag := addFormatFlag(flags)
|
||||||
|
var files repeatedFlag
|
||||||
|
flags.Var(&files, "file", "bundle-relative file to include")
|
||||||
|
flagArgs, positionalArgs, ok := splitManifestCreateArgs(stderr, args)
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if err := flags.Parse(flagArgs); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if len(positionalArgs) != 1 {
|
||||||
|
fmt.Fprintf(stderr, "%s: manifest create requires exactly one bundle path\n", app.Name)
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
format, ok := parseOutputFormat(stderr, "manifest create", *formatFlag)
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
err := app.ManifestCreate(ctx, app.ManifestCreateOptions{
|
||||||
|
Root: positionalArgs[0],
|
||||||
|
ID: *id,
|
||||||
|
Created: *created,
|
||||||
|
Files: []string(files),
|
||||||
|
Overwrite: *overwrite,
|
||||||
|
Stdout: stdout,
|
||||||
|
OutputFormat: format,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fail(stderr, err)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitManifestCreateArgs(stderr io.Writer, args []string) ([]string, []string, bool) {
|
||||||
|
var flagArgs []string
|
||||||
|
var positionalArgs []string
|
||||||
|
for index := 0; index < len(args); index++ {
|
||||||
|
arg := args[index]
|
||||||
|
switch arg {
|
||||||
|
case "--overwrite":
|
||||||
|
flagArgs = append(flagArgs, arg)
|
||||||
|
case "--id", "--created", "--file", "--format":
|
||||||
|
if index+1 >= len(args) {
|
||||||
|
fmt.Fprintf(stderr, "%s: manifest create %s requires a value\n", app.Name, arg)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
flagArgs = append(flagArgs, arg, args[index+1])
|
||||||
|
index++
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(arg, "--id=") ||
|
||||||
|
strings.HasPrefix(arg, "--created=") ||
|
||||||
|
strings.HasPrefix(arg, "--file=") ||
|
||||||
|
strings.HasPrefix(arg, "--format=") {
|
||||||
|
flagArgs = append(flagArgs, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(arg, "-") {
|
||||||
|
flagArgs = append(flagArgs, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
positionalArgs = append(positionalArgs, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flagArgs, positionalArgs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type repeatedFlag []string
|
||||||
|
|
||||||
|
func (f *repeatedFlag) String() string {
|
||||||
|
return fmt.Sprint([]string(*f))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *repeatedFlag) Set(value string) error {
|
||||||
|
*f = append(*f, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printManifestHelp(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `Usage:
|
||||||
|
distributor manifest <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
create Create a source bundle manifest
|
||||||
|
|
||||||
|
Use "distributor manifest <command> --help" for command-specific help.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printManifestCreateHelp(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `Usage:
|
||||||
|
distributor manifest create <bundle-path> --id <bundle-id> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--id <bundle-id> Source bundle id
|
||||||
|
--file <path> Bundle-relative file to include; repeatable
|
||||||
|
--created <time> RFC3339 source created timestamp
|
||||||
|
--overwrite Replace an existing manifest.json
|
||||||
|
--format text|json Output format
|
||||||
|
|
||||||
|
Create manifest.json for a local source bundle directory.
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return validateCommand(ctx, args[1:], stdout, stderr)
|
return validateCommand(ctx, args[1:], stdout, stderr)
|
||||||
case "inspect":
|
case "inspect":
|
||||||
return inspectCommand(ctx, args[1:], stdout, stderr)
|
return inspectCommand(ctx, args[1:], stdout, stderr)
|
||||||
|
case "manifest":
|
||||||
|
return manifestCommand(ctx, args[1:], stdout, stderr)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(stderr, "%s: unknown command %q\n\n", app.Name, args[0])
|
fmt.Fprintf(stderr, "%s: unknown command %q\n\n", app.Name, args[0])
|
||||||
printRootHelp(stderr)
|
printRootHelp(stderr)
|
||||||
@@ -51,6 +53,7 @@ Commands:
|
|||||||
run Run configured distribution pipelines
|
run Run configured distribution pipelines
|
||||||
validate Validate a source bundle or bundle tree
|
validate Validate a source bundle or bundle tree
|
||||||
inspect Inspect bundles or distributor state
|
inspect Inspect bundles or distributor state
|
||||||
|
manifest Create source bundle manifests
|
||||||
|
|
||||||
Use "%s <command> --help" for command-specific help.
|
Use "%s <command> --help" for command-specific help.
|
||||||
`, app.Name, app.Name, app.Name)
|
`, app.Name, app.Name, app.Name)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||||
|
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]any {
|
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]any {
|
||||||
@@ -267,6 +268,206 @@ func TestExecuteInspectArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateExplicitFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "b.txt", "bravo")
|
||||||
|
writeCLIFile(t, root, "nested/a.txt", "alpha")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{
|
||||||
|
"manifest", "create", root,
|
||||||
|
"--id", "reports.explicit",
|
||||||
|
"--created", "2026-06-01T11:00:00Z",
|
||||||
|
"--file", "b.txt",
|
||||||
|
"--file", "nested/a.txt",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"created manifest.json",
|
||||||
|
"bundle: reports.explicit",
|
||||||
|
"files: 2",
|
||||||
|
"digest: sha256:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(stdout.String(), want) {
|
||||||
|
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest, err := producerbundle.LoadManifest(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := manifestPaths(manifest), []string{"b.txt", "nested/a.txt"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("manifest paths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if err := producerbundle.ValidateBundle(root, manifest); err != nil {
|
||||||
|
t.Fatalf("ValidateBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
var validateStdout, validateStderr bytes.Buffer
|
||||||
|
validateCode := Execute(context.Background(), []string{"validate", root}, &validateStdout, &validateStderr)
|
||||||
|
if validateCode != exitOK {
|
||||||
|
t.Fatalf("validate exit code = %d, want %d; stderr = %q", validateCode, exitOK, validateStderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateScansBundle(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "z.txt", "zulu")
|
||||||
|
writeCLIFile(t, root, ".env", "dotfile")
|
||||||
|
writeCLIFile(t, root, "nested/report.md", "# Report\n")
|
||||||
|
writeCLIFile(t, root, storage.StateFileName, "destination state")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.scan"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
manifest, err := producerbundle.LoadManifest(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := manifestPaths(manifest), []string{".env", "nested/report.md", "z.txt"}; !equalStrings(got, want) {
|
||||||
|
t.Fatalf("manifest paths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateJSON(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "report.md", "# Report\n")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.json", "--file", "report.md", "--format", "json"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
envelope := decodeEnvelope(t, &stdout)
|
||||||
|
if envelope["command"] != "manifest create" || envelope["ok"] != true {
|
||||||
|
t.Fatalf("envelope = %#v, want manifest create ok", envelope)
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
if result["id"] != "reports.json" || result["file_count"] != float64(1) {
|
||||||
|
t.Fatalf("result = %#v, want manifest summary", result)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateOverwrite(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "report.md", "old\n")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.old", "--file", "report.md"}, &stdout, &stderr)
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("initial exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
writeCLIFile(t, root, "report.md", "new\n")
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
code = Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.new", "--file", "report.md"}, &stdout, &stderr)
|
||||||
|
if code != exitError {
|
||||||
|
t.Fatalf("overwrite exit code = %d, want %d", code, exitError)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "write manifest") {
|
||||||
|
t.Fatalf("stderr = %q, want write manifest error", stderr.String())
|
||||||
|
}
|
||||||
|
manifest, err := producerbundle.LoadManifest(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
if manifest.ID != "reports.old" {
|
||||||
|
t.Fatalf("manifest id = %q, want reports.old", manifest.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
code = Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.new", "--file", "report.md", "--overwrite"}, &stdout, &stderr)
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("overwrite exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
manifest, err = producerbundle.LoadManifest(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
if manifest.ID != "reports.new" {
|
||||||
|
t.Fatalf("manifest id = %q, want reports.new", manifest.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateRejectsSymlink(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "target.md", "# Report\n")
|
||||||
|
if err := os.Symlink("target.md", filepath.Join(root, "link.md")); err != nil {
|
||||||
|
t.Skipf("symlink unavailable: %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"manifest", "create", root, "--id", "reports.link", "--file", "link.md"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitError {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, exitError)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "regular file") {
|
||||||
|
t.Fatalf("stderr = %q, want regular file error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteManifestCreateArgs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeCLIFile(t, root, "report.md", "# Report\n")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantCode int
|
||||||
|
wantStderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing path",
|
||||||
|
args: []string{"manifest", "create", "--id", "reports.missing"},
|
||||||
|
wantCode: exitUsage,
|
||||||
|
wantStderr: "requires exactly one bundle path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing id",
|
||||||
|
args: []string{"manifest", "create", root},
|
||||||
|
wantCode: exitError,
|
||||||
|
wantStderr: "requires --id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad created",
|
||||||
|
args: []string{"manifest", "create", root, "--id", "reports.bad", "--created", "June 1"},
|
||||||
|
wantCode: exitError,
|
||||||
|
wantStderr: "created must be RFC3339",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad format",
|
||||||
|
args: []string{"manifest", "create", root, "--id", "reports.bad", "--format", "xml"},
|
||||||
|
wantCode: exitUsage,
|
||||||
|
wantStderr: "format must be text or json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Execute(context.Background(), tt.args, &stdout, &stderr)
|
||||||
|
if code != tt.wantCode {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, tt.wantCode, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||||
|
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRunDryRun(t *testing.T) {
|
func TestExecuteRunDryRun(t *testing.T) {
|
||||||
sourceRoot := t.TempDir()
|
sourceRoot := t.TempDir()
|
||||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||||
@@ -512,3 +713,34 @@ func TestUnknownCommandIsUsageError(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want unknown command error", stderr.String())
|
t.Fatalf("stderr = %q, want unknown command error", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func manifestPaths(manifest producerbundle.Manifest) []string {
|
||||||
|
paths := make([]string, 0, len(manifest.Files))
|
||||||
|
for _, file := range manifest.Files {
|
||||||
|
paths = append(paths, file.Path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range a {
|
||||||
|
if a[index] != b[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCLIFile(t *testing.T, root, relative, body string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(relative))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user