Implement ddns feature in dns-policy - #475
weiiwang01 wants to merge 4 commits into
Conversation
cdc67e8 to
c0281d1
Compare
yhaliaw
left a comment
There was a problem hiding this comment.
Major changes needed
🤝 Human review with AI assistance.
| if not entries: | ||
| logger.debug("Reconciliation: no entry to publish upstream") | ||
| return | ||
| self._publish_upstream(entries) |
There was a problem hiding this comment.
The new if not entries: return guard sits on the combined approved-requests + ddns list, whereas the pre-PR code guarded on the requirer-requested entries and then unconditionally published approved_requests — even when empty.
This means a withdrawal is silently dropped: a requirer submits a record request, it's approved and published; the admin later denies/deletes it. On the next reconcile the relation still exists (so the if not relations guard at line 163 passes), get_approved_requests returns [], ddns is disabled, entries == [], and the function returns before calling _publish_upstream. Bind keeps serving the withdrawn record indefinitely. _clear_ddns_domains only clears the ddns-domain field on the provider relation, so it masks this rather than fixing it — the requirer-facing upstream relation is what goes stale.
Drop the early return and always call _publish_upstream(entries) so an empty list withdraws everything, same as a non-empty one publishes it. Worth adding a unit test for the non-empty → empty transition that asserts on the dns-record-requirer local app databag rather than just the provider-side ddns-domain field.
🤖 AI-assisted
There was a problem hiding this comment.
Thank you for spotting the issue, fixed, thanks!
0229b78 to
567e301
Compare
| logger.error("No domain allocated for the relation %s", relation.id) | ||
| continue | ||
|
|
||
| domain = f"{label}.{ddns_domain}" |
There was a problem hiding this comment.
The size of ddns_domain is validated ensuring it's not more than 253 chars, but adding the prefix and dot may go over 253.
Shouldn't we validate the full domain ?
There was a problem hiding this comment.
Yes, you are right. The current label length is 8 characters. I will limit the ddns-domain configuration to be less than 200 characters for future-proofing.
| pytest.param("localhost", ["localhost"], id="already-allowed"), | ||
| pytest.param("", ["localhost"], id="empty"), |
There was a problem hiding this comment.
I'm curious how is it passing while DNS_POLICY_API_HOST is setting 127.0.0.1 and not localhost ?
There was a problem hiding this comment.
Yes, the unit test is failing, I forget to update the unit test after changing the host, updated, thanks!
| raise ApiError(str(e)) from e | ||
|
|
||
| try: | ||
| labels[relation_id] = str(req.json()["label"]) |
There was a problem hiding this comment.
I think we should be stricted as null, collections will be accepted as labels. numbers too even if I don't thinks it's a pb
There was a problem hiding this comment.
Added a more strict check, thanks!
| @pytest.mark.abort_on_fail | ||
| def test_ddns_domain_is_allocated( | ||
| juju: jubilant.Juju, | ||
| dns_policy_name: str, | ||
| ddns_deployment, # pylint: disable=unused-argument | ||
| ): | ||
| """ | ||
| arrange: deploy the charms and set the ddns-domain configuration. | ||
| act: integrate a requirer on the dns-record-provider endpoint. | ||
| assert: the requirer is handed a domain under the configured suffix. | ||
| """ | ||
| domain = _wait_for_ddns_domain(juju, f"{dns_policy_name}/0", published=True) | ||
|
|
||
| assert domain is not None | ||
| _, _, suffix = domain.partition(".") | ||
| assert suffix == DDNS_DOMAIN | ||
|
|
||
|
|
||
| @pytest.mark.abort_on_fail | ||
| def test_ddns_domain_is_resolvable( | ||
| juju: jubilant.Juju, | ||
| bind_name: str, | ||
| dns_policy_name: str, | ||
| dns_integrator_name: str, | ||
| ddns_deployment, # pylint: disable=unused-argument | ||
| ): | ||
| """ | ||
| arrange: deploy the charms and let a requirer be allocated a domain. | ||
| act: resolve that domain, and one of its subdomains, against the DNS provider. | ||
| assert: both resolve to the address of the requirer. | ||
| """ | ||
| domain = _published_ddns_domain(juju, f"{dns_policy_name}/0") | ||
| assert domain is not None | ||
| address = _requirer_ingress_address(juju, f"{dns_policy_name}/0", f"{dns_integrator_name}/0") | ||
| bind_address = juju.status().get_units(bind_name)[f"{bind_name}/0"].public_address | ||
|
|
||
| assert _resolve(bind_address, domain) == [address] | ||
| assert _resolve(bind_address, f"anything.{domain}") == [address] | ||
|
|
||
|
|
||
| @pytest.mark.abort_on_fail | ||
| def test_ddns_domain_is_stable( | ||
| juju: jubilant.Juju, | ||
| dns_policy_name: str, | ||
| ddns_deployment, # pylint: disable=unused-argument | ||
| ): | ||
| """ | ||
| arrange: deploy the charms and let a requirer be allocated a domain. | ||
| act: reconfigure the charm. | ||
| assert: the requirer keeps the domain it was allocated. | ||
| """ | ||
| domain = _published_ddns_domain(juju, f"{dns_policy_name}/0") | ||
|
|
||
| juju.config(dns_policy_name, {"debug": True}) | ||
| juju.wait( | ||
| lambda status: jubilant.all_active(status, dns_policy_name), | ||
| error=jubilant.any_error, | ||
| ) | ||
| time.sleep(120) # let the reconciliation timer tick a couple of times | ||
|
|
||
| assert _published_ddns_domain(juju, f"{dns_policy_name}/0") == domain | ||
|
|
||
|
|
||
| @pytest.mark.abort_on_fail | ||
| def test_invalid_ddns_domain_blocks_the_charm( | ||
| juju: jubilant.Juju, | ||
| dns_policy_name: str, | ||
| ddns_deployment, # pylint: disable=unused-argument | ||
| ): | ||
| """ | ||
| arrange: deploy the charms and let a requirer be allocated a domain. | ||
| act: set an invalid ddns-domain configuration. | ||
| assert: the charm blocks and keeps the domain it already allocated. | ||
| """ | ||
| domain = _published_ddns_domain(juju, f"{dns_policy_name}/0") | ||
|
|
||
| juju.config(dns_policy_name, {"ddns-domain": "not a domain"}) | ||
| juju.wait(lambda status: jubilant.all_blocked(status, dns_policy_name)) | ||
| time.sleep(120) # let the reconciliation timer tick a couple of times | ||
|
|
||
| assert _published_ddns_domain(juju, f"{dns_policy_name}/0") == domain |
There was a problem hiding this comment.
These tests seems to rely on their order, someone making changes may break them. This should at least be explicit
There was a problem hiding this comment.
Yes, that's a good idea, use a parametrized fixture for configuration instead, thanks!
| """Allocate the label of the automatically allocated domain of a relation.""" | ||
| permission_classes = [permissions.IsAuthenticated] | ||
|
|
||
| def get(self, request, instance, relation_id): |
There was a problem hiding this comment.
Since it's mutating the data shouldn't it be a PUT ?
There was a problem hiding this comment.
This is a get-or-create function that will create the label if the corresponding label doesn't exist. The charm will also use this exact endpoint to get the corresponding labels. I am still undecided on whether or not this should be a GET or POST/PUT; I feel like both are valid.
| ): | ||
| """Integrate a requirer with the dns-policy charm and enable the ddns feature.""" | ||
| if not _workload_supports_ddns(juju, f"{dns_policy_name}/0"): | ||
| pytest.skip("The charmed-dns-policy snap has no ddns allocation API yet") |
There was a problem hiding this comment.
Shouldn't it be an xfail in case of 404 ? The skip will lead to succesful test suite despite the tests not been executed.
Also I'd reverse the logic of _workload_supports_ddns and explicitely look for 2XX
Because a 500 will be considered successful
There was a problem hiding this comment.
I have updated the test to inject the dns-policy charm into the charm, so we don't need to wait for the next pull request to test it. This is no longer needed and removed, thanks!
| except subprocess.CalledProcessError as exc: | ||
| raise OSError(f"Error packing charm: {exc}; Stderr:\n{exc.stderr}") from None | ||
|
|
||
| charms = [p.absolute() for p in directory.glob(f"{app_name}_*.charm")] |
There was a problem hiding this comment.
nit: If there's a pre-existing .charm (staled by a removed architecture for example) this will select it. Can't we get the artifacts produced ?
There was a problem hiding this comment.
Updated the code to remove stale charms before build. Will convert this repository to charm-ci in the future to prevent this kind of issues. Thanks!
| default: "0.0.0.0" | ||
| ddns-domain: | ||
| type: string | ||
| description: > |
There was a problem hiding this comment.
Should specify that it takes precedence and that ingress-address is used as fallback
There was a problem hiding this comment.
I think the field that uses ingress-address as default is the ddns-addresses field in the relation data, not the ddns-domain configuration used for the ddns-domain suffix.
567e301 to
1bb179f
Compare
What this PR does
Implementing the ddns feature in the dns-policy charm. As agreed in the spec, the ddns feature should be implemented at the dns-policy level first.
A new charm configuration called
ddns-domainis added to the dns-policy charm. When set, the ddns feature is enabled in the dns-policy charm. The ddns domain allocation is done in the dns-policy application and stored inside the database to ensure uniqueness across all dns requirers.Why we need it
Checklist
docs/changelog.mdwith user-relevant changes(e.g., in
.github/workflows/integration_tests.yaml, ensure themoduleslist is correct)terraform fmtpasses andtflintreports no errors