From 55455ff1b6c77bcb14c4f4943a40e013416765cf Mon Sep 17 00:00:00 2001 From: Alessio Ricci Toniolo Date: Fri, 6 Mar 2026 12:50:57 -0500 Subject: [PATCH] Add 'docker network edit' command for updating network labels Adds a new 'docker network edit' subcommand that allows users to add or remove labels on an existing network using --label-add and --label-rm flags, consistent with the UX of 'docker service update'. Because the Docker Engine API does not expose a network-update endpoint, this command implements the change by inspecting the network, removing it, and recreating it with identical configuration and updated labels. To avoid disrupting connectivity the command refuses to operate on networks that have active endpoints, instructing the user to disconnect containers first. Signed-off-by: Alessio Toniolo --- cli/command/network/cmd.go | 1 + cli/command/network/edit.go | 112 +++++++++++++++++++++++++ cli/command/network/edit_test.go | 135 +++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 cli/command/network/edit.go create mode 100644 cli/command/network/edit_test.go diff --git a/cli/command/network/cmd.go b/cli/command/network/cmd.go index 7f4ec64e1f80..c9a5865d540e 100644 --- a/cli/command/network/cmd.go +++ b/cli/command/network/cmd.go @@ -26,6 +26,7 @@ func newNetworkCommand(dockerCLI command.Cli) *cobra.Command { newConnectCommand(dockerCLI), newCreateCommand(dockerCLI), newDisconnectCommand(dockerCLI), + newEditCommand(dockerCLI), newInspectCommand(dockerCLI), newListCommand(dockerCLI), newRemoveCommand(dockerCLI), diff --git a/cli/command/network/edit.go b/cli/command/network/edit.go new file mode 100644 index 000000000000..3464a05f73ef --- /dev/null +++ b/cli/command/network/edit.go @@ -0,0 +1,112 @@ +package network + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/completion" + "github.com/docker/cli/opts" + "github.com/moby/moby/client" + "github.com/spf13/cobra" +) + +type editOptions struct { + labelsToAdd opts.ListOpts + labelsToRm []string +} + +func newEditCommand(dockerCLI command.Cli) *cobra.Command { + options := editOptions{ + labelsToAdd: opts.NewListOpts(opts.ValidateLabel), + } + + cmd := &cobra.Command{ + Use: "edit [OPTIONS] NETWORK", + Short: "Edit a network", + Long: `Edit the labels of a network. + +Because the Docker Engine API does not support in-place network updates, this +command recreates the network with the same configuration and updated labels. +The network must have no active endpoints before editing; use +'docker network disconnect' to disconnect any containers first.`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runEdit(cmd.Context(), dockerCLI.Client(), dockerCLI.Out(), args[0], options) + }, + ValidArgsFunction: completion.NetworkNames(dockerCLI), + DisableFlagsInUseLine: true, + } + + flags := cmd.Flags() + flags.Var(&options.labelsToAdd, "label-add", "Add or update a label (format: `key=value`)") + flags.StringSliceVar(&options.labelsToRm, "label-rm", nil, "Remove a label by key") + return cmd +} + +func runEdit(ctx context.Context, apiClient client.NetworkAPIClient, output io.Writer, networkID string, options editOptions) error { + if options.labelsToAdd.Len() == 0 && len(options.labelsToRm) == 0 { + return fmt.Errorf("no changes requested; use --label-add or --label-rm to modify labels") + } + + result, err := apiClient.NetworkInspect(ctx, networkID, client.NetworkInspectOptions{}) + if err != nil { + return err + } + nw := result.Network + + if len(nw.Containers) > 0 { + var names []string + for _, ep := range nw.Containers { + names = append(names, ep.Name) + } + return fmt.Errorf("network %s has active endpoints (%s); disconnect all containers before editing", + nw.Name, strings.Join(names, ", ")) + } + + // Build updated labels from existing ones. + labels := make(map[string]string, len(nw.Labels)) + for k, v := range nw.Labels { + labels[k] = v + } + for _, l := range options.labelsToAdd.GetSlice() { + k, v, _ := strings.Cut(l, "=") + labels[k] = v + } + for _, k := range options.labelsToRm { + delete(labels, k) + } + + // NetworkRemove is called first; if it fails the original network is left intact. + if _, err = apiClient.NetworkRemove(ctx, nw.ID, client.NetworkRemoveOptions{}); err != nil { + return fmt.Errorf("NetworkRemove: %w", err) + } + + // Preserve EnableIPv4/EnableIPv6 as pointers so that false values are + // explicitly sent to the daemon (matching the original network's settings). + enableIPv4 := nw.EnableIPv4 + enableIPv6 := nw.EnableIPv6 + + resp, err := apiClient.NetworkCreate(ctx, nw.Name, client.NetworkCreateOptions{ + Driver: nw.Driver, + Options: nw.Options, + IPAM: &nw.IPAM, + Internal: nw.Internal, + EnableIPv4: &enableIPv4, + EnableIPv6: &enableIPv6, + Attachable: nw.Attachable, + Ingress: nw.Ingress, + Scope: nw.Scope, + ConfigOnly: nw.ConfigOnly, + ConfigFrom: nw.ConfigFrom.Network, + Labels: labels, + }) + if err != nil { + return fmt.Errorf("NetworkCreate: %w", err) + } + _, _ = fmt.Fprintln(output, resp.ID) + return nil +} diff --git a/cli/command/network/edit_test.go b/cli/command/network/edit_test.go new file mode 100644 index 000000000000..8fb4856e6d34 --- /dev/null +++ b/cli/command/network/edit_test.go @@ -0,0 +1,135 @@ +package network + +import ( + "context" + "testing" + + "github.com/docker/cli/internal/test" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + "gotest.tools/v3/assert" +) + +func TestNetworkEditNoChanges(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cmd := newEditCommand(cli) + cmd.SetArgs([]string{"mynet"}) + err := cmd.Execute() + assert.ErrorContains(t, err, "no changes requested") +} + +func TestNetworkEditActiveEndpoints(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{ + networkInspectFunc: func(_ context.Context, _ string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) { + return client.NetworkInspectResult{ + Network: network.Inspect{ + Network: network.Network{ + Name: "mynet", + ID: "abc123", + }, + Containers: map[string]network.EndpointResource{ + "ep1": {Name: "mycontainer"}, + }, + }, + }, nil + }, + }) + cmd := newEditCommand(cli) + cmd.SetArgs([]string{"--label-add", "env=prod", "mynet"}) + err := cmd.Execute() + assert.ErrorContains(t, err, "active endpoints") + assert.ErrorContains(t, err, "mycontainer") +} + +func TestNetworkEditLabelAdd(t *testing.T) { + var createCalled bool + var removeCalled bool + + fakeNetwork := network.Inspect{ + Network: network.Network{ + Name: "mynet", + ID: "abc123", + Driver: "bridge", + Options: map[string]string{}, + Labels: map[string]string{"existing": "value"}, + }, + } + + cli := test.NewFakeCli(&fakeClient{ + networkInspectFunc: func(_ context.Context, _ string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) { + return client.NetworkInspectResult{Network: fakeNetwork}, nil + }, + networkRemoveFunc: func(_ context.Context, _ string) error { + removeCalled = true + return nil + }, + networkCreateFunc: func(_ context.Context, name string, opts client.NetworkCreateOptions) (client.NetworkCreateResult, error) { + createCalled = true + assert.Equal(t, "mynet", name) + assert.Equal(t, "prod", opts.Labels["env"]) + assert.Equal(t, "value", opts.Labels["existing"]) + return client.NetworkCreateResult{ID: "newid123"}, nil + }, + }) + + cmd := newEditCommand(cli) + cmd.SetArgs([]string{"--label-add", "env=prod", "mynet"}) + assert.NilError(t, cmd.Execute()) + assert.Assert(t, removeCalled) + assert.Assert(t, createCalled) +} + +func TestNetworkEditLabelRemove(t *testing.T) { + fakeNetwork := network.Inspect{ + Network: network.Network{ + Name: "mynet", + ID: "abc123", + Driver: "bridge", + Options: map[string]string{}, + Labels: map[string]string{"env": "prod", "keep": "me"}, + }, + } + + cli := test.NewFakeCli(&fakeClient{ + networkInspectFunc: func(_ context.Context, _ string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) { + return client.NetworkInspectResult{Network: fakeNetwork}, nil + }, + networkRemoveFunc: func(_ context.Context, _ string) error { return nil }, + networkCreateFunc: func(_ context.Context, _ string, opts client.NetworkCreateOptions) (client.NetworkCreateResult, error) { + _, hasEnv := opts.Labels["env"] + assert.Assert(t, !hasEnv, "expected 'env' label to be removed") + assert.Equal(t, "me", opts.Labels["keep"]) + return client.NetworkCreateResult{ID: "newid456"}, nil + }, + }) + + cmd := newEditCommand(cli) + cmd.SetArgs([]string{"--label-rm", "env", "mynet"}) + assert.NilError(t, cmd.Execute()) +} + +func TestNetworkEditOutputsNewID(t *testing.T) { + fakeNetwork := network.Inspect{ + Network: network.Network{ + Name: "mynet", + ID: "abc123", + Driver: "bridge", + Options: map[string]string{}, + }, + } + + cli := test.NewFakeCli(&fakeClient{ + networkInspectFunc: func(_ context.Context, _ string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) { + return client.NetworkInspectResult{Network: fakeNetwork}, nil + }, + networkRemoveFunc: func(_ context.Context, _ string) error { return nil }, + networkCreateFunc: func(_ context.Context, _ string, _ client.NetworkCreateOptions) (client.NetworkCreateResult, error) { + return client.NetworkCreateResult{ID: "newid789"}, nil + }, + }) + + cmd := newEditCommand(cli) + cmd.SetArgs([]string{"--label-add", "foo=bar", "mynet"}) + assert.NilError(t, cmd.Execute()) + assert.Equal(t, "newid789\n", cli.OutBuffer().String()) +}