Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for static DNS mappings #2129

Merged
merged 3 commits into from
Jun 3, 2022
Merged

Conversation

cbgbt
Copy link
Contributor

@cbgbt cbgbt commented May 4, 2022

Issue number: #2045

Description of changes:

  • Implement models and settings changes
  • Write migrations for new settings
This allows users to populate /etc/hosts with static DNS entries via
settings and the Bottlerocket API. Bottlerocket doesn't currently
perform resolver caching, so there is no need to restart networked
services after rendering a new /etc/hosts file.

According to man hosts:

Before the advent of DNS, the host table was the only way of resolving hostnames on the fledgling Internet.

Today, this file is still used to configure a static lookup table for hostnames on Linux. The hosts file is a text file that associates IP addresses (v4 or v6) with hostnames, one line per IP address.

IP_address canonical_hostname [aliases...]

e.g.

10.0.0.1 myhost.example.com myhost

Would ensure that myhost.example.com and myhost both resolve to 10.0.0.1. The man page explicitly calls out the first element as being the canonical_hostname, but there are no technical requirements that differentiate this to aliases — each element must:

  • Contain only alphanumeric characters, minus signs (“-”) and periods (“.”)
  • Begin with an alphabetic character
  • End with an alphanumeric character

The modeled settings enforce these requirements.

Per #921, we don't support DNS caching on Bottlerocket, so we shouldn't need to restart any additional services when we modify static DNS mappings.

Testing done:

  • Test on aws-ecs-1
  • Test on aws-k8s-1.22
  • Test migrations

Example of using the feature:

[ec2-user@admin]$ sudo sheltie
bash-5.1# cat /etc/hosts
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 


bash-5.1# apiclient set -j '{"settings": {"network": {"hosts": [["10.0.0.1", ["test.example.com"]], ["127.0.0.1", ["test.bottlerocket.aws"]], ["10.0.0.1", ["test2.example.com"]]]}}}'
bash-5.1# apiclient get settings.network.hosts
{
  "settings": {
    "network": {
      "hosts": [
        [
          "10.0.0.1",
          [
            "test.example.com"
          ]
        ],
        [
          "127.0.0.1",
          [
            "test.bottlerocket.aws"
          ]
        ],
        [
          "10.0.0.1",
          [
            "test2.example.com"
          ]
        ]
      ]
    }
  }
}
bash-5.1# cat /etc/hosts
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 test.bottlerocket.aws
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 

10.0.0.1 test.example.com test2.example.com

Terms of contribution:

By submitting this pull request, I agree that this contribution is dual-licensed under the terms of both the Apache License, version 2.0, and the MIT license.

@zmrow
Copy link
Contributor

zmrow commented May 5, 2022

LGTM so far! Once the migrations are out I will give it another look-see

@cbgbt cbgbt force-pushed the staticdns branch 3 times, most recently from 351f82a to 920e6b2 Compare May 5, 2022 23:53
@cbgbt
Copy link
Contributor Author

cbgbt commented May 5, 2022

  • Added README documentation
  • Use BTreeMap to store host entries to preserve ordering

@cbgbt cbgbt marked this pull request as ready for review May 5, 2022 23:54
@cbgbt
Copy link
Contributor Author

cbgbt commented May 6, 2022

I'm going to make a few changes to the /etc/hosts template, documentation, and settings name based on some verbal feedback from the team. Should have revision up shortly 😄

@cbgbt cbgbt marked this pull request as draft May 6, 2022 21:03
@cbgbt
Copy link
Contributor Author

cbgbt commented May 17, 2022

A few changes:

  • Setting name changed to settings.network.hosts.
  • Modified /etc/hosts such that it can't contain duplicate line entries for host addresses

@cbgbt cbgbt force-pushed the staticdns branch 4 times, most recently from c504f46 to 1b9f780 Compare May 17, 2022 18:13
@cbgbt cbgbt marked this pull request as ready for review May 17, 2022 18:14
@cbgbt
Copy link
Contributor Author

cbgbt commented May 17, 2022

I keep ending up having strange conflicts with develop. Will fix the git tree.

@cbgbt
Copy link
Contributor Author

cbgbt commented May 17, 2022

Fixed, as well as fixed some code clarity issues.

