Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config/components/admission-webhooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ apply. Hand-authoring the MWC in infra decoupled those versions and caused a
production write outage when `failurePolicy: Fail` hit a build with nothing
listening on `:9443` (see [#69](https://github.com/datum-cloud/dns-operator/issues/69)).

`failurePolicy` is `Fail`. With co-versioned registration, a missing webhook
should block DNSRecordSet writes rather than admit without activity
annotations (Ignore has no self-recovery for the audit event that already
fired). Always apply this path from the same OCI tag as the manager
Deployment.

This directory is **not** included in `config/default` or the replicator
overlay. Same-cluster packaging for kind/e2e stays under `config/webhook/`.

## Contents

| Kind | Name | Notes |
| ---- | ---- | ----- |
| `MutatingWebhookConfiguration` | `dns-operator-mutating-webhook-configuration` | `failurePolicy: Ignore`; Service ref is `dns-operator-webhook-service` / `datum-dns-system` |
| `MutatingWebhookConfiguration` | `dns-operator-mutating-webhook-configuration` | `failurePolicy: Fail`; Service ref is `dns-operator-webhook-service` / `datum-dns-system` |

No Service and no kustomize `namespace` transformer: Flux `targetNamespace`
must not rewrite `clientConfig.service.namespace`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# clientConfig points at the webhook Service on the infra/deployment cluster.
# Do not add kustomize nameReference/namespace transformers here: Flux
# targetNamespace would rewrite the Service namespace and break cross-cluster
# lookup. failurePolicy is Ignore so a missing webhook server degrades
# activity FQDNs instead of blocking all DNSRecordSet writes.
# lookup. failurePolicy is Fail so a missing webhook does not silently
# skip activity annotations (no self-recovery for the audit event). The
# MWC must ship with the same OCI tag as the manager that serves it.
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
Expand All @@ -21,7 +22,7 @@ webhooks:
namespace: datum-dns-system
path: /mutate-dns-networking-miloapis-com-v1alpha1-dnsrecordset
port: 443
failurePolicy: Ignore
failurePolicy: Fail
matchPolicy: Equivalent
sideEffects: None
timeoutSeconds: 10
Expand Down
88 changes: 88 additions & 0 deletions config/milo/activity/policies/dnsrecordset-policy.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ webhooks:
name: webhook-service
namespace: system
path: /mutate-dns-networking-miloapis-com-v1alpha1-dnsrecordset
failurePolicy: Ignore
failurePolicy: Fail
name: mdnsrecordset.kb.io
rules:
- apiGroups:
Expand Down
12 changes: 9 additions & 3 deletions docs/enhancements/activity-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Status**: Implemented (Phase 1–2; UX polish for operator-readable summaries in issue #62)
**Author**: Engineering
**Created**: 2026-02-12
**Updated**: 2026-07-23
**Updated**: 2026-07-29

## Summary

Expand Down Expand Up @@ -101,10 +101,15 @@ The Activity Service translates audit logs and Kubernetes events into human-read
|------------|---------|--------|
| `dns.networking.miloapis.com/display-name` | `www.example.com` | Mutating webhook at create/update; replicator safety net |
| `dns.networking.miloapis.com/display-value` | `192.0.2.10` | Same |
| `dns.networking.miloapis.com/activity-change` | `added` / `removed` / `updated` | Mutating webhook on update when `records[]` differs from OldObject (issue #72) |
| `dns.networking.miloapis.com/activity-name` | `app.example.com` | Hostname(s) that changed (FQDN) |
| `dns.networking.miloapis.com/activity-value` | `192.0.2.10` | Value(s) for the changed hostname(s) |

Helpers live in `internal/display`. The mutating webhook resolves the parent `DNSZone` on the project control plane (cluster-aware admission context); if the zone is missing or cluster resolution fails, annotations are left unset and the replicator acts as a safety net. Policy fallbacks use `spec.records[0].name`.

Admission uses `failurePolicy: Ignore` so a missing webhook server degrades activity FQDNs instead of blocking DNSRecordSet writes. Cross-cluster registration for Datum control planes ships in `config/components/admission-webhooks` (OCI path `components/admission-webhooks`), versioned with the manager image (see that directory's README). Same-cluster kind/e2e packaging stays under `config/webhook/`.
Portal UX often keeps one `DNSRecordSet` per zone per type and edits hostnames inside `spec.records[]`. Adding or removing a hostname is therefore a Kubernetes update. Activity update rules prefer `activity-*` annotations so the timeline says **added** / **deleted** for that hostname instead of **updated** with a joined sibling list. When `activity-change` is absent (older images), rules fall back to `display-name` / `display-value`.

Admission uses `failurePolicy: Fail` so a missing webhook blocks DNSRecordSet writes instead of admitting without activity annotations (those audits do not self-heal). Cross-cluster registration for Datum control planes ships in `config/components/admission-webhooks` (OCI path `components/admission-webhooks`), versioned with the same manager image tag (see that directory's README). Same-cluster kind/e2e packaging stays under `config/webhook/`.

### Data Sources

Expand Down Expand Up @@ -236,7 +241,8 @@ We use ActivityPolicy + Events for activity generation. A **mutating** webhook i

## Open Questions

1. How should we handle bulk operations (e.g., many records updated at once)?
1. How should we handle bulk operations (e.g., many records updated at once)? Mixed add+remove in one write is summarized as `updated` with affected names; pure adds/removes use `added`/`removed` via `activity-*` annotations.
2. Should activities include namespace information for multi-tenant visibility?
3. How should we format multiple record values (e.g., multiple A records for the same name)?
4. Portal activity *detail* views may still emphasize resource ids; summary link text is the FQDN — follow up in the portal if detail prominence is still weak.
5. Longer-term: portal may choose one DNSRecordSet per hostname so create/delete map 1:1; activity-* annotations remain useful until then.
102 changes: 102 additions & 0 deletions internal/activitypolicy/dnsrecordset_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ func TestDNSRecordSetPolicy_Structure(t *testing.T) {
t.Errorf("update-a-aaaa must include A/AAAA recordType in summary, got: %s", updateA)
}

for _, name := range []string{
"update-activity-added-a-aaaa",
"update-activity-removed-a-aaaa",
"update-activity-updated-a-aaaa",
} {
m, ok := byName[name]
if !ok {
t.Fatalf("missing rule %q", name)
}
if !strings.Contains(m, "activity-change") {
t.Errorf("%s must match activity-change annotation, got: %s", name, m)
}
if !strings.Contains(m, "has(audit.requestObject.spec)") {
t.Errorf("%s must require requestObject.spec", name)
}
}
addedSummary := summaries["update-activity-added-a-aaaa"]
if !strings.Contains(addedSummary, "added") || !strings.Contains(addedSummary, "activity-name") {
t.Errorf("update-activity-added-a-aaaa must say added with activity-name, got: %s", addedSummary)
}
removedSummary := summaries["update-activity-removed-a-aaaa"]
if !strings.Contains(removedSummary, "deleted") || !strings.Contains(removedSummary, "activity-name") {
t.Errorf("update-activity-removed-a-aaaa must say deleted with activity-name, got: %s", removedSummary)
}
// Activity rules must appear before display-name update fallbacks.
addedIdx, updateAIdx := -1, -1
for i, r := range pol.Spec.AuditRules {
switch r.Name {
case "update-activity-added-a-aaaa":
addedIdx = i
case "update-a-aaaa":
updateAIdx = i
}
}
if addedIdx < 0 || updateAIdx < 0 || addedIdx >= updateAIdx {
t.Errorf("update-activity-added-a-aaaa (idx %d) must precede update-a-aaaa (idx %d)", addedIdx, updateAIdx)
}

createCNAME := summaries["create-cname"]
if !strings.Contains(createCNAME, "CNAME record") {
t.Errorf("create-cname must name the record type, got: %s", createCNAME)
Expand Down Expand Up @@ -251,6 +289,70 @@ func TestDNSRecordSetPolicy_CELMatchFixtures(t *testing.T) {
},
},
},
{
name: "add hostname on multi-name A object (#72)",
wantRule: "update-activity-added-a-aaaa",
audit: map[string]any{
"user": map[string]any{"username": "dgaghan@datum.net"},
"verb": "patch",
"objectRef": map[string]any{},
"requestObject": map[string]any{
"spec": map[string]any{
"records": []any{
map[string]any{"name": "www", "a": map[string]any{"content": "192.168.1.1"}},
map[string]any{"name": "app", "a": map[string]any{"content": "192.168.1.1"}},
},
},
},
"responseObject": map[string]any{
"metadata": map[string]any{"annotations": map[string]any{
"dns.networking.miloapis.com/display-name": "www.dodik.me, app.dodik.me",
"dns.networking.miloapis.com/display-value": "192.168.1.1, 192.168.1.1",
"dns.networking.miloapis.com/activity-change": "added",
"dns.networking.miloapis.com/activity-name": "app.dodik.me",
"dns.networking.miloapis.com/activity-value": "192.168.1.1",
}},
"spec": map[string]any{
"recordType": "A",
"records": []any{
map[string]any{"name": "www", "a": map[string]any{"content": "192.168.1.1"}},
map[string]any{"name": "app", "a": map[string]any{"content": "192.168.1.1"}},
},
},
},
},
},
{
name: "remove hostname on multi-name A object (#72)",
wantRule: "update-activity-removed-a-aaaa",
audit: map[string]any{
"user": map[string]any{"username": "dgaghan@datum.net"},
"verb": "patch",
"objectRef": map[string]any{},
"requestObject": map[string]any{
"spec": map[string]any{
"records": []any{
map[string]any{"name": "www", "a": map[string]any{"content": "192.168.1.1"}},
},
},
},
"responseObject": map[string]any{
"metadata": map[string]any{"annotations": map[string]any{
"dns.networking.miloapis.com/display-name": "www.dodik.me",
"dns.networking.miloapis.com/display-value": "192.168.1.1",
"dns.networking.miloapis.com/activity-change": "removed",
"dns.networking.miloapis.com/activity-name": "app.dodik.me",
"dns.networking.miloapis.com/activity-value": "192.168.1.1",
}},
"spec": map[string]any{
"recordType": "A",
"records": []any{
map[string]any{"name": "www", "a": map[string]any{"content": "192.168.1.1"}},
},
},
},
},
},
{
name: "human delete with annotations",
wantRule: "delete-annotated",
Expand Down
Loading
Loading