Skip to content

Commit

Permalink
Merge branch 'release/v1.6.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
qdii committed Feb 5, 2024
2 parents 9a31b39 + 1893d29 commit e4826c4
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 31 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ This script requires the following permissions on `/domain/zone/myzone.fr`: GET
to fetch the current records and compare them with the intent, POST to create
new records and DELETE to remove records.

## Hermetic environment

### Building

[Bazel](http://bazel.build) is used to build and test the application in a
hermetic, sandboxed environment.

The following command will download the environment and build a cross-platform
python binary which executes within this environment.

```shell
$ bazel build //src:ovh_reconciler
```

### Testing

To run all unit tests, use the following command:

```shell
$ bazel test //src:ovh_reconciler_test
```

## Flags

- `--input`: Can be either a path towards a file containing the source of truth
Expand Down
6 changes: 3 additions & 3 deletions WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
# At the time of writing the most recent version is python 3.11.
http_archive(
name = "rules_python",
sha256 = "0a8003b044294d7840ac7d9d73eef05d6ceb682d7516781a4ec62eeb34702578",
strip_prefix = "rules_python-0.24.0",
url = "https://github.com/bazelbuild/rules_python/archive/refs/tags/0.24.0.tar.gz",
sha256 = "5868e73107a8e85d8f323806e60cad7283f34b32163ea6ff1020cf27abef6036",
strip_prefix = "rules_python-0.25.0",
url = "https://github.com/bazelbuild/rules_python/archive/refs/tags/0.25.0.tar.gz",
)
load("@rules_python//python:repositories.bzl", "py_repositories")
py_repositories()
Expand Down
7 changes: 7 additions & 0 deletions src/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ py_binary(
srcs=["ovh_reconciler.py"],
deps=[
requirement("absl-py"),
requirement("certifi"),
requirement("charset-normalizer"),
requirement("idna"),
requirement("ovh"),
requirement("parameterized"),
requirement("requests"),
requirement("urllib3"),
],
)

Expand Down
16 changes: 16 additions & 0 deletions src/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
v1.6.0
- new --default_ttl flag to set TTL when unspecified in the records.

v1.5.0
- Improve logging
- Fix bug where modifications were not applied

v1.4.0
- Allow setting a TTL to A, AAAA, TXT and CNAME records.

v1.3.0
- Allow passing empty subdomains in A, AAAA, TXT records.

v1.2.0
- Fix incorrect parsing when no subdomain was specified in CNAME records.

v1.1.0
- Support TXT records

Expand Down
91 changes: 72 additions & 19 deletions src/ovh_reconciler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import fileinput
import ovh
import re
from typing import Dict, NamedTuple, Set
from typing import NamedTuple, Set
from enum import Enum
from absl import app
from absl import flags
Expand Down Expand Up @@ -43,18 +43,23 @@
'dry_run', False,
'If True, no records are created or deleted.')

_DEFAULT_TTL = flags.DEFINE_integer(
'default_ttl', 0,
'The default ttl to use if not in the indicated in the record row.')