Comment on lines 273 to 276
// Ordering matters in /etc/hosts if multiple domains point to the same IP, so we use BTreeMap to preserve order.
hosts: BTreeMap<IpAddr, Vec<ValidLinuxHostname>>,
https_proxy: Url,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been doing some extra reading about BTreeMap and my assumption that it preserves iteration order based on insertion is false: BTreeMap preserves iteration order based on key order. The common solution in the Rust community is to use IndexMap for a map which preserves insertion order.

This also means that I would need to assert that every serializer and deserializer which touches settings objects respects ordering. Fortunately for serde_json and toml this can be enabled by adding a feature flag, but we have a few points e.g. in migrations where helpers assume that a Map can be safely represented via a HashMap.

I'm looking into solving this either by building in order preservation to settings (which could be extremely difficult to continuously enforce in the Bottlerocket repo, even with great testing in the core settings path) or by changing our model here to use an ordered data structure, although this adds some uncomfortable ergonomic questions over how to use the feature.

@cbgbt cbgbt force-pushed the staticdns branch 2 times, most recently from aa83b3b to ad263da Compare May 19, 2022 18:25
@cbgbt
Copy link
Contributor Author

cbgbt commented May 19, 2022

I reworked the static DNS feature to use a Vec<(IpAddr, Vec<ValidLinuxHostname>)> as its internal representation, then added a helper method to merge IP address lines together (though this doesn't dedupe hostnames).

While it does make it a bit less ergonomic to specify mappings due to the use of lists to represent pairs, it safely models the behavior of /etc/hosts.

@cbgbt
Copy link
Contributor Author

cbgbt commented May 19, 2022

Force-pushed to rebase atop develop.

Comment on lines 1069 to 1080
// If our hostname isn't resolveable, add it to the alias list.
if hostname.len() > 0 && !hostname_resolveable(hostname) {
results.push(hostname.to_owned());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's an edge case here where the hostname isn't initially resolvable, but should resolve to a non-localhost IP provided in settings.network.hosts, after /etc/hosts is populated.

In that case, we'd incorrectly write /etc/hosts with the hostname as an alias for localhost.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! I'll fix that and see if I can't add a unit test to cover that edge case.

Comment on lines 1160 to 1164
// Cast aside anything that doesn't parse as an IP Address, or any references to localhost.
*ip_address != IPV4_LOCALHOST && *ip_address != IPV6_LOCALHOST
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are we casting aside anything that doesn't parse as an IP address?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, holdover comment. This used to be much more "stringly typed" but I ended up routing all of the parsing through the models -- much cleaner now.

@cbgbt
Copy link
Contributor Author

cbgbt commented May 24, 2022

Force-pushed to address comments by @bcressey.

Added a unit test for the edge case. Additional testing of that change: https://gist.github.com/cbgbt/d0e7789fe3a9a12e2b59e59b6f2f0278

@bcressey
Copy link
Contributor

LGTM once cargo fmt is happy.

@cbgbt
Copy link
Contributor Author

cbgbt commented May 25, 2022

Force-push to fix cargo-fmt issue.

let template_name = template_name(renderctx);
trace!("Template name: {}", &template_name);

// Check number of parameters, must be exactly one
// Check number of parameters, must be exactly two (IP version, hostname, hosts overrides)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should the number be three?

Comment on lines 1130 to 1127
// Check number of parameters, must be exactly two (IP version, hostname, hosts overrides)
trace!("Number of params: {}", helper.params().len());
check_param_count(helper, template_name, 1)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: another inconsistent count?

@@ -1128,6 +1267,41 @@ fn kube_cpu_helper(num_cores: usize) -> Result<String, TemplateHelperError> {
))
}

/// Returns whether or not a hostname resolves to a non-loopback IP address.
///
/// If `configured_hosts` is Some, the hostname will be considered resolvable if it is listed as an alias for any given IP address.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
/// If `configured_hosts` is Some, the hostname will be considered resolvable if it is listed as an alias for any given IP address.
/// If `configured_hosts` is set, the hostname will be considered resolvable if it is listed as an alias for any given IP address.

