diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 823ddfc..549cf29 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1508,6 +1508,12 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner, if r.Network != "" { args = append(args, "--network", r.Network) } + for _, d := range r.Dns { + args = append(args, "--dns", d) + } + if r.IP != "" { + args = append(args, "--ip", r.IP) + } args = append(args, "--label", specLabel+"="+want, "--label", idLabel+"="+r.ID) for _, k := range sortedKeys(r.Env) { diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go index 9c46dcc..4075ff2 100644 --- a/internal/apply/vocabulary_test.go +++ b/internal/apply/vocabulary_test.go @@ -403,3 +403,35 @@ func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) { } } } + +// A container may name its resolvers and its own address — the shape a module shipping its own +// validating DNS needs: the resolver pinned where its siblings can find it, the siblings pointed +// at it. Both flags take addresses, so both reach the runtime verbatim. +func TestAContainerIsGivenItsResolverAndItsAddress(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + if len(args) > 0 && args[0] == "container" { + return "", fmt.Errorf("no such container") + } + return "", nil + } + d := declare(t, `{"id":"imap","type":"container","name":"mailu-imap",`+ + `"image":"dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+ + `"network":"mailu","dns":["192.168.203.254"],"ip":"192.168.203.7"}`) + + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, run, nil, nil) + + var started string + for _, line := range ran { + if strings.Contains(line, "run ") { + started = line + } + } + for _, want := range []string{"--dns 192.168.203.254", "--ip 192.168.203.7"} { + if !strings.Contains(started, want) { + t.Errorf("the container was started without %q:\n%s", want, started) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 8c99e76..21b3b0c 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -11,10 +11,12 @@ import ( "encoding/json" "fmt" "io" + "net" "reflect" "regexp" "slices" "sort" + "strconv" "strings" ) @@ -807,6 +809,23 @@ type Container struct { // worse failure mode. Network string `json:"network,omitempty"` + // Dns is the resolvers this container asks, passed to the runtime unchanged. + // + // **Because some software refuses to run behind the runtime's forwarding resolver.** A mail + // server's admin demands a DNSSEC-validating resolver, and the runtime's own (127.0.0.11) + // forwards to whatever the machine has — so a module that ships its own validating resolver + // must be able to point its other containers at it. Addresses, not names: the runtime's flag + // takes only addresses, which is also why IP below exists — the resolver has to be somewhere + // its siblings can name before any of them can resolve anything. + Dns []string `json:"dns,omitempty"` + + // IP is this container's address on its network, passed to the runtime unchanged. + // + // Only meaningful on a user-defined network, and refused by the runtime elsewhere. Exists for + // exactly one shape: a container others must reach *before* name resolution works — a + // module's own DNS resolver being the case that forced it (see Dns). + IP string `json:"ip,omitempty"` + // RestartOn names resources whose change means this container must be recreated — the same // field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a // mounted file once at start; a changed file leaves the running process holding the old value, @@ -875,6 +894,23 @@ func (c *Container) validate(where string, _ bool) []string { problems = append(problems, where+": "+err.Error()) } } + // The runtime's flags take addresses, and a name here would be handed to it verbatim and + // refused at create — after the old container was already removed. Refused on arrival instead. + for _, d := range c.Dns { + if net.ParseIP(d) == nil { + problems = append(problems, where+": dns "+strconv.Quote(d)+" is not an address; "+ + "the runtime's resolver flag takes only addresses") + } + } + if c.IP != "" { + if net.ParseIP(c.IP) == nil { + problems = append(problems, where+": ip "+strconv.Quote(c.IP)+" is not an address") + } + if c.Network == "" { + problems = append(problems, where+": an ip needs a network; the runtime refuses a "+ + "static address anywhere but a user-defined one") + } + } return append(problems, checkImage(where, c.Image)...) } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index d0823e0..bb4e7fa 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -427,3 +427,27 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { t.Fatal("a secret the content never mentions was accepted") } } + +// The runtime's resolver and address flags take only addresses; a name would be refused at +// create, after the old container was already gone. Refused on arrival instead — and an address +// without a user-defined network is refused for the same reason. +func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { + refused := func(body string) []string { + _, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`)) + if err == nil { + return nil + } + return []string{err.Error()} + } + base := `"id":"c","type":"container","name":"x",` + + `"image":"a@sha256:0000000000000000000000000000000000000000000000000000000000000000"` + if p := refused(`{` + base + `,"network":"m","dns":["resolver.local"]}`); len(p) == 0 { + t.Error("a resolver named by name was accepted; the runtime takes only addresses") + } + if p := refused(`{` + base + `,"ip":"192.168.203.7"}`); len(p) == 0 { + t.Error("a static address with no network was accepted; the runtime refuses it") + } + if p := refused(`{` + base + `,"network":"m","dns":["192.168.203.254"],"ip":"192.168.203.7"}`); len(p) != 0 { + t.Errorf("a well-formed resolver and address were refused: %v", p) + } +}