Skip to content

Commit 92c209a

Browse files
authored
fix: SA authentication and change how errors in requests are handled (#79)
* fix: add token URL parameter to SetConfigOptions for SA authentication * fix: enforce strict execution order for DNS changes to prevent orphaned records and mitigate quota issues * add test, fix lint, add new option to documentation
1 parent 454e18e commit 92c209a

6 files changed

Lines changed: 159 additions & 40 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ Below are the options that are available.
231231
- `--auth-key-path`/`AUTH_KEY_PATH` (required): Defines the file path of the service account key for the STACKIT API.
232232
Prefer using a Kubernetes Secret mounted as a file and set `AUTH_KEY_PATH` to the in-container path
233233
(e.g. `/var/run/secrets/stackit/sa.json`).
234+
- `--token-url`/`TOKEN_URL` (optional): Specifies alternative URL for authentication with service account key (default "https://service-account.api.stackit.cloud/token").
234235
- `--worker`/`WORKER` (optional): Specifies the number of workers to employ for querying the API. Given that we
235236
need to iterate over all zones and records, it can be parallelized. However, it is important to avoid
236237
setting this number excessively high to prevent receiving 429 rate limiting from the API (default 10).

cmd/webhook/cmd/root.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ var (
2222
apiPort string
2323
authBearerToken string
2424
authKeyPath string
25+
tokenUrl string
2526
baseUrl string
2627
projectID string
2728
worker int
@@ -45,7 +46,7 @@ var rootCmd = &cobra.Command{
4546

4647
endpointDomainFilter := endpoint.DomainFilter{Filters: domainFilter}
4748

48-
stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath)
49+
stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath, tokenUrl)
4950
if err != nil {
5051
panic(err)
5152
}
@@ -76,9 +77,8 @@ var rootCmd = &cobra.Command{
7677

7778
func getLogger() *zap.Logger {
7879
cfg := zap.Config{
79-
Level: zap.NewAtomicLevelAt(getZapLogLevel()),
80-
Encoding: "json", // or "console"
81-
// ... other zap configuration as needed
80+
Level: zap.NewAtomicLevelAt(getZapLogLevel()),
81+
Encoding: "json",
8282
OutputPaths: []string{"stdout"},
8383
ErrorOutputPaths: []string{"stderr"},
8484
}
@@ -116,12 +116,10 @@ func init() {
116116
rootCmd.PersistentFlags().StringVar(&apiPort, "api-port", "8888", "Specifies the port to listen on.")
117117
rootCmd.PersistentFlags().StringVar(&authBearerToken, "auth-token", "", "Defines the authentication token for the STACKIT API. Mutually exclusive with 'auth-key-path'.")
118118
rootCmd.PersistentFlags().StringVar(&authKeyPath, "auth-key-path", "", "Defines the file path of the service account key for the STACKIT API. Mutually exclusive with 'auth-token'.")
119+
rootCmd.PersistentFlags().StringVar(&tokenUrl, "token-url", "", "Defines the authentication token endpoint for the STACKIT API.")
119120
rootCmd.PersistentFlags().StringVar(&baseUrl, "base-url", "https://dns.api.stackit.cloud", " Identifies the Base URL for utilizing the API.")
120121
rootCmd.PersistentFlags().StringVar(&projectID, "project-id", "", "Specifies the project id of the STACKIT project.")
121-
rootCmd.PersistentFlags().IntVar(&worker, "worker", 10, "Specifies the number "+
122-
"of workers to employ for querying the API. Given that we need to iterate over all zones and "+
123-
"records, it can be parallelized. However, it is important to avoid setting this number "+
124-
"excessively high to prevent receiving 429 rate limiting from the API.")
122+
rootCmd.PersistentFlags().IntVar(&worker, "worker", 10, "Specifies the number of workers to employ for querying the API. Given that we need to iterate over all zones and records, it can be parallelized. However, it is important to avoid setting this number excessively high to prevent receiving 429 rate limiting from the API.")
125123
rootCmd.PersistentFlags().StringArrayVar(&domainFilter, "domain-filter", []string{}, "Establishes a filter for DNS zone names")
126124
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Specifies whether to perform a dry run.")
127125
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Specifies the log level. Possible values are: debug, info, warn, error")

internal/stackitprovider/apply_changes.go

Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package stackitprovider
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"sync"
78

@@ -11,31 +12,72 @@ import (
1112
"sigs.k8s.io/external-dns/plan"
1213
)
1314

14-
// ApplyChanges applies a given set of changes in a given zone.
15+
// ApplyChanges applies a given set of DNS changes to the STACKIT DNS API.
16+
// It enforces a strict phase-based execution order to prevent orphaned records
17+
// and mitigate quota limit issues (e.g., max 10k records per zone).
18+
// Deletions are processed before creations to free up zone quota.
1519
func (d *StackitDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
16-
// Preallocate to avoid repeated growth (prealloc)
17-
totalTasks := len(changes.Create) + len(changes.UpdateNew) + len(changes.Delete)
18-
tasks := make([]changeTask, 0, totalTasks)
19-
20-
// create rr set. POST /v1/projects/{projectId}/zones/{zoneId}/rrsets
21-
tasks = append(tasks, d.buildRRSetTasks(changes.Create, CREATE)...)
22-
// update rr set. PATCH /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId}
23-
tasks = append(tasks, d.buildRRSetTasks(changes.UpdateNew, UPDATE)...)
20+
if len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete) == 0 {
21+
return nil
22+
}
2423

2524
d.logger.Info("records to delete", zap.String("records", fmt.Sprintf("%v", changes.Delete)))
2625

27-
// delete rr set. DELETE /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId}
28-
tasks = append(tasks, d.buildRRSetTasks(changes.Delete, DELETE)...)
29-
3026
zones, err := d.zoneFetcherClient.zones(ctx)
3127
if err != nil {
3228
return err
3329
}
3430

35-
return d.handleRRSetWithWorkers(ctx, tasks, zones)
31+
// Separate ownership records (TXT) from target records (A, CNAME, etc.)
32+
// to enforce strict dependency ordering and prevent orphaned records.
33+
deleteTXT, deleteOther := splitTXTAndOther(changes.Delete)
34+
updateTXT, updateOther := splitTXTAndOther(changes.UpdateNew)
35+
createTXT, createOther := splitTXTAndOther(changes.Create)
36+
37+
// Execution order is critical.
38+
// 1. Delete targets first, then their TXT ownership records.
39+
// 2. Update TXT ownerships, then targets.
40+
// 3. Create TXT ownerships first, then create targets.
41+
batches := [][]changeTask{
42+
d.buildRRSetTasks(deleteOther, DELETE),
43+
d.buildRRSetTasks(deleteTXT, DELETE),
44+
d.buildRRSetTasks(updateTXT, UPDATE),
45+
d.buildRRSetTasks(updateOther, UPDATE),
46+
d.buildRRSetTasks(createTXT, CREATE),
47+
d.buildRRSetTasks(createOther, CREATE),
48+
}
49+
50+
for _, batch := range batches {
51+
if len(batch) == 0 {
52+
continue
53+
}
54+
55+
// If any batch fails (e.g., hitting a quota limit), the entire sync loop aborts.
56+
// This leaves the DNS state consistent for the next retry attempt.
57+
if err := d.handleRRSetWithWorkers(ctx, batch, zones); err != nil {
58+
return err
59+
}
60+
}
61+
62+
return nil
63+
}
64+
65+
// splitTXTAndOther separates TXT records from all other record types.
66+
// External-DNS relies on TXT records to track ownership.
67+
func splitTXTAndOther(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, []*endpoint.Endpoint) {
68+
var txt, other []*endpoint.Endpoint
69+
for _, ep := range endpoints {
70+
if ep.RecordType == "TXT" {
71+
txt = append(txt, ep)
72+
} else {
73+
other = append(other, ep)
74+
}
75+
}
76+
77+
return txt, other
3678
}
3779

38-
// handleRRSetWithWorkers handles the given endpoints with workers to optimize speed.
80+
// buildRRSetTasks wraps endpoint changes into executable tasks for the worker pool.
3981
func (d *StackitDNSProvider) buildRRSetTasks(
4082
endpoints []*endpoint.Endpoint,
4183
action string,
@@ -52,52 +94,70 @@ func (d *StackitDNSProvider) buildRRSetTasks(
5294
return tasks
5395
}
5496

55-
// handleRRSetWithWorkers handles the given endpoints with workers to optimize speed.
97+
// handleRRSetWithWorkers processes a batch of DNS changes concurrently.
98+
// It implements a fail-fast mechanism: if any worker encounters an error
99+
// (like a 4xx quota limit reached), it cancels the context to stop remaining queued tasks,
100+
// preventing an API DoS.
56101
func (d *StackitDNSProvider) handleRRSetWithWorkers(
57102
ctx context.Context,
58103
tasks []changeTask,
59104
zones []stackitdnsclient.Zone,
60105
) error {
106+
cancelCtx, cancel := context.WithCancel(ctx)
107+
defer cancel()
108+
61109
workerChannel := make(chan changeTask, len(tasks))
62110
errorChannel := make(chan error, len(tasks))
63111

64112
var wg sync.WaitGroup
65113
for i := 0; i < d.workers; i++ {
66114
wg.Add(1)
67-
go d.changeWorker(ctx, workerChannel, errorChannel, zones, &wg)
115+
go d.changeWorker(cancelCtx, workerChannel, errorChannel, zones, &wg)
68116
}
69117

70118
for _, task := range tasks {
71119
workerChannel <- task
72120
}
73121
close(workerChannel)
74122

75-
// capture first error
76-
var err error
123+
var firstErr error
77124
for i := 0; i < len(tasks); i++ {
78-
err = <-errorChannel
79-
if err != nil {
80-
break
125+
err := <-errorChannel
126+
if err != nil && firstErr == nil {
127+
if !errors.Is(err, context.Canceled) {
128+
firstErr = err
129+
d.logger.Error("error encountered during batch processing, canceling remaining tasks", zap.Error(err))
130+
// Fail fast: signal all active and pending workers to abort.
131+
cancel()
132+
}
81133
}
82134
}
83135

84136
// wait until all workers have finished
85137
wg.Wait()
86138

87-
return err
139+
return firstErr
88140
}
89141

90-
// changeWorker is a worker that handles changes passed by a channel.
142+
// changeWorker listens for tasks on the workerChannel and executes the appropriate API call.
143+
// It respects context cancellation to safely abort pending operations.
91144
func (d *StackitDNSProvider) changeWorker(
92145
ctx context.Context,
93-
changes chan changeTask,
94-
errorChannel chan error,
146+
changes <-chan changeTask,
147+
errorChannel chan<- error,
95148
zones []stackitdnsclient.Zone,
96149
wg *sync.WaitGroup,
97150
) {
98151
defer wg.Done()
99152

100153
for change := range changes {
154+
// Check for context cancellation before processing the next task.
155+
if err := ctx.Err(); err != nil {
156+
errorChannel <- err
157+
158+
continue
159+
}
160+
101161
var err error
102162
switch change.action {
103163
case CREATE:

internal/stackitprovider/apply_changes_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"sync/atomic"
910
"testing"
1011

1112
stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api"
@@ -198,6 +199,52 @@ func TestPartialUpdate(t *testing.T) {
198199
assert.True(t, rrSetUpdated, "rrset was not updated")
199200
}
200201

202+
func TestFailFastCancellation(t *testing.T) {
203+
t.Parallel()
204+
ctx := context.Background()
205+
206+
validZoneResponse := getValidResponseZoneAllBytes(t)
207+
208+
var requestCount atomic.Int32
209+
mux := http.NewServeMux()
210+
server := httptest.NewServer(mux)
211+
defer server.Close()
212+
213+
setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK)
214+
215+
mux.HandleFunc("/v1/projects/1234/zones/1234/rrsets", func(w http.ResponseWriter, r *http.Request) {
216+
requestCount.Add(1)
217+
w.Header().Set("Content-Type", "application/json")
218+
// Force the first requests to fail with a quota-like error
219+
w.WriteHeader(http.StatusUnprocessableEntity)
220+
w.Write([]byte(`{"message": "quota exceeded"}`))
221+
})
222+
223+
stackitDnsProvider, err := getDefaultTestProvider(server)
224+
assert.NoError(t, err)
225+
226+
// Create a large batch of changes to ensure the queue fills up and tests the cancellation
227+
endpoints := make([]*endpoint.Endpoint, 0, 50)
228+
for i := 0; i < 50; i++ {
229+
endpoints = append(endpoints, &endpoint.Endpoint{
230+
DNSName: fmt.Sprintf("test%d.com", i),
231+
Targets: endpoint.Targets{"1.2.3.4"},
232+
RecordType: "A",
233+
})
234+
}
235+
236+
changes := &plan.Changes{
237+
Create: endpoints,
238+
}
239+
240+
err = stackitDnsProvider.ApplyChanges(ctx, changes)
241+
assert.Error(t, err)
242+
243+
// If fail-fast is working, the request count should be significantly less than 50
244+
// because the context cancellation stops the remaining workers from executing HTTP requests.
245+
assert.Less(t, int(requestCount.Load()), 50, "expected fail-fast to cancel remaining requests")
246+
}
247+
201248
// setUpCommonEndpoints for all change types.
202249
func setUpCommonEndpoints(mux *http.ServeMux, responseZone []byte, responseZoneCode int) {
203250
mux.HandleFunc("/v1/projects/1234/zones", func(w http.ResponseWriter, r *http.Request) {

pkg/stackit/options.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package stackit
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"time"
@@ -13,7 +14,7 @@ import (
1314
// passed bearerToken and keyPath parameters. If no baseURL or an invalid
1415
// combination of auth options is given (neither or both), the function returns
1516
// an error.
16-
func SetConfigOptions(baseURL, bearerToken, keyPath string) ([]stackitconfig.ConfigurationOption, error) {
17+
func SetConfigOptions(baseURL, bearerToken, keyPath, tokenURL string) ([]stackitconfig.ConfigurationOption, error) {
1718
if len(baseURL) == 0 {
1819
return nil, fmt.Errorf("base-url is required")
1920
}
@@ -35,6 +36,10 @@ func SetConfigOptions(baseURL, bearerToken, keyPath string) ([]stackitconfig.Con
3536
if bearerTokenSet {
3637
return append(options, stackitconfig.WithToken(bearerToken)), nil
3738
}
39+
if len(tokenURL) > 0 {
40+
options = append(options, stackitconfig.WithTokenEndpoint(tokenURL))
41+
}
42+
options = append(options, stackitconfig.WithBackgroundTokenRefresh(context.Background()))
3843

3944
return append(options, stackitconfig.WithServiceAccountKeyPath(keyPath)), nil
4045
}

pkg/stackit/options_test.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,47 @@ import (
99
func TestMissingBaseURL(t *testing.T) {
1010
t.Parallel()
1111

12-
options, err := SetConfigOptions("", "", "")
12+
options, err := SetConfigOptions("", "", "", "")
1313
assert.ErrorContains(t, err, "base-url")
1414
assert.Nil(t, options)
1515
}
1616

1717
func TestBothAuthOptionsMissing(t *testing.T) {
1818
t.Parallel()
1919

20-
options, err := SetConfigOptions("https://example.com", "", "")
20+
options, err := SetConfigOptions("https://example.com", "", "", "")
2121
assert.ErrorContains(t, err, "auth-token or auth-key-path")
2222
assert.Nil(t, options)
2323
}
2424

2525
func TestBothAuthOptionsSet(t *testing.T) {
2626
t.Parallel()
2727

28-
options, err := SetConfigOptions("https://example.com", "token", "key/path")
28+
options, err := SetConfigOptions("https://example.com", "token", "key/path", "")
2929
assert.ErrorContains(t, err, "auth-token or auth-key-path")
3030
assert.Nil(t, options)
3131
}
3232

3333
func TestBearerTokenSet(t *testing.T) {
3434
t.Parallel()
3535

36-
options, err := SetConfigOptions("https://example.com", "token", "")
36+
options, err := SetConfigOptions("https://example.com", "token", "", "")
3737
assert.NoError(t, err)
3838
assert.Len(t, options, 3)
3939
}
4040

4141
func TestKeyPathSet(t *testing.T) {
4242
t.Parallel()
4343

44-
options, err := SetConfigOptions("https://example.com", "", "key/path")
44+
options, err := SetConfigOptions("https://example.com", "", "key/path", "")
4545
assert.NoError(t, err)
46-
assert.Len(t, options, 3)
46+
assert.Len(t, options, 4)
47+
}
48+
49+
func TestKeyPathAndURLSet(t *testing.T) {
50+
t.Parallel()
51+
52+
options, err := SetConfigOptions("https://example.com", "", "key/path", "https://alternative.url.stackit.cloud/token")
53+
assert.NoError(t, err)
54+
assert.Len(t, options, 5)
4755
}

0 commit comments

Comments
 (0)