#[test]
fn hostname_resolves_to_static_mapping() {
// Given a configured hostname that does not resolve in DNS
// and an /etc/hosts configuration which points that contains that hostname as an alias to an IP address,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think there are extra words here? Should the line be:

Suggested change
// and an /etc/hosts configuration which points that contains that hostname as an alias to an IP address,
// and an /etc/hosts configuration that contains that hostname as an alias to an IP address,


#[test]
fn test_valid_etc_hosts_entries() {
assert!(serde_json::from_str::<EtcHostsEntries>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't all these tests be part of the same assert? Same question similar tests below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to logically separate different checks into multiple assert lines here, so if one fails it's easier to reason about what behavior the check was testing for.

README.md Outdated
Comment on lines 532 to 547
Repeated entries are merged (including loopback entries), with the first aliases listed taking precedence. e.g.:

```toml
[settings.network]
hosts = [
["10.0.0.0", ["test.example.com", "test1.example.com"]],
["10.1.1.1", ["test2.example.com"]],
["10.1.1.0", ["test3.example.com"]],
]
```
Would result in `/etc/hosts` entries like so:
```
10.0.0.0 test.example.com test1.example.com test3.example.com
10.1.1.1 test2.example.com
```

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi I'm trying to validate if the current logic will work for our use-case.

  1. Why in the example test3.example.com (10.1.1.0) is merged with the 10.0.0.0 line and thus effectively rewritten to 10.0.0.0?

  2. Is it possible with the current logic to have one hostname to resolve to multiple IPs? The input syntax seems to support it. Just want to make sure no de-duplication will happen in this case. We will require a final /etc/hosts file that looks something like this:

10.0.2.252 test1.example.com
10.0.2.236 test1.example.com
10.0.3.205 test1.example.com

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for taking a look at this PR.

  1. This is a documentation mistake, I meant for 10.1.1.0 to be a repeat of 10.0.0.0. I'll fix that.
  2. Yes, you can have one hostname resolve to multiple IPs. De-duplication will only occur for IPs, not hostnames. If you submit something like the following, you should end up with /etc/hosts entries like the ones you've given:
hosts = [
    ["10.0.2.252", ["test1.example.com"]],
    ["10.0.2.236", ["test1.example.com"]],
    ["10.0.3.205", ["test1.example.com]]
]

@cbgbt
Copy link
Contributor Author

cbgbt commented May 26, 2022

Force push to address comments from @arnaldo2792

@cbgbt
Copy link
Contributor Author

cbgbt commented May 26, 2022

Additional push to address README issue pointed out by @fgutmann. Thanks!

Copy link
Contributor

@webern webern left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm cursed with the ability to always see a way to unit test. Otherwise this looks great.

README.md Outdated
@@ -502,11 +502,27 @@ In addition to the container runtime daemons, these credential settings will als

* `settings.network.hostname`: The desired hostname of the system.
**Important note for all Kubernetes variants:** Changing this setting at runtime (not via user data) can cause issues with kubelet registration, as hostname is closely tied to the identity of the system for both registration and certificates/authorization purposes.
* `settings.network.hosts`: A mapping of IP addresses to domain names which should resolve to those IP addresses.
This setting results in modifications to the `/etc/hosts` file for Bottlerocket.
Note that this setting does not typically impact name resolution for containers, which usually rely on [orchestrator-specific](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_HostEntry.html) [mechanisms](https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/) for configuring static resolution.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a suggestion...

Suggested change
Note that this setting does not typically impact name resolution for containers, which usually rely on [orchestrator-specific](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_HostEntry.html) [mechanisms](https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/) for configuring static resolution.
Note that this setting does not typically impact name resolution for containers, which usually rely on orchestrator-specific mechanisms for configuring static resolution.
(See [ECS](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_HostEntry.html) and [Kubernetes](https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/) documentation for those mechanisms.)

README.md Outdated
Comment on lines 519 to 550
Entries which point to the loopback address are merged as aliases with the lowest precedence on the existing loopback line.



Most users don't need to change this setting as the following defaults work for the majority of use cases.
If this setting isn't set we attempt to use DNS reverse lookup for the hostname.
If the lookup is unsuccessful, the IP of the node is used.

##### Proxy settings
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: newline crimes were committed

Entries which point to the loopback address are merged as aliases with the lowest precedence on the existing loopback line.

Most users don't need to change this setting as the following defaults work for the majority of use cases.
If this setting isn't set we attempt to use DNS reverse lookup for the hostname.
If the lookup is unsuccessful, the IP of the node is used.

##### Proxy settings

let template_name = template_name(renderctx);
trace!("Template name: {}", &template_name);

// Check number of parameters, must be exactly one
// Check number of parameters, must be exactly two (IP version, hostname, hosts overrides)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Check number of parameters, must be exactly two (IP version, hostname, hosts overrides)
// Check number of parameters, must be exactly three (IP version, hostname, hosts overrides)

.param(1)
.map(|v| v.value())
.context(error::InternalSnafu {
msg: "Missing params after confirming that they exist.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe since we use this same message all over we could add ParamUnwrapSnafu variant with this message baked in and eliminate the strings from the code.

            msg: "Missing params after confirming that they exist.",

Comment on lines 1077 to 1081
if let Ok(ip_address) = ip_address.parse::<IpAddr>() {
ip_address == localhost_comparator
} else {
false
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Golfable if desired

Suggested change
if let Ok(ip_address) = ip_address.parse::<IpAddr>() {
ip_address == localhost_comparator
} else {
false
}
ip_address.parse::<IpAddr>().map(|ip_address| ip_address == localhost_comparator).unwrap_or_default()

Comment on lines 1143 to 1146
if hosts_value.is_null() {
// If hosts aren't set, just exit.
Ok(())
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a stylistic preference, but I would return here and unindent the "else" code.

Suggested change
if hosts_value.is_null() {
// If hosts aren't set, just exit.
Ok(())
} else {
if hosts_value.is_null() {
// If hosts aren't set, just exit.
return Ok(())
}

trace!("Hosts from template: {:?}", hosts);

// If our hostname isn't resolveable, add it to the alias list.
if hostname.len() > 0 && !hostname_resolveable(hostname, hosts.as_ref()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to think of a way to remove this impurity from this otherwise testable (and complex) function. Alas, I don't see a way to inject this network dependency.

Edit, well... as usual I did think of a way:

localhost_aliases could be a thin wrapper for localhost_aliases_impl.

localhost_aliases_impl takes a functor matching the signature of hostname_resolvable, then localhost_aliases becomes:

pub fn localhost_aliases(
    helper: &Helper<'_, '_>,
    h: &Handlebars,
    c: &Context,
    renderctx: &mut RenderContext<'_, '_>,
    out: &mut dyn Output,
) -> Result<(), RenderError> {
    localhost_aliases_impl(helper, h, c, renderctx, out, &hostname_resolvabe)
}

Then I think you could write tests for localhost_aliases_impl that takes a mock of the hostname_resolvable function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love function purity. Thanks for the tip, I'll implement this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I toyed around with implementing something like this, but it ended up making it more challenging to understand and didn't necessarily improve testability, since there are a few tests here already which just use more apparently resolve-able or un-resolve-able hostnames.

.append(&mut (aliases.clone()));
}

merged.into_iter()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤯

Nice rust.

@cbgbt cbgbt force-pushed the staticdns branch 2 times, most recently from fd7577f to d6ab9ae Compare June 2, 2022 13:01
@cbgbt
Copy link
Contributor Author

cbgbt commented Jun 2, 2022

Force push to make some stylistic and comment changes suggested by @webern

If this setting isn't set we attempt to use DNS reverse lookup for the hostname.
If the lookup is unsuccessful, the IP of the node is used.

* `settings.network.hosts`: A mapping of IP addresses to domain names which should resolve to those IP addresses.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explain what the default is when this setting is not set? Or maybe it's obvious enough, and it would be too verbose.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be obvious enough to omit. I noticed that we don't always include the default value, for settings as well.

cbgbt added 3 commits June 3, 2022 00:20
This allows users to populate /etc/hosts with static DNS entries via
settings and the Bottlerocket API. Bottlerocket doesn't currently
perform resolver caching, so there is no need to restart networked
services after rendering a new /etc/hosts file.
@cbgbt
Copy link
Contributor Author

cbgbt commented Jun 3, 2022

Force push to rebase.

@cbgbt cbgbt merged commit 2a06e2d into bottlerocket-os:develop Jun 3, 2022
@cbgbt cbgbt deleted the staticdns branch August 15, 2023 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants