diff --git a/.github/workflows/devstack.yml b/.github/workflows/devstack.yml index c80f28e..b4ba3b6 100644 --- a/.github/workflows/devstack.yml +++ b/.github/workflows/devstack.yml @@ -70,6 +70,16 @@ jobs: run: ./stack.sh working-directory: ./devstack + - name: Fix Designate root_helper + # root_helper defaults to "sudo designate-rootwrap ..." in designate/conf/base.py. + # designate-rootwrap lives in the devstack venv (/opt/stack/data/venv/bin) which + # is not in sudo's secure_path -> every rndc addzone fails -> zones PENDING->ERROR. + # Insert the absolute path immediately after [DEFAULT] so oslo.config picks it up. + run: | + sudo sed -i '/^\[DEFAULT\]/a root_helper = sudo /opt/stack/data/venv/bin/designate-rootwrap /etc/designate/rootwrap.conf' \ + /etc/designate/designate.conf + shell: bash + - name: Restart Designate run: sudo systemctl restart "devstack@designate-*.service" @@ -79,7 +89,7 @@ jobs: - name: Wait for zone creation run: | - while [ "$(openstack zone list -f csv | grep PENDING)" != "" ]; do date; openstack zone list -f value; sleep 1; done + while [ "$(openstack zone list -f value --status PENDING)" != "" ]; do date; openstack zone list -f value; sleep 1; done - name: Start external-dns-openstack-webhook in background run: | @@ -94,7 +104,7 @@ jobs: - name: Wait for PENDING run: | - while [ "$(openstack zone list -f csv | grep PENDING)" != "" ]; do date; openstack zone list -f value; sleep 1; done + while [ "$(openstack zone list -f value --status PENDING)" != "" ]; do date; openstack zone list -f value; sleep 1; done - name: Show created entries run: | @@ -107,3 +117,47 @@ jobs: run: | if [ $(openstack recordset list all -f value | grep -c " TXT ") -ne 10 ]; then exit 1; fi if [ $(openstack recordset list all -f value | grep -c " A ") -ne 10 ]; then exit 2; fi + + - name: Create cloud_admin project and grant admin access + run: | + openstack --os-cloud devstack-admin project create cloud_admin + openstack --os-cloud devstack-admin role add --project cloud_admin --user admin admin + shell: bash + + - name: Add devstack-cloud-admin cloud entry (admin user scoped to cloud_admin project) + run: | + AUTH_URL=$(python3 -c "import yaml; print(yaml.safe_load(open('/etc/openstack/clouds.yaml'))['clouds']['devstack-admin']['auth']['auth_url'])") + ./devstack/tools/update_clouds_yaml.py \ + --file /etc/openstack/clouds.yaml \ + --os-cloud devstack-cloud-admin \ + --os-region-name RegionOne \ + --os-auth-url "$AUTH_URL" \ + --os-username admin \ + --os-password secret \ + --os-project-name cloud_admin + shell: bash + + - name: Create zone all-projects.com owned by demo project + run: | + openstack zone create --email admin@example.com all-projects.com. + + - name: Wait for all-projects.com zone creation + run: | + while [ "$(openstack zone list -f value --status PENDING)" != "" ]; do date; openstack zone list -f value; sleep 1; done + + - name: Start external-dns-openstack-webhook as cloud_admin with --all-projects on port 8889 + run: | + OS_CLOUD=devstack-cloud-admin ./build/bin/external-dns-openstack-webhook --all-projects --domain-filter all-projects.com --webhook-server-address 127.0.0.1:8889 --status-server-address 0.0.0.0:8081 >/tmp/external-dns-openstack-webhook-all-projects.log 2>&1 & + for i in $(seq 30); do curl -sf http://127.0.0.1:8889 > /dev/null 2>&1 && break; sleep 1; done + curl -sf http://127.0.0.1:8889 > /dev/null || { cat /tmp/external-dns-openstack-webhook-all-projects.log; exit 1; } + + - name: Run external-dns against all-projects.com as cloud_admin project user + run: ./build/external-dns --txt-owner-id all-projects-cluster-id --provider webhook --webhook-provider-url http://127.0.0.1:8889 --source fake --fqdn-template=all-projects.com --log-level=debug --once 2>&1 + working-directory: ./external-dns + + - name: Show /tmp/external-dns-openstack-webhook-all-projects.log + run: cat /tmp/external-dns-openstack-webhook-all-projects.log + + - name: Check all-projects created entries in all-projects.com + run: | + if [ $(openstack --os-cloud devstack-admin recordset list all-projects.com. --all-projects -f value | grep -c "all-projects-cluster-id") -ne 10 ]; then exit 1; fi \ No newline at end of file diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 04b701b..fd84741 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -16,13 +16,19 @@ import ( ) const ( - webhookServerAddr = "127.0.0.1:8888" - statusServerAddr = "0.0.0.0:8080" + defaultWebhookServerAddr = "127.0.0.1:8888" + defaultStatusServerAddr = "0.0.0.0:8080" ) func main() { var domainFilters []string + var allProjects bool + var webhookServerAddr string + var statusServerAddr string pflag.StringArrayVar(&domainFilters, "domain-filter", []string{}, "List of domains to work on (can be specified multiple times)") + pflag.BoolVar(&allProjects, "all-projects", false, "Manage all zones and recordsets regardless of the project the service user resides in (requires an admin or appropriately scoped token)") + pflag.StringVar(&webhookServerAddr, "webhook-server-address", defaultWebhookServerAddr, "Address to bind the webhook server to") + pflag.StringVar(&statusServerAddr, "status-server-address", defaultStatusServerAddr, "Address to bind the status and metrics server to") pflag.Parse() log.SetLevel(log.DebugLevel) @@ -61,7 +67,7 @@ func main() { }() epf := endpoint.NewDomainFilter(domainFilters) - dp, err := provider.NewDesignateProvider(*epf, false) + dp, err := provider.NewDesignateProvider(*epf, allProjects, false) if err != nil { log.Fatalf("NewDesignateProvider: %v", err) metrics.OpenstackConnectionMetric.Set(0) diff --git a/go.mod b/go.mod index be70506..0efce80 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/gophercloud/gophercloud/v2 v2.13.0 + github.com/gophercloud/gophercloud/v2 v2.14.0 github.com/gophercloud/utils/v2 v2.0.0-20260424064311-2eeed4ceb3e9 github.com/prometheus/client_golang v1.24.1 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 6c48e67..fbfc023 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= -github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= +github.com/gophercloud/gophercloud/v2 v2.14.0 h1:xGxKCvyaOxJDc5FqrnKDNqtdYn43ocQPuJ2Cm4KX/cs= +github.com/gophercloud/gophercloud/v2 v2.14.0/go.mod h1:4fs5I9VH6Wg2LyocDL9xf0ASb8VD63tyLA8sgAX/69U= github.com/gophercloud/utils/v2 v2.0.0-20260424064311-2eeed4ceb3e9 h1:WEPhYFzYmpfWHq+YPaP3+8pYf4wKQuJMkgPiiI4g7CY= github.com/gophercloud/utils/v2 v2.0.0-20260424064311-2eeed4ceb3e9/go.mod h1:bIEH+wgvnxfegUewFuGi0u/L+ji5uiEuVVQMNKQASEY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/internal/designate/client/client.go b/internal/designate/client/client.go index 092d54a..ab09a2f 100644 --- a/internal/designate/client/client.go +++ b/internal/designate/client/client.go @@ -57,15 +57,16 @@ type DesignateClientInterface interface { // implementation of the DesignateClientInterface type designateClient struct { serviceClient *gophercloud.ServiceClient + allProjects bool } // factory function for the DesignateClientInterface -func NewDesignateClient() (DesignateClientInterface, error) { +func NewDesignateClient(allProjects bool) (DesignateClientInterface, error) { serviceClient, err := createDesignateServiceClient() if err != nil { return nil, err } - return &designateClient{serviceClient}, nil + return &designateClient{serviceClient: serviceClient, allProjects: allProjects}, nil } // authenticate in OpenStack and obtain Designate service endpoint @@ -136,10 +137,10 @@ func (c designateClient) ForEachZone(ctx context.Context, filters []string, hand var err error if len(filters) == 0 { - err = doList(zones.ListOpts{}) + err = doList(zones.ListOpts{AllProjects: c.allProjects}) } else { for _, f := range filters { - if err = doList(zones.ListOpts{Name: f + "."}); err != nil { + if err = doList(zones.ListOpts{Name: f + ".", AllProjects: c.allProjects}); err != nil { break } } @@ -162,7 +163,7 @@ func (c designateClient) ForEachZone(ctx context.Context, filters []string, hand func (c designateClient) ForEachRecordSet(ctx context.Context, zoneID string, handler func(recordSet *recordsets.RecordSet) error) error { startTime := time.Now() - pager := recordsets.ListByZone(c.serviceClient, zoneID, recordsets.ListOpts{}) + pager := recordsets.ListByZone(c.serviceClient, zoneID, recordsets.ListOpts{AllProjects: c.allProjects}) var pageCount int var recordCount int @@ -209,6 +210,7 @@ func (c designateClient) CreateRecordSet(ctx context.Context, zoneID string, opt log.Debugf("→ Creating recordset: %s (%s) with %d targets", opts.Name, opts.Type, len(opts.Records)) + opts.AllProjects = c.allProjects r, err := recordsets.Create(ctx, c.serviceClient, zoneID, opts).Extract() duration := time.Since(startTime) @@ -235,6 +237,7 @@ func (c designateClient) UpdateRecordSet(ctx context.Context, zoneID, recordSetI } log.Debugf("→ Updating recordset: %s with %d targets", recordSetID, recordCount) + opts.AllProjects = c.allProjects _, err := recordsets.Update(ctx, c.serviceClient, zoneID, recordSetID, opts).Extract() duration := time.Since(startTime) @@ -257,7 +260,7 @@ func (c designateClient) DeleteRecordSet(ctx context.Context, zoneID, recordSetI log.Debugf("→ Deleting recordset: %s", recordSetID) - err := recordsets.Delete(ctx, c.serviceClient, zoneID, recordSetID).ExtractErr() + err := recordsets.DeleteWithOpts(ctx, c.serviceClient, zoneID, recordSetID, recordsets.DeleteOpts{AllProjects: c.allProjects}).ExtractErr() duration := time.Since(startTime) metrics.ApiCallLatency.WithLabelValues("DeleteRecordSet").Observe(duration.Seconds()) diff --git a/internal/designate/provider/provider.go b/internal/designate/provider/provider.go index bb7a488..f8fbd85 100644 --- a/internal/designate/provider/provider.go +++ b/internal/designate/provider/provider.go @@ -56,8 +56,8 @@ type designateProvider struct { } // NewDesignateProvider is a factory function for OpenStack designate providers -func NewDesignateProvider(domainFilter endpoint.DomainFilter, dryRun bool) (provider.Provider, error) { - client, err := client.NewDesignateClient() +func NewDesignateProvider(domainFilter endpoint.DomainFilter, allProjects, dryRun bool) (provider.Provider, error) { + client, err := client.NewDesignateClient(allProjects) if err != nil { return nil, err } diff --git a/internal/designate/provider/provider_test.go b/internal/designate/provider/provider_test.go index b3c65d2..da9dd0c 100644 --- a/internal/designate/provider/provider_test.go +++ b/internal/designate/provider/provider_test.go @@ -262,7 +262,7 @@ clouds: os.Setenv("OS_CLOUD", "unittest") os.Setenv("OS_CACERT", tmpfile.Name()) - if _, err := NewDesignateProvider(endpoint.DomainFilter{}, true); err != nil { + if _, err := NewDesignateProvider(endpoint.DomainFilter{}, false, true); err != nil { t.Fatalf("Failed to initialize Designate provider: %s", err) } }