Skip to content

Commit c20b526

Browse files
fix: emit Networks and Addresses in deterministic order (#431, #169)
The per-container Networks and Addresses slices passed to templates were built by ranging over Go maps (NetworkSettings.Networks, NetworkSettings.Ports and Config.ExposedPorts), whose iteration order is random. This caused generated files to churn on every regeneration and triggered spurious reloads / container restarts even when nothing meaningful changed (#431), and made `index .Networks 0` pick an arbitrary network (#169). - internal/context/address.go: sort addresses in GetContainerAddresses via a new sortAddresses helper (Port numeric -> Proto -> HostPort -> HostIP -> IP). - internal/generator/generator.go: sort runtimeContainer.Networks by Name via a new sortNetworks helper, right after the network map is collected. - Tests: add order-sensitive TestSortAddresses / TestGetContainerAddressesSorted and TestSortNetworks. Existing order-insensitive address tests still pass. - README.md: document the deterministic ordering, the behavior change to `index .Networks 0`, and how to select a network by name with `where`. #169 needs no further code change: a specific network is selectable via `index (where $value.Networks "Name" $name) 0` once the order is stable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ec1acf commit c20b526

5 files changed

Lines changed: 140 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,20 @@ For example, this is a JSON version of an emitted RuntimeContainer struct:
394394
}
395395
```
396396

397+
#### Ordering of `Addresses` and `Networks`
398+
399+
The `Addresses` and `Networks` slices are emitted in a deterministic order, so generated output is stable across regenerations and does not trigger spurious reloads/restarts when nothing meaningful changed:
400+
401+
- `Addresses` are sorted by port (compared numerically), then by protocol, host port, host IP and IP.
402+
- `Networks` are sorted alphabetically by `Name`.
403+
404+
As a consequence, `index .Networks 0` returns the alphabetically-first network (for example `bridge`) rather than an arbitrary one. To select a specific network by name, filter with `where`:
405+
406+
```
407+
{{ $net := index (where $value.Networks "Name" "my-network") 0 }}
408+
server {{ $net.IP }}:{{ (index $value.Addresses 0).Port }};
409+
```
410+
397411
#### Functions
398412

399413
- [Functions from Go](https://pkg.go.dev/text/template#hdr-Functions)

internal/context/address.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package context
22

33
import (
4+
"sort"
5+
"strconv"
6+
47
docker "github.com/fsouza/go-dockerclient"
58
)
69

@@ -46,5 +49,35 @@ func GetContainerAddresses(container *docker.Container) []Address {
4649
}
4750
}
4851

52+
// The addresses above are collected by ranging over Go maps
53+
// (NetworkSettings.Ports / Config.ExposedPorts), whose iteration order is
54+
// random. Sort them into a deterministic order so the generated output is
55+
// stable across regenerations and does not churn (issue #431).
56+
sortAddresses(addresses)
57+
4958
return addresses
5059
}
60+
61+
// sortAddresses sorts addresses in place into a deterministic, total order:
62+
// Port (numeric) -> Proto -> HostPort -> HostIP -> IP. (Port, Proto) is already
63+
// unique among the generated rows; the remaining keys guarantee a total order.
64+
func sortAddresses(addresses []Address) {
65+
sort.Slice(addresses, func(i, j int) bool {
66+
a, b := addresses[i], addresses[j]
67+
pa, _ := strconv.Atoi(a.Port) // non-numeric / "" -> 0; docker ports are always numeric
68+
pb, _ := strconv.Atoi(b.Port)
69+
if pa != pb {
70+
return pa < pb
71+
}
72+
if a.Proto != b.Proto {
73+
return a.Proto < b.Proto
74+
}
75+
if a.HostPort != b.HostPort {
76+
return a.HostPort < b.HostPort
77+
}
78+
if a.HostIP != b.HostIP {
79+
return a.HostIP < b.HostIP
80+
}
81+
return a.IP < b.IP
82+
})
83+
}

internal/context/address_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,50 @@ func TestGenerateContainerAddressesWithNoPorts(t *testing.T) {
114114
HostPort: "",
115115
})
116116
}
117+
118+
func TestSortAddresses(t *testing.T) {
119+
addresses := []Address{
120+
{IP: "10.0.0.10", Port: "8080", Proto: "tcp"},
121+
{IP: "10.0.0.10", Port: "80", Proto: "tcp"},
122+
{IP: "10.0.0.10", Port: "443", Proto: "tcp"},
123+
{IP: "10.0.0.10", Port: "53", Proto: "udp"},
124+
{IP: "10.0.0.10", Port: "53", Proto: "tcp"},
125+
}
126+
127+
// Port sorts numerically (not lexically: "443" must not sort before "80"),
128+
// with Proto as the tie-breaker for equal ports (53/tcp before 53/udp).
129+
want := []Address{
130+
{IP: "10.0.0.10", Port: "53", Proto: "tcp"},
131+
{IP: "10.0.0.10", Port: "53", Proto: "udp"},
132+
{IP: "10.0.0.10", Port: "80", Proto: "tcp"},
133+
{IP: "10.0.0.10", Port: "443", Proto: "tcp"},
134+
{IP: "10.0.0.10", Port: "8080", Proto: "tcp"},
135+
}
136+
137+
sortAddresses(addresses)
138+
assert.Equal(t, want, addresses)
139+
}
140+
141+
func TestGetContainerAddressesSorted(t *testing.T) {
142+
testContainer := &docker.Container{
143+
Config: &docker.Config{
144+
ExposedPorts: map[docker.Port]struct{}{},
145+
},
146+
NetworkSettings: &docker.NetworkSettings{
147+
IPAddress: "10.0.0.10",
148+
Ports: map[docker.Port][]docker.PortBinding{},
149+
},
150+
}
151+
// Insert ports out of numeric order; the map iteration order is random but
152+
// GetContainerAddresses must return them deterministically sorted by port.
153+
testContainer.NetworkSettings.Ports[httpTestPort] = []docker.PortBinding{} // 8080
154+
testContainer.NetworkSettings.Ports[httpPort] = []docker.PortBinding{} // 80
155+
testContainer.NetworkSettings.Ports[httpsPort] = []docker.PortBinding{} // 443
156+
157+
addresses := GetContainerAddresses(testContainer)
158+
ports := make([]string, len(addresses))
159+
for i, a := range addresses {
160+
ports[i] = a.Port
161+
}
162+
assert.Equal(t, []string{"80", "443", "8080"}, ports)
163+
}

internal/generator/generator.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/exec"
88
"os/signal"
9+
"sort"
910
"strings"
1011
"sync"
1112
"syscall"
@@ -388,6 +389,15 @@ func (g *generator) sendSignalToFilteredContainers(config config.Config) {
388389
}
389390
}
390391

392+
// sortNetworks sorts networks in place by Name (ascending). Name is the docker
393+
// network name (the NetworkSettings.Networks map key), so it is unique and
394+
// non-empty, giving a stable total order.
395+
func sortNetworks(networks []context.Network) {
396+
sort.Slice(networks, func(i, j int) bool {
397+
return networks[i].Name < networks[j].Name
398+
})
399+
}
400+
391401
func (g *generator) getContainers(config config.Config) ([]*context.RuntimeContainer, error) {
392402
apiInfo, err := g.Client.Info()
393403
if err != nil {
@@ -474,6 +484,12 @@ func (g *generator) getContainers(config config.Config) ([]*context.RuntimeConta
474484
network)
475485
}
476486

487+
// NetworkSettings.Networks is a Go map, so the slice built above has a
488+
// random order. Sort it deterministically so generated output is stable
489+
// across regenerations (issue #431) and `index .Networks 0` is
490+
// predictable / selectable by name (issue #169).
491+
sortNetworks(runtimeContainer.Networks)
492+
477493
for k, v := range container.Volumes {
478494
runtimeContainer.Volumes[k] = context.Volume{
479495
Path: k,

internal/generator/generator_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/nginx-proxy/docker-gen/internal/config"
1919
"github.com/nginx-proxy/docker-gen/internal/context"
2020
"github.com/nginx-proxy/docker-gen/internal/dockerclient"
21+
"github.com/stretchr/testify/assert"
2122
)
2223

2324
func TestGenerateFromEvents(t *testing.T) {
@@ -216,3 +217,32 @@ func TestGenerateFromEvents(t *testing.T) {
216217
}
217218
}
218219
}
220+
221+
func TestSortNetworks(t *testing.T) {
222+
for _, tc := range []struct {
223+
desc string
224+
in []context.Network
225+
want []context.Network
226+
}{
227+
{
228+
desc: "multiple unsorted",
229+
in: []context.Network{{Name: "frontend"}, {Name: "bridge"}, {Name: "app_net"}},
230+
want: []context.Network{{Name: "app_net"}, {Name: "bridge"}, {Name: "frontend"}},
231+
},
232+
{
233+
desc: "single element",
234+
in: []context.Network{{Name: "bridge"}},
235+
want: []context.Network{{Name: "bridge"}},
236+
},
237+
{
238+
desc: "empty",
239+
in: []context.Network{},
240+
want: []context.Network{},
241+
},
242+
} {
243+
t.Run(tc.desc, func(t *testing.T) {
244+
sortNetworks(tc.in)
245+
assert.Equal(t, tc.want, tc.in)
246+
})
247+
}
248+
}

0 commit comments

Comments
 (0)