287 lines
9.8 KiB
Go
287 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
|
)
|
|
|
|
func TestExecutePruneRejectsInvalidFlags(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantStderr string
|
|
}{
|
|
{
|
|
name: "missing config",
|
|
args: []string{"prune", "--pipeline", "reports", "--destination", "archive", "--dry-run"},
|
|
wantStderr: "requires --config",
|
|
},
|
|
{
|
|
name: "missing pipeline",
|
|
args: []string{"prune", "--config", "config.yml", "--destination", "archive", "--dry-run"},
|
|
wantStderr: "requires --pipeline",
|
|
},
|
|
{
|
|
name: "missing destination",
|
|
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--dry-run"},
|
|
wantStderr: "requires --destination",
|
|
},
|
|
{
|
|
name: "missing mode",
|
|
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive"},
|
|
wantStderr: "requires exactly one of --dry-run or --apply",
|
|
},
|
|
{
|
|
name: "conflicting modes",
|
|
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--apply"},
|
|
wantStderr: "requires exactly one of --dry-run or --apply",
|
|
},
|
|
{
|
|
name: "invalid format",
|
|
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--format", "xml"},
|
|
wantStderr: "format must be text or json",
|
|
},
|
|
{
|
|
name: "positional",
|
|
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "extra"},
|
|
wantStderr: "does not accept positional arguments",
|
|
},
|
|
}
|
|
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 != exitUsage {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitUsage, 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 TestExecutePruneDryRunReportsWithoutWriting(t *testing.T) {
|
|
destinationRoot, configPath := writePruneLocalFixture(t)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{
|
|
"prune",
|
|
"--config", configPath,
|
|
"--pipeline", "reports",
|
|
"--destination", "archive",
|
|
"--dry-run",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
if output := stdout.String(); !strings.Contains(output, "status=would_change") || !strings.Contains(output, "planned=2") || !strings.Contains(output, "deleted=0") {
|
|
t.Fatalf("stdout = %q, want dry-run prune summary", output)
|
|
}
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "html.txt"), "other")
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
|
|
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
|
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,summary.txt,html.txt" {
|
|
t.Fatalf("state outputs = %q, want original outputs", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePruneJSONReport(t *testing.T) {
|
|
_, configPath := writePruneLocalFixture(t)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{
|
|
"prune",
|
|
"--config", configPath,
|
|
"--pipeline", "reports",
|
|
"--destination", "archive",
|
|
"--dry-run",
|
|
"--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"] != "prune" || envelope["ok"] != true {
|
|
t.Fatalf("envelope = %#v, want prune ok", envelope)
|
|
}
|
|
result := envelopeResult(t, envelope)
|
|
if result["would_change"] != true || result["state_changed"] != false || result["dry_run"] != true {
|
|
t.Fatalf("result = %#v, want dry-run pending change", result)
|
|
}
|
|
planned, ok := result["planned_outputs"].([]any)
|
|
if !ok || len(planned) != 2 {
|
|
t.Fatalf("planned outputs = %#v, want two", result["planned_outputs"])
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePruneApplyDeletesManagedOutputs(t *testing.T) {
|
|
destinationRoot, configPath := writePruneLocalFixture(t)
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := Execute(context.Background(), []string{
|
|
"prune",
|
|
"--config", configPath,
|
|
"--pipeline", "reports",
|
|
"--destination", "archive",
|
|
"--apply",
|
|
}, &stdout, &stderr)
|
|
|
|
if code != exitOK {
|
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
|
}
|
|
if output := stdout.String(); !strings.Contains(output, "status=changed") || !strings.Contains(output, "deleted=2") {
|
|
t.Fatalf("stdout = %q, want applied prune summary", output)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) {
|
|
t.Fatalf("report.md stat error = %v, want not exist", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(destinationRoot, "summary.txt")); !os.IsNotExist(err) {
|
|
t.Fatalf("summary.txt stat error = %v, want not exist", err)
|
|
}
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "html.txt"), "other")
|
|
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
|
|
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
|
|
t.Fatalf("state file stat error = %v", err)
|
|
}
|
|
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
|
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "html.txt" {
|
|
t.Fatalf("state outputs = %q, want html.txt", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
}
|
|
|
|
func writePruneLocalFixture(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
|
writeCatalogDestinationState(t, destinationRoot, manifest, true)
|
|
for _, file := range testutil.DefaultSourceFiles() {
|
|
path := filepath.Join(destinationRoot, filepath.FromSlash(file.Path))
|
|
if err := os.WriteFile(path, []byte(file.Data), 0o600); err != nil {
|
|
t.Fatalf("write destination output: %v", err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destinationRoot, "html.txt"), []byte("other"), 0o600); err != nil {
|
|
t.Fatalf("write other owner output: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
|
|
t.Fatalf("write unmanaged output: %v", err)
|
|
}
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
config := `
|
|
pipelines:
|
|
- id: reports
|
|
source:
|
|
backend: local
|
|
path: ` + sourceRoot + `
|
|
destinations:
|
|
- id: archive
|
|
backend: local
|
|
path: ` + destinationRoot + `
|
|
retention:
|
|
prune:
|
|
enabled: true
|
|
older_than: 1h
|
|
`
|
|
if err := os.WriteFile(configPath, []byte(strings.TrimSpace(config)+"\n"), 0o600); err != nil {
|
|
t.Fatalf("write prune config: %v", err)
|
|
}
|
|
return destinationRoot, configPath
|
|
}
|
|
|
|
func writeCatalogDestinationState(t *testing.T, root string, manifest bundle.Manifest, includeOtherOwner bool) {
|
|
t.Helper()
|
|
createdAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
|
source := state.CatalogSourceIdentity{ID: manifest.ID, Digest: manifest.Digest, Created: manifest.Created}
|
|
outputs := []state.CatalogOutputFile{{
|
|
Path: "report.md",
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
Source: source,
|
|
Kind: state.OutputKindSource,
|
|
SHA256: manifest.Files[0].SHA256,
|
|
Size: manifest.Files[0].Size,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}, {
|
|
Path: "summary.txt",
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
Source: source,
|
|
Kind: state.OutputKindSource,
|
|
SHA256: manifest.Files[1].SHA256,
|
|
Size: manifest.Files[1].Size,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}}
|
|
if includeOtherOwner {
|
|
outputs = append(outputs, state.CatalogOutputFile{
|
|
Path: "html.txt",
|
|
PipelineID: "reports",
|
|
DestinationID: "html",
|
|
Source: source,
|
|
Kind: state.OutputKindSource,
|
|
SHA256: manifest.Files[0].SHA256,
|
|
Size: manifest.Files[0].Size,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
})
|
|
}
|
|
catalog := state.CatalogState{
|
|
SchemaVersion: state.CatalogSchemaVersion,
|
|
DistributorVersion: "test",
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
State: state.StatePolicy{Mode: state.StateModeCatalog},
|
|
Outputs: outputs,
|
|
}
|
|
data, err := json.MarshalIndent(catalog, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal catalog state: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, storage.StateFileName), append(data, '\n'), 0o600); err != nil {
|
|
t.Fatalf("write catalog state: %v", err)
|
|
}
|
|
}
|
|
|
|
func readLocalCatalogState(t *testing.T, path string) state.CatalogState {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read catalog state: %v", err)
|
|
}
|
|
catalog, err := state.ParseCatalog(data)
|
|
if err != nil {
|
|
t.Fatalf("parse catalog state: %v", err)
|
|
}
|
|
return catalog
|
|
}
|