Writing a provider plugin¶
A Provider plugin is a Drupal plugin that integrates one external
DNS service (ClouDNS, Route 53, Cloudflare, …) with the
dns_external framework. The plugin manager discovers plugins via
the #[\Drupal\dns_external\Attribute\Provider] attribute on classes
under any module's Plugin/Provider/.
This page walks through the contract, the value-object normalization rules, and the capability model.
Module layout¶
A provider integration is its own Drupal module — dns_cloudns,
dns_route53, etc. The framework lives in dns_external; concrete
providers ship separately so each can pull in its own SDK / API
client / Composer dependencies without polluting the framework's
dependency surface.
dns_cloudns/
├── dns_cloudns.info.yml
├── composer.json # SDK dependency, peer-dep on dns_external
└── src/
└── Plugin/
└── Provider/
└── ClouDnsProvider.php
Declare dns_external as a hard dependency in info.yml:
dependencies:
- dns_external:dns_external
The contract¶
Every provider plugin implements
\Drupal\dns_external\Plugin\Provider\ProviderInterface (extend
ProviderBase in practice — it stubs every optional method with a
sensible default or a loud LogicException for undeclared
operations).
The contract is grouped:
// 1. Identity & capabilities.
public function capabilities(): array;
public function zoneTypes(): array;
public function label(): TranslatableMarkup;
// 2. Configuration.
public function buildSettingsForm(...): array;
public function validateSettingsForm(...): void;
public function submitSettingsForm(...): void;
public function buildZoneTypeForm(...): array;
// 3. Discovery / import.
public function listRemoteZones(ProviderConfigInterface $config): iterable;
public function fetchZone(ProviderConfigInterface $config, string $remote_id): RemoteZone;
public function fetchRecords(ProviderConfigInterface $config, string $remote_id): iterable;
// 4. Export / push.
public function pushZone(...): string;
public function pushZoneUpdate(...): void;
public function pushZoneDelete(...): void;
public function pushRecord(...): string;
public function pushRecordUpdate(...): void;
public function pushRecordDelete(...): void;
Implement only the groups you actually support — declare the matching
Capability cases, and ProviderBase's LogicException defaults
will catch any wiring mistake immediately.
Declaring capabilities¶
capabilities() is the source of truth for what the admin UI shows.
Return a subset of
\Drupal\dns_external\Provider\Capability::cases():
use Drupal\dns_external\Provider\Capability;
public function capabilities(): array {
return [Capability::Import, Capability::Export];
}
Cases:
Capability::Import— discover, fetch zones, fetch records.Capability::Export— push zones, push records (create + update + delete).Capability::ZoneTypes— provider has per-zone variants (master/slave/geo/parked);zoneTypes()andbuildZoneTypeForm()are required.Capability::Sync— reserved for the bidirectional-sync pipeline shipping later. Don't claim it yet.
RemoteZone / RemoteRecord — the normalization contract¶
Provider plugins never hand provider-specific API responses back to the framework. Discovery and fetch return value objects:
\Drupal\dns_external\Provider\RemoteZone\Drupal\dns_external\Provider\RemoteRecord
The importer consumes only these objects, so a foreign API's representation never leaks into the import pipeline. The class docblocks have the full normalization rules; the high points:
RemoteZone¶
| Field | Rule |
|---|---|
remoteId |
The provider's identifier for this zone. |
name |
Lowercase Punycode (ACE) form. Use idn_to_ascii() if the upstream returns Unicode. |
data |
Free-form bag round-tripped into the binding's provider_data JSON. Zone type for providers with Capability::ZoneTypes, geo-routing settings, etc. |
RemoteRecord¶
| Field | Rule |
|---|---|
remoteId |
The provider's identifier for the record. |
type |
Uppercase DNS token (A, AAAA, MX, …). The importer skip-and-logs types it doesn't natively support — pass through, don't filter. |
prefix |
Empty string for the zone apex. Not @, not the zone name, not a trailing dot. Lowercased per RFC 1035 §2.3.3. |
ttl |
Integer seconds. Use 3600 if the provider doesn't surface one. |
ipAddress |
Plain IPv4/IPv6 string for A/AAAA only; NULL otherwise. |
target |
BIND zone-file form for CNAME/MX/NS/PTR/SRV/HTTPS/SVCB/DNAME. Trailing dot for absolute (mail.example.com.), no dot for relative (www), literal @ for the zone apex. |
rdata |
Per the record-type plugin's contract (see table below). |
rdata keys¶
MX → ['priority' => int]
SRV → ['priority' => int, 'weight' => int, 'port' => int]
TXT → ['content' => string]
CAA → ['tag' => string, 'value' => string, 'critical' => bool]
HTTPS → ['priority' => int, 'params' => array<string, mixed>]
SVCB → same as HTTPS
(A / AAAA / CNAME / NS / PTR carry no rdata.)
Plugins for the dns_extras family (DNAME, SSHFP, TLSA, SMIMEA,
OPENPGPKEY, DS, DNSKEY) fill in the relevant payload keys per
HashRecordTypeBase's contract.
Settings form — secrets via Key, identifiers in plain config¶
The settings form contributes whatever fields your plugin needs.
Secret material — passwords, API tokens, anything whose leak
would constitute a credential compromise — must never live in the
provider_config settings blob; reference a Key entity by id and
resolve it at API call time.
Non-secret identifiers (account ids, sub-user usernames, region codes, endpoint URLs) belong in plain config. Wrapping a username in a Key entity adds friction without protecting anything.
The ClouDNS plugin is the canonical example: the auth-id (a
ClouDNS account-level numeric id or a sub-user identifier — not
secret) is a plain textfield setting, and the auth-password (the
only actual secret) is the Key reference.
public function buildSettingsForm(array $form, FormStateInterface $form_state, ProviderConfigInterface $config): array {
$elements = [];
// Plain identifier — not secret.
$elements['auth_id'] = [
'#type' => 'textfield',
'#title' => $this->t('Auth-id'),
'#default_value' => (string) $config->getSetting('auth_id', ''),
'#required' => TRUE,
];
// Secret — Key reference.
$elements['api_password_key'] = [
'#type' => 'key_select',
'#title' => $this->t('API password Key'),
'#default_value' => $config->getSetting('api_password_key', ''),
'#required' => TRUE,
];
return $elements;
}
At runtime:
$auth_id = (string) $config->getSetting('auth_id');
$key = \Drupal::service('key.repository')->getKey((string) $config->getSetting('api_password_key'));
$secret = $key->getKeyValue();
Push semantics — what the framework expects¶
The pusher walks records in stable order and, per record:
- If the record has no
provider_remote_id→ callspushRecord(), stamps the returned id back on the local entity. - If the record has a
provider_remote_id→ callspushRecordUpdate(). Don't create duplicates — if the remote id is stale, throw, and the framework will record it as a per- record failure.
pushZone() is called once per binding, on the first push (when
provider_remote_id on the binding is empty). Subsequent pushes
treat the remote zone as already-existing and only touch records.
pushZoneUpdate() is reserved for an admin-driven re-push of zone-
level settings — the v1 push UI doesn't call it; provider modules can
expose a separate action if they need to.
Per-record failures are non-fatal at the zone level — throw your
exception and the pusher records it in PushResult::$failures so the
admin sees the count and the per-record reason. A re-run picks up
where the failure left off.
Reference: dns_cloudns¶
The first concrete provider lives in its own module, dns_cloudns. It
exercises the full contract — discovery, import, push, plus the four
ClouDNS zone types via Capability::ZoneTypes — and is the
recommended template for new integrations. See the
dns_cloudns project page
for the latest version.
Test fixture¶
dns_external ships a FixtureProvider plugin under
tests/modules/dns_external_test/ that returns canned data driven by
Drupal state — pre-seed
state->set('dns_external_test.zones', [...]) and
state->set('dns_external_test.records', [...]) to exercise importer
or pusher paths in your own tests without standing up a real provider
account.