Expose reconcile state command

This commit is contained in:
2026-06-08 19:19:43 +00:00
parent de6723c5de
commit 2abd09bde3
10 changed files with 408 additions and 5 deletions

View File

@@ -0,0 +1,84 @@
package cli
import (
"context"
"fmt"
"io"
"gitea.maximumdirect.net/eric/distributor/internal/app"
)
func reconcileStateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if hasHelp(args) {
printReconcileStateHelp(stdout)
return exitOK
}
flags := newFlagSet("reconcile-state", stderr)
configPath := flags.String("config", "", "path to config file")
pipelineID := flags.String("pipeline", "", "pipeline id")
destinationID := flags.String("destination", "", "destination id")
allOwners := flags.Bool("all-owners", false, "repair all shared-root owners in the selected destination root")
dryRun := flags.Bool("dry-run", false, "report repairs without rewriting state")
formatFlag := addFormatFlag(flags)
if err := flags.Parse(args); err != nil {
return exitUsage
}
if rejectPositionalArgs(stderr, "reconcile-state", flags.Args()) {
return exitUsage
}
format, ok := parseOutputFormat(stderr, "reconcile-state", *formatFlag)
if !ok {
return exitUsage
}
if !validateReconcileStateFlags(stderr, *configPath, *pipelineID, *destinationID) {
return exitUsage
}
if _, err := app.ReconcileState(ctx, app.ReconcileStateOptions{
ConfigPath: *configPath,
PipelineID: *pipelineID,
DestinationID: *destinationID,
AllOwners: *allOwners,
DryRun: *dryRun,
Stdout: stdout,
OutputFormat: format,
}); err != nil {
return fail(stderr, err)
}
return exitOK
}
func validateReconcileStateFlags(stderr io.Writer, configPath, pipelineID, destinationID string) bool {
if configPath == "" {
fmt.Fprintf(stderr, "%s: reconcile-state requires --config\n", app.Name)
return false
}
if pipelineID == "" {
fmt.Fprintf(stderr, "%s: reconcile-state requires --pipeline\n", app.Name)
return false
}
if destinationID == "" {
fmt.Fprintf(stderr, "%s: reconcile-state requires --destination\n", app.Name)
return false
}
return true
}
func printReconcileStateHelp(w io.Writer) {
fmt.Fprint(w, `Usage:
distributor reconcile-state --config <path> --pipeline <id> --destination <id> [--all-owners] [--dry-run] [--format text|json]
Options:
--config <path> Path to config file
--pipeline <id> Pipeline id that selects the destination root
--destination <id> Destination id that selects the destination root
--all-owners Repair all shared-root owners in the selected destination root
--dry-run Report repairs without rewriting state
--format text|json Output format
Reconcile-state checks managed output records against destination storage and
removes records for missing managed outputs unless --dry-run is set. It reports
unmanaged entries but does not delete or adopt destination files.
`)
}

View File

@@ -0,0 +1,190 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
)
func TestExecuteReconcileStateAppliesByDefault(t *testing.T) {
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
t.Fatalf("write managed output: %v", err)
}
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
t.Fatalf("write unmanaged output: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{
"reconcile-state",
"--config", configPath,
"--pipeline", "reports",
"--destination", "archive",
}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "status=changed") {
t.Fatalf("stdout = %q, want changed status", stdout.String())
}
destinationState := testutil.ReadDestinationState(t, filepath.Join(destinationRoot, storage.StateFileName))
if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md" {
t.Fatalf("state outputs = %q, want report.md", got)
}
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteReconcileStateDryRunReportsWithoutWriting(t *testing.T) {
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
t.Fatalf("write managed output: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{
"reconcile-state",
"--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 !strings.Contains(stdout.String(), "status=would_change") {
t.Fatalf("stdout = %q, want would_change status", stdout.String())
}
destinationState := testutil.ReadDestinationState(t, filepath.Join(destinationRoot, storage.StateFileName))
if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md,summary.txt" {
t.Fatalf("state outputs = %q, want original outputs", got)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteReconcileStateJSONReport(t *testing.T) {
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
t.Fatalf("write managed output: %v", err)
}
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
t.Fatalf("write unmanaged output: %v", err)
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{
"reconcile-state",
"--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"] != "reconcile-state" || envelope["ok"] != true {
t.Fatalf("envelope = %#v, want reconcile-state ok", envelope)
}
result := envelopeResult(t, envelope)
if result["would_change"] != true || result["changed"] != false || result["dry_run"] != true {
t.Fatalf("result = %#v, want dry-run pending change", result)
}
missing, ok := result["missing_managed_outputs"].([]any)
if !ok || len(missing) != 1 {
t.Fatalf("missing outputs = %#v, want one", result["missing_managed_outputs"])
}
unmanaged, ok := result["unmanaged_entries"].([]any)
if !ok || len(unmanaged) != 1 {
t.Fatalf("unmanaged entries = %#v, want one", result["unmanaged_entries"])
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteReconcileStateRejectsInvalidFlags(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr string
}{
{
name: "missing config",
args: []string{"reconcile-state", "--pipeline", "reports", "--destination", "archive"},
wantStderr: "requires --config",
},
{
name: "missing pipeline",
args: []string{"reconcile-state", "--config", "config.yml", "--destination", "archive"},
wantStderr: "requires --pipeline",
},
{
name: "missing destination",
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports"},
wantStderr: "requires --destination",
},
{
name: "invalid format",
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--format", "xml"},
wantStderr: "format must be text or json",
},
{
name: "positional",
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "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 writeReconcileStateLocalFixture(t *testing.T) (string, string, string) {
t.Helper()
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
testutil.WriteDestinationState(t, destinationRoot, "", manifest, testutil.DestinationStateOptions{})
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
return sourceRoot, destinationRoot, configPath
}
func assertLocalFile(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file %s: %v", path, err)
}
if string(data) != want {
t.Fatalf("file %s = %q, want %q", path, data, want)
}
}

View File

@@ -29,6 +29,8 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(ctx, args[1:], stdout, stderr)
case "run":
return runCommand(ctx, args[1:], stdout, stderr)
case "reconcile-state":
return reconcileStateCommand(ctx, args[1:], stdout, stderr)
case "serve":
return serveCommand(ctx, args[1:], stdout, stderr)
case "validate":
@@ -53,6 +55,8 @@ Usage:
Commands:
version Print version information
run Run configured distribution pipelines
reconcile-state
Repair missing managed-output records in destination state
serve Run the HTTP upload server
validate Validate a source bundle or bundle tree
inspect Inspect bundles or distributor state