Expose reconcile state command
This commit is contained in:
84
internal/cli/reconcile_state.go
Normal file
84
internal/cli/reconcile_state.go
Normal 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.
|
||||
`)
|
||||
}
|
||||
190
internal/cli/reconcile_state_test.go
Normal file
190
internal/cli/reconcile_state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user