# TODO: This accepts invalid IPs, such as 999.999.999.999. Make it stricter.
RE_IPV4 = r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
RE_IPV6 = r'(([a-f0-9:]+:+)+[a-f0-9]+)'
RE_IPV4 = r'(?P<ipv4>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
RE_IPV6 = r'(?P<ipv6>([a-f0-9:]+:+)+[a-f0-9]+)'
# This regex matches either a double-quote delimited string, or the same
# but wrapped inside parenthesis.
RE_TXT = r'(?:"(?P<txt1>[^"]*)"|\(\s*"(?P<txt2>[^"]*)"\s*\))'
RE_SUBDOMAIN = r'([-.@|a-zA-Z0-9_]*)'
RE_RECORD_A = r'^\s*' + RE_SUBDOMAIN + r'\s*IN\s+A\s+' + RE_IPV4 + r'\s*$'
RE_RECORD_AAAA = r'^\s*' + RE_SUBDOMAIN + r'\s*IN\s+AAAA\s+' + RE_IPV6 + r'\s*$'
RE_RECORD_CNAME = r'^\s*' + RE_SUBDOMAIN + r'\s+IN\s+CNAME\s+' + RE_SUBDOMAIN + r'\s*$' # pylint: disable=line-too-long
RE_RECORD_TXT = r'^\s*' + RE_SUBDOMAIN + r'\s+IN\s+TXT\s+' + RE_TXT + r'\s*$'
RE_TTL = r'(?P<ttl>[0-9]*)'
RE_SUBDOMAIN = r'(?P<subdomain>[-.@|a-zA-Z0-9_]*)'
RE_TARGET = r'(?P<target>[-.@|a-zA-Z0-9_]*)'
RE_RECORD_A = r'^\s*' + RE_SUBDOMAIN + r'\s*' + RE_TTL + r'\s*IN\s+A\s+' + RE_IPV4 + r'\s*$'
RE_RECORD_AAAA = r'^\s*' + RE_SUBDOMAIN + r'\s*' + RE_TTL + r'\s*IN\s+AAAA\s+' + RE_IPV6 + r'\s*$'
RE_RECORD_CNAME = r'^\s*' + RE_SUBDOMAIN + r'\s*' + RE_TTL + r'\s+IN\s+CNAME\s+' + RE_TARGET + r'\s*$' # pylint: disable=line-too-long
RE_RECORD_TXT = r'^\s*' + RE_SUBDOMAIN + r'\s*' + RE_TTL + r'\s+IN\s+TXT\s+' + RE_TXT + r'\s*$'


class Type(Enum):
Expand Down Expand Up @@ -104,9 +109,14 @@ class Record(NamedTuple):
# then this field is 0.
id: int

# The time-to-live of the DNS record. 0 means no caching. None means the
# TTL is not send to OVH API.
ttl: int | None

def __str__(self) -> str:
"""A printable representation of the object."""
return f'({self.type.name}, {self.subdomain} -> {self.target})'
ttl = f' ({self.ttl})' if self.ttl else ''
return f'({self.type.name}, {self.subdomain}{ttl} -> {self.target})'

def __eq__(self, other):
"""Whether two objects are the same. Needed when comparing sets."""
Expand All @@ -116,11 +126,13 @@ def __eq__(self, other):
return False
if self.target != other.target:
return False
if self.ttl and other.ttl and self.ttl != other.ttl:
return False
return True

def __hash__(self):
"""Whether two objects are the same. Needed when comparing sets."""
return hash((self.type, self.subdomain, self.target))
return hash((self.type, self.subdomain, self.target, self.ttl or 0))


def parse_a_record(line: str) -> Record | None:
Expand All @@ -134,10 +146,14 @@ def parse_a_record(line: str) -> Record | None:
result = re.fullmatch(RE_RECORD_A, line, re.MULTILINE)
if not result:
return None
ttl = result.group('ttl') or _DEFAULT_TTL.value
if ttl:
ttl = int(ttl)
return Record(
type=Type.A,
subdomain=result[1],
target=result[2] or '',
subdomain=result.group('subdomain'),
target=result.group('ipv4'),
ttl=ttl,
id=0)


Expand All @@ -152,10 +168,14 @@ def parse_aaaa_record(line: str) -> Record | None:
result = re.fullmatch(RE_RECORD_AAAA, line, re.MULTILINE)
if not result:
return None
ttl = result.group('ttl') or _DEFAULT_TTL.value
if ttl:
ttl = int(ttl)
return Record(
type=Type.AAAA,
subdomain=result[1] or '',
target=result[2],
subdomain=result.group('subdomain'),
target=result.group('ipv6'),
ttl=ttl,
id=0)


Expand All @@ -170,11 +190,15 @@ def parse_txt_record(line: str) -> Record | None:
result = re.fullmatch(RE_RECORD_TXT, line, re.MULTILINE)
if not result:
return None
ttl = result.group('ttl') or _DEFAULT_TTL.value
if ttl:
ttl = int(ttl)
target = (result.group('txt1') or '') + (result.group('txt2') or '')
return Record(
type=Type.TXT,
subdomain=result[1] or '',
subdomain=result.group('subdomain'),
target=target,
ttl=ttl,
id=0)


Expand All @@ -189,8 +213,11 @@ def parse_cname_record(line: str) -> Record | None:
result = re.fullmatch(RE_RECORD_CNAME, line, re.MULTILINE)
if not result:
return None
subdomain = result[1]
target = result[2]
subdomain = result.group('subdomain')
target = result.group('target')
ttl = result.group('ttl') or _DEFAULT_TTL.value
if ttl:
ttl = int(ttl)
# Catch mistake where CNAME points to an IP address.
if any([re.fullmatch(RE_IPV4, subdomain, re.M),
re.fullmatch(RE_IPV6, subdomain, re.M),
Expand All @@ -201,6 +228,7 @@ def parse_cname_record(line: str) -> Record | None:
type=Type.CNAME,
subdomain=subdomain,
target=target,
ttl=ttl,
id=0)


Expand All @@ -222,7 +250,7 @@ def fetch_records(record_type: Type, client: ovh.Client) -> Set[Record]:
record_ids = client.get(
f'/domain/zone/{_DNS_ZONE.value}/record',
fieldType=record_type.name)
logging.debug('Found %d records of type %s for zone %s.',
logging.info('Fetched %d records of type %s for zone %s.',
len(record_ids), record_type.name, _DNS_ZONE.value)
records = set()
for id in record_ids:
Expand All @@ -231,8 +259,9 @@ def fetch_records(record_type: Type, client: ovh.Client) -> Set[Record]:
type=record_type,
subdomain=d['subDomain'],
target=d['target'],
ttl=d['ttl'],
id=id)
logging.info('Found record: %s', r)
logging.debug('Fetched record [%d]: %s', id, r)
records.add(r)
return records

Expand All @@ -246,6 +275,7 @@ def add_record(record: Record, client: ovh.Client) -> int:
record = client.post(f'/domain/zone/{_DNS_ZONE.value}/record',
fieldType=record.type.name,
subDomain=record.subdomain,
ttl=record.ttl,
target=record.target)
return record['id']

Expand All @@ -261,6 +291,11 @@ def delete_record(record: Record, client: ovh.Client) -> None:
def parse_input() -> Set[Record]:
records = set()
i = 0
records_per_type = {}
for type in ALLOWED_TYPES:
records_per_type[type] = []

# Parsed each line of the file.
with fileinput.FileInput(files=_INPUT.value) as f:
for line in f:
i += 1
Expand All @@ -270,6 +305,16 @@ def parse_input() -> Set[Record]:
continue
logging.debug('Parsed line %d: %s', i, record)
records.add(record)
records_per_type[record.type].append(record)

# Print out debug information.
for type in ALLOWED_TYPES:
logging.info('Parsed %d records of type %s.',
len(records_per_type[type]), type.name)

for r in records_per_type[type]:
logging.debug('Parsed record: %s', r)

return records


Expand All @@ -286,6 +331,13 @@ def reconcile(intent: Set[Record], current: Set[Record], client: ovh.Client):
delete_record(r, client)


def apply(client: ovh.Client):
if _DRY_RUN.value:
return
logging.info('Applying modifications.')
client.post(f'/domain/zone/{_DNS_ZONE.value}/refresh')


def main(unused_argv):
client = ovh.Client(
endpoint=_ENDPOINT.value,
Expand All @@ -300,6 +352,7 @@ def main(unused_argv):
current = current.union(fetch_records(type, client))
logging.info('Reconciling intent and reality')
reconcile(intent, current, client)
apply(client)


if __name__ == '__main__':
Expand Down
Loading

0 comments on commit e4826c4

Please sign in to comment.