From de2ad047bb3177b5ac2afcae5a3767b8a7ff9ee0 Mon Sep 17 00:00:00 2001 From: Sachin Kumar Date: Sun, 10 May 2026 15:40:42 +0530 Subject: [PATCH] feat(fxconfig): add dry-run mode for namespace operations Signed-off-by: Sachin Kumar --- tools/fxconfig/internal/app/deploy.go | 5 +++ tools/fxconfig/internal/app/deploy_test.go | 24 +++++++++++++ tools/fxconfig/internal/cli/v1/flags.go | 3 ++ tools/fxconfig/internal/cli/v1/namespace.go | 24 +++++++++++++ .../internal/cli/v1/namespace_create.go | 16 +++++++++ .../internal/cli/v1/namespace_create_test.go | 36 +++++++++++++++++++ .../internal/cli/v1/namespace_update.go | 18 ++++++++++ .../internal/cli/v1/namespace_update_test.go | 36 +++++++++++++++++++ 8 files changed, 162 insertions(+) diff --git a/tools/fxconfig/internal/app/deploy.go b/tools/fxconfig/internal/app/deploy.go index cbc2728..e47007d 100644 --- a/tools/fxconfig/internal/app/deploy.go +++ b/tools/fxconfig/internal/app/deploy.go @@ -23,6 +23,7 @@ type DeployNamespaceInput struct { Endorse bool Submit bool Wait bool + DryRun bool } // Validate validates namespace configuration. @@ -75,6 +76,10 @@ func (d *AdminApp) DeployNamespace( return nil, UnknownStatus, err } + if input.DryRun { + return out, UnknownStatus, nil + } + // note that we enforce submit if wait is set if !input.Submit && !input.Wait { return out, UnknownStatus, nil diff --git a/tools/fxconfig/internal/app/deploy_test.go b/tools/fxconfig/internal/app/deploy_test.go index 99de03d..cf40724 100644 --- a/tools/fxconfig/internal/app/deploy_test.go +++ b/tools/fxconfig/internal/app/deploy_test.go @@ -226,3 +226,27 @@ func TestDeployNamespace_EndorseAndSubmitWithWaitError(t *testing.T) { _, _, err := a.DeployNamespace(t.Context(), input) require.Error(t, err) } + +func TestDeployNamespace_DryRunSkipsSubmission(t *testing.T) { + t.Parallel() + + a := &AdminApp{ + Validators: fakeValidationContext(), + MspProvider: makeMSPProvider(&testSigningIdentity{}, nil), + OrdererProvider: makeOrdererProvider(nil, errors.New("orderer should not be used")), + NotificationProvider: makeNotificationProvider(nil, errors.New("notification should not be used")), + } + input := validDeployInput() + input.Endorse = true + input.Submit = true + input.Wait = true + input.DryRun = true + + out, status, err := a.DeployNamespace(t.Context(), input) + require.NoError(t, err) + require.NotNil(t, out) + require.NotEmpty(t, out.TxID) + require.NotNil(t, out.Tx) + require.Equal(t, UnknownStatus, status) + require.NotEmpty(t, out.Tx.Endorsements, "dry-run should still allow endorsement before skipping submission") +} diff --git a/tools/fxconfig/internal/cli/v1/flags.go b/tools/fxconfig/internal/cli/v1/flags.go index 34f882b..e5e571f 100644 --- a/tools/fxconfig/internal/cli/v1/flags.go +++ b/tools/fxconfig/internal/cli/v1/flags.go @@ -37,6 +37,7 @@ type namespaceDeployFlags struct { endorse bool submit bool wait bool + dryRun bool } func (f *namespaceDeployFlags) bind(cmd *cobra.Command) { @@ -46,6 +47,8 @@ func (f *namespaceDeployFlags) bind(cmd *cobra.Command) { "Submit transaction to ordering service (requires --endorse)") cmd.Flags().BoolVar(&f.wait, "wait", false, "Wait for transaction finalization (implies --submit)") + cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, + "Validate and preview changes without submitting") } // waitFlag represents a flag to wait for transaction finalization. diff --git a/tools/fxconfig/internal/cli/v1/namespace.go b/tools/fxconfig/internal/cli/v1/namespace.go index 0a96d30..2168045 100644 --- a/tools/fxconfig/internal/cli/v1/namespace.go +++ b/tools/fxconfig/internal/cli/v1/namespace.go @@ -7,6 +7,8 @@ SPDX-License-Identifier: Apache-2.0 package v1 import ( + "fmt" + "github.com/spf13/cobra" ) @@ -34,3 +36,25 @@ endorsement policies. Each namespace has: return cmd } + +type dryRunPreview struct { + operation string + namespace string + txID string +} + +func printDryRun(cmd *cobra.Command, ctx *CLIContext, preview dryRunPreview) { + message := fmt.Sprintf( + "=== DRY RUN ===\n\nNamespace operation prepared successfully.\n\n"+ + "Namespace: %s\nOperation: %s\nTxID: %s\n\nTransaction was NOT submitted.\n", + preview.namespace, + preview.operation, + preview.txID, + ) + if ctx.Printer != nil { + ctx.Printer.Print(message) + return + } + + _, _ = fmt.Fprint(cmd.OutOrStdout(), message) +} diff --git a/tools/fxconfig/internal/cli/v1/namespace_create.go b/tools/fxconfig/internal/cli/v1/namespace_create.go index ee42c06..ab38b75 100644 --- a/tools/fxconfig/internal/cli/v1/namespace_create.go +++ b/tools/fxconfig/internal/cli/v1/namespace_create.go @@ -42,11 +42,15 @@ Transaction Lifecycle Flags: --endorse Collect endorsement from local MSP --submit Submit transaction to ordering service --wait Wait for transaction finalization (implies --submit) + --dry-run Validate and preview changes without submitting Examples: # Create namespace with single org policy (save to file) fxconfig namespace create hello --policy="OR('Org1MSP.member')" --output=tx.json + # Preview a namespace creation without submitting + fxconfig namespace create payments --config config.yaml --dry-run + # Create and immediately deploy (endorse + submit + wait) fxconfig namespace create hello --policy="OR('Org1MSP.member')" --endorse --submit --wait @@ -71,6 +75,7 @@ Examples: Endorse: namespace.endorse, Submit: namespace.submit, Wait: namespace.wait, + DryRun: namespace.dryRun, } res, status, err := ctx.App.DeployNamespace(cmd.Context(), &input) @@ -85,6 +90,17 @@ Examples: return nil } + if input.DryRun { + printDryRun(cmd, ctx, dryRunPreview{ + operation: "create", + namespace: args[0], + txID: res.TxID, + }) + if output == "" { + return nil + } + } + o, err := ctx.IOTransactionCodec.Encode(res.TxID, res.Tx) if err != nil { return err diff --git a/tools/fxconfig/internal/cli/v1/namespace_create_test.go b/tools/fxconfig/internal/cli/v1/namespace_create_test.go index 5b9d14e..48ad71a 100644 --- a/tools/fxconfig/internal/cli/v1/namespace_create_test.go +++ b/tools/fxconfig/internal/cli/v1/namespace_create_test.go @@ -33,6 +33,9 @@ func TestNewCreateCommand(t *testing.T) { policy := cmd.Flag("policy") require.NotNil(t, policy, "policy flag should exist") + + dryRun := cmd.Flag("dry-run") + require.NotNil(t, dryRun, "dry-run flag should exist") } func TestNewCreateCommandRun_TxReturned(t *testing.T) { @@ -85,6 +88,39 @@ func TestNewCreateCommandRun_NoTx(t *testing.T) { mockApp.AssertExpectations(t) } +func TestNewCreateCommandRun_DryRun(t *testing.T) { + t.Parallel() + + mockApp := &testApp{} + deployOut := &app.DeployNamespaceOutput{ + TxID: "tx-dry-run", + Tx: &applicationpb.Tx{}, + } + mockApp.On("DeployNamespace", mock.Anything, mock.MatchedBy(func(input *app.DeployNamespaceInput) bool { + return input != nil && input.DryRun && input.NsID == "my-namespace" + })).Return(deployOut, app.UnknownStatus, nil) + + var printerOut, printerErr bytes.Buffer + printer := cliio.NewCLIPrinter(&printerOut, &printerErr, cliio.FormatTable) + cmd := newNsCreateCommand(&CLIContext{ + App: mockApp, + Printer: printer, + }) + cmd.SetOut(&printerOut) + require.NoError(t, cmd.Flags().Set("policy", "OR('Org1MSP.member')")) + require.NoError(t, cmd.Flags().Set("dry-run", "true")) + + err := cmd.RunE(cmd, []string{"my-namespace"}) + + require.NoError(t, err) + require.Contains(t, printerOut.String(), "=== DRY RUN ===") + require.Contains(t, printerOut.String(), "Namespace: my-namespace") + require.Contains(t, printerOut.String(), "Operation: create") + require.Contains(t, printerOut.String(), "TxID: tx-dry-run") + require.Contains(t, printerOut.String(), "Transaction was NOT submitted.") + mockApp.AssertExpectations(t) +} + type testApp struct { mock.Mock } diff --git a/tools/fxconfig/internal/cli/v1/namespace_update.go b/tools/fxconfig/internal/cli/v1/namespace_update.go index 12ad550..6caaa51 100644 --- a/tools/fxconfig/internal/cli/v1/namespace_update.go +++ b/tools/fxconfig/internal/cli/v1/namespace_update.go @@ -40,6 +40,9 @@ Use 'fxconfig namespace list' to find the current version number. Version numbers increment with each successful update. If the version you specify doesn't match the current version, the update will fail. +Dry-run validates the update and previews the generated transaction without +submitting it to the ordering service. + Examples: # Update namespace policy (check version first with 'list') fxconfig namespace update hello \ @@ -47,6 +50,9 @@ Examples: --version=0 \ --endorse --submit --wait + # Preview a namespace update without submitting + fxconfig namespace update payments --config updated.yaml --dry-run + # Change from single-org to multi-org policy fxconfig namespace update hello \ --policy="AND('Org1MSP.member', 'Org2MSP.member')" \ @@ -70,6 +76,7 @@ Examples: Endorse: namespace.endorse, Submit: namespace.submit, Wait: namespace.wait, + DryRun: namespace.dryRun, } res, status, err := ctx.App.DeployNamespace(cmd.Context(), &input) @@ -84,6 +91,17 @@ Examples: return nil } + if input.DryRun { + printDryRun(cmd, ctx, dryRunPreview{ + operation: "update", + namespace: args[0], + txID: res.TxID, + }) + if output == "" { + return nil + } + } + o, err := ctx.IOTransactionCodec.Encode(res.TxID, res.Tx) if err != nil { return err diff --git a/tools/fxconfig/internal/cli/v1/namespace_update_test.go b/tools/fxconfig/internal/cli/v1/namespace_update_test.go index a21cc75..4c36a05 100644 --- a/tools/fxconfig/internal/cli/v1/namespace_update_test.go +++ b/tools/fxconfig/internal/cli/v1/namespace_update_test.go @@ -36,6 +36,9 @@ func TestNewUpdateCommand(t *testing.T) { policy := cmd.Flag("policy") require.NotNil(t, policy, "policy flag should exist") + + dryRun := cmd.Flag("dry-run") + require.NotNil(t, dryRun, "dry-run flag should exist") } func TestNsUpdateCommandRun_TxReturned(t *testing.T) { @@ -85,3 +88,36 @@ func TestNsUpdateCommandRun_NoTx(t *testing.T) { require.Contains(t, printerOut.String(), "Transaction status: STATUS_UNSPECIFIED") mockApp.AssertExpectations(t) } + +func TestNsUpdateCommandRun_DryRun(t *testing.T) { + t.Parallel() + + mockApp := &testApp{} + deployOut := &app.DeployNamespaceOutput{ + TxID: "tx-dry-run-update", + Tx: &applicationpb.Tx{}, + } + mockApp.On("DeployNamespace", mock.Anything, mock.MatchedBy(func(input *app.DeployNamespaceInput) bool { + return input != nil && input.DryRun && input.NsID == "my-namespace" && input.Version == 1 + })).Return(deployOut, app.UnknownStatus, nil) + + var outBuf bytes.Buffer + cmd := newNsUpdateCommand(&CLIContext{ + App: mockApp, + Printer: cliio.NewCLIPrinter(&outBuf, &outBuf, cliio.FormatTable), + }) + cmd.SetOut(&outBuf) + require.NoError(t, cmd.Flags().Set("policy", "OR('Org1MSP.member')")) + require.NoError(t, cmd.Flags().Set("version", "1")) + require.NoError(t, cmd.Flags().Set("dry-run", "true")) + + err := cmd.RunE(cmd, []string{"my-namespace"}) + + require.NoError(t, err) + require.Contains(t, outBuf.String(), "=== DRY RUN ===") + require.Contains(t, outBuf.String(), "Namespace: my-namespace") + require.Contains(t, outBuf.String(), "Operation: update") + require.Contains(t, outBuf.String(), "TxID: tx-dry-run-update") + require.Contains(t, outBuf.String(), "Transaction was NOT submitted.") + mockApp.AssertExpectations(t) +}