Skip to content

Commit aa3fb24

Browse files
authored
Feat: serviceaccount auth
Replace token-based auth with serviceAccountKey (JSON) across provider, SDK client, validation, docs, samples, and tests; add env flags for endpoint/no-auth.
1 parent 0a4c911 commit aa3fb24

32 files changed

+432
-122
lines changed

README.md

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Out of tree (controller based) implementation for `STACKIT` as a provider for Ga
88

99
A Machine Controller Manager (MCM) external provider implementation for STACKIT cloud infrastructure. This provider enables Gardener to manage virtual machines on STACKIT using the declarative Kubernetes API.
1010

11-
The provider was built following the [MCM provider development guidelines](https://github.com/gardener/machine-controller-manager/blob/master/docs/development/cp_support_new.md) and bootstrapped from the [sample provider template](https://github.com/gardener/machine-controller-manager-provider-sampleprovider).Following are the basic principles kept in mind while developing the external plugin.
11+
The provider was built following the [MCM provider development guidelines](https://github.com/gardener/machine-controller-manager/blob/master/docs/development/cp_support_new.md) and bootstrapped from the [sample provider template](https://github.com/gardener/machine-controller-manager-provider-sampleprovider).
1212

1313
## Project Structure
1414

@@ -59,19 +59,9 @@ This project uses **Hermit** for reproducible development environments and **jus
5959
hermit shell-hooks
6060
```
6161

62-
**just** is the task runner (defined in `justfile`). It provides a cleaner syntax than Make and better task organization:
62+
**[just](https://github.com/casey/just)** is the task runner (defined in `justfile`):
6363

64-
```sh
65-
# List all available commands
66-
just --list
67-
68-
# Or just run 'just' with no arguments
69-
just
7064
```
71-
72-
### Quick Start
73-
74-
```sh
7565
just build # Build the provider binary
7666
just test # Run unit tests
7767
just test-e2e # Run end-to-end tests
@@ -80,16 +70,22 @@ just start # Run provider locally for debugging
8070
just docker-build # Build container image
8171
```
8272

83-
**NOTE:** Run `just --list` for more information on all available commands.
73+
```sh
74+
# List all available commands
75+
just --list
76+
77+
# Or just run 'just' with no arguments
78+
just
79+
```
8480

8581
### Deployment
8682

8783
See the [samples/](./samples/) directory for example manifests including:
88-
- `secret.yaml` - STACKIT credentials configuration
89-
- `machine-class.yaml` - MachineClass definition
90-
- `machine.yaml` - Individual Machine example
91-
- `machine-deployment.yaml` - MachineDeployment for scaled workloads
92-
- `deployment.yaml` - Provider controller deployment
84+
- [`secret.yaml`](./samples/secret.yaml) - STACKIT credentials configuration
85+
- [`machine-class.yaml`](./samples/machine-class.yaml) - MachineClass definition
86+
- [`machine.yaml`](./samples/machine.yaml) - Individual Machine example
87+
- [`machine-deployment.yaml`](./samples/machine-deployment.yaml) - MachineDeployment for scaled workloads
88+
- [`deployment.yaml`](./kubernetes/deployment.yaml) - Provider controller deployment
9389

9490
Deploy using standard kubectl commands:
9591

@@ -112,11 +108,24 @@ The provider requires STACKIT credentials to be provided via a Kubernetes Secret
112108
| Field | Required | Description |
113109
|-------|----------|-------------|
114110
| `projectId` | Yes | STACKIT project UUID |
115-
| `stackitToken` | Yes | STACKIT API authentication token |
111+
| `serviceAccountKey` | Yes | STACKIT service account credentials (JSON format) |
116112
| `region` | Yes | STACKIT region (e.g., `eu01-1`, `eu01-2`) |
117113
| `userData` | No | Default cloud-init user data (can be overridden in ProviderSpec) |
118114
| `networkId` | No | Default network UUID (can be overridden in ProviderSpec) |
119115

116+
The service account key should be obtained from the STACKIT Portal (Project Settings → Service Accounts → Create Key) and contains JWT credentials and a private key for secure authentication.
117+
118+
### Environment Variables
119+
120+
The provider supports the following environment variables for configuration:
121+
122+
| Variable | Default | Description |
123+
|----------|---------|-------------|
124+
| `STACKIT_API_ENDPOINT` | (SDK default) | Override STACKIT API endpoint URL (useful for testing) |
125+
| `STACKIT_NO_AUTH` | `false` | Skip authentication (for testing with mock servers, set to `true`) |
126+
127+
**Note:** `STACKIT_NO_AUTH=true` is only intended for testing environments with mock servers. It skips the authenticaiton step and communicates with the STACKIT API without authenticating itself. Do not use in production.
128+
120129
## Configuration Reference
121130

122131
### ProviderSpec Fields
@@ -138,19 +147,6 @@ The provider requires STACKIT credentials to be provided via a Kubernetes Secret
138147
| `agent` | AgentSpec | No | STACKIT agent configuration |
139148
| `metadata` | map[string]interface{} | No | Custom metadata key-value pairs |
140149

141-
## Contributing
142-
143-
Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
144-
145-
### Development Workflow
146-
147-
1. Fork the repository
148-
2. Create a feature branch: `git checkout -b feature/my-feature`
149-
3. Make changes and add tests
150-
4. Run verification: `just test && just golang::lint`
151-
5. Commit with meaningful messages
152-
6. Push and create a Pull Request
153-
154150
### Local Testing
155151

156152
Use the local development environment for rapid iteration:

pkg/provider/apis/validation/validation.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package validation
77

88
import (
9+
"encoding/json"
910
"fmt"
1011
"regexp"
1112

@@ -52,29 +53,32 @@ func ValidateProviderSpecNSecret(spec *api.ProviderSpec, secrets *corev1.Secret)
5253

5354
projectID, ok := secrets.Data["projectId"]
5455
if !ok {
55-
errors = append(errors, fmt.Errorf("secret must contain 'projectId' field"))
56+
errors = append(errors, fmt.Errorf("secret field 'projectId' is required"))
5657
} else if len(projectID) == 0 {
57-
errors = append(errors, fmt.Errorf("secret 'projectId' cannot be empty"))
58+
errors = append(errors, fmt.Errorf("secret field 'projectId' cannot be empty"))
5859
} else if !isValidUUID(string(projectID)) {
59-
errors = append(errors, fmt.Errorf("secret 'projectId' must be a valid UUID"))
60+
errors = append(errors, fmt.Errorf("secret field 'projectId' must be a valid UUID"))
6061
}
6162

62-
// Validate stackitToken (required for authentication)
63-
stackitToken, ok := secrets.Data["stackitToken"]
63+
// Validate serviceAccountKey (required for authentication)
64+
// ServiceAccount Key Flow: JSON string containing service account credentials and private key
65+
serviceAccountKey, ok := secrets.Data["serviceAccountKey"]
6466
if !ok {
65-
errors = append(errors, fmt.Errorf("secret must contain 'stackitToken' field"))
66-
} else if len(stackitToken) == 0 {
67-
errors = append(errors, fmt.Errorf("secret 'stackitToken' cannot be empty"))
67+
errors = append(errors, fmt.Errorf("secret field 'serviceAccountKey' is required"))
68+
} else if len(serviceAccountKey) == 0 {
69+
errors = append(errors, fmt.Errorf("secret field 'serviceAccountKey' cannot be empty"))
70+
} else if !isValidJSON(string(serviceAccountKey)) {
71+
errors = append(errors, fmt.Errorf("secret field 'serviceAccountKey' must be valid JSON (service account credentials)"))
6872
}
6973

7074
// Validate region (required for SDK)
7175
region, ok := secrets.Data["region"]
7276
if !ok {
73-
errors = append(errors, fmt.Errorf("secret must contain 'region' field"))
77+
errors = append(errors, fmt.Errorf("secret field 'region' is required"))
7478
} else if len(region) == 0 {
75-
errors = append(errors, fmt.Errorf("secret 'region' cannot be empty"))
79+
errors = append(errors, fmt.Errorf("secret field 'region' cannot be empty"))
7680
} else if !isValidRegion(string(region)) {
77-
errors = append(errors, fmt.Errorf("secret 'region' has invalid format (expected format: eu01-1, eu01-2, etc.)"))
81+
errors = append(errors, fmt.Errorf("secret field 'region' has invalid format (expected format: eu01-1, eu01-2, etc.)"))
7882
}
7983

8084
// Validate ProviderSpec
@@ -280,3 +284,9 @@ func isValidMachineType(s string) bool {
280284
func isValidRegion(s string) bool {
281285
return regionRegex.MatchString(s)
282286
}
287+
288+
// isValidJSON checks if a string is valid JSON
289+
func isValidJSON(s string) bool {
290+
var js json.RawMessage
291+
return json.Unmarshal([]byte(s), &js) == nil
292+
}

pkg/provider/apis/validation/validation_core_labels_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})

pkg/provider/apis/validation/validation_fields_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})

pkg/provider/apis/validation/validation_networking_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})

pkg/provider/apis/validation/validation_secgroup_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})

pkg/provider/apis/validation/validation_secret_test.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})
@@ -60,5 +60,73 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
6060
Expect(errors).NotTo(BeEmpty())
6161
Expect(errors[0].Error()).To(ContainSubstring("projectId' must be a valid UUID"))
6262
})
63+
64+
It("should fail when serviceAccountKey is missing from secret", func() {
65+
delete(secret.Data, "serviceAccountKey")
66+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
67+
Expect(errors).NotTo(BeEmpty())
68+
Expect(errors[0].Error()).To(ContainSubstring("serviceAccountKey"))
69+
})
70+
71+
It("should fail when serviceAccountKey is empty in secret", func() {
72+
secret.Data["serviceAccountKey"] = []byte("")
73+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
74+
Expect(errors).NotTo(BeEmpty())
75+
Expect(errors[0].Error()).To(ContainSubstring("serviceAccountKey"))
76+
})
77+
78+
It("should fail when serviceAccountKey is not valid JSON", func() {
79+
secret.Data["serviceAccountKey"] = []byte("not-valid-json")
80+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
81+
Expect(errors).NotTo(BeEmpty())
82+
Expect(errors[0].Error()).To(ContainSubstring("must be valid JSON"))
83+
})
84+
85+
It("should fail when serviceAccountKey is malformed JSON (missing closing brace)", func() {
86+
secret.Data["serviceAccountKey"] = []byte(`{"credentials":{"iss":"test"`)
87+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
88+
Expect(errors).NotTo(BeEmpty())
89+
Expect(errors[0].Error()).To(ContainSubstring("must be valid JSON"))
90+
})
91+
92+
It("should pass when serviceAccountKey is valid JSON with minimal structure", func() {
93+
secret.Data["serviceAccountKey"] = []byte(`{"credentials":{"iss":"test@sa.stackit.cloud"}}`)
94+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
95+
Expect(errors).To(BeEmpty())
96+
})
97+
98+
It("should pass when serviceAccountKey is valid JSON with full structure", func() {
99+
secret.Data["serviceAccountKey"] = []byte(`{
100+
"credentials": {
101+
"iss": "test@sa.stackit.cloud",
102+
"sub": "12345678-1234-1234-1234-123456789012",
103+
"aud": "stackit"
104+
},
105+
"privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE\n-----END PRIVATE KEY-----"
106+
}`)
107+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
108+
Expect(errors).To(BeEmpty())
109+
})
110+
111+
It("should pass when serviceAccountKey is valid JSON array (edge case)", func() {
112+
// JSON validation should accept any valid JSON, even if not the expected structure
113+
// The SDK will validate the actual content
114+
secret.Data["serviceAccountKey"] = []byte(`[]`)
115+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
116+
// Should not have JSON validation error (though SDK would fail with this content)
117+
for _, err := range errors {
118+
Expect(err.Error()).NotTo(ContainSubstring("must be valid JSON"))
119+
}
120+
})
121+
122+
It("should pass when serviceAccountKey is valid JSON string (edge case)", func() {
123+
// JSON validation should accept any valid JSON, even if not the expected structure
124+
secret.Data["serviceAccountKey"] = []byte(`"some-string"`)
125+
errors := ValidateProviderSpecNSecret(providerSpec, secret)
126+
// Should not have JSON validation error (though SDK would fail with this content)
127+
for _, err := range errors {
128+
Expect(err.Error()).NotTo(ContainSubstring("must be valid JSON"))
129+
}
130+
})
63131
})
64132
})

pkg/provider/apis/validation/validation_volumes_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
2626
}
2727
secret = &corev1.Secret{
2828
Data: map[string][]byte{
29-
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30-
"stackitToken": []byte("test-token"),
31-
"region": []byte("eu01-1"),
29+
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
30+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
31+
"region": []byte("eu01-1"),
3232
},
3333
}
3434
})

pkg/provider/core.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR
5050

5151
// Extract credentials from Secret
5252
projectID := string(req.Secret.Data["projectId"])
53-
token := string(req.Secret.Data["stackitToken"])
53+
serviceAccountKey := string(req.Secret.Data["serviceAccountKey"])
5454
region := string(req.Secret.Data["region"])
5555

5656
// Build labels: merge ProviderSpec labels with MCM-specific labels
@@ -164,7 +164,7 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR
164164
}
165165

166166
// Call STACKIT API to create server
167-
server, err := p.client.CreateServer(ctx, token, projectID, region, createReq)
167+
server, err := p.client.CreateServer(ctx, serviceAccountKey, projectID, region, createReq)
168168
if err != nil {
169169
klog.Errorf("Failed to create server for machine %q: %v", req.Machine.Name, err)
170170
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to create server: %v", err))
@@ -203,7 +203,7 @@ func (p *Provider) DeleteMachine(ctx context.Context, req *driver.DeleteMachineR
203203
}
204204

205205
// Extract token from Secret for authentication
206-
token := string(req.Secret.Data["stackitToken"])
206+
serviceAccountKey := string(req.Secret.Data["serviceAccountKey"])
207207

208208
// Extract region from Secret
209209
region := string(req.Secret.Data["region"])
@@ -215,7 +215,7 @@ func (p *Provider) DeleteMachine(ctx context.Context, req *driver.DeleteMachineR
215215
}
216216

217217
// Call STACKIT API to delete server
218-
err = p.client.DeleteServer(ctx, token, projectID, region, serverID)
218+
err = p.client.DeleteServer(ctx, serviceAccountKey, projectID, region, serverID)
219219
if err != nil {
220220
// Check if server was not found (404) - this is OK for idempotency
221221
if errors.Is(err, ErrServerNotFound) {
@@ -259,7 +259,7 @@ func (p *Provider) GetMachineStatus(ctx context.Context, req *driver.GetMachineS
259259
}
260260

261261
// Extract token from Secret for authentication
262-
token := string(req.Secret.Data["stackitToken"])
262+
serviceAccountKey := string(req.Secret.Data["serviceAccountKey"])
263263

264264
// Extract region from Secret
265265
region := string(req.Secret.Data["region"])
@@ -272,7 +272,7 @@ func (p *Provider) GetMachineStatus(ctx context.Context, req *driver.GetMachineS
272272
}
273273

274274
// Call STACKIT API to get server status
275-
server, err := p.client.GetServer(ctx, token, projectID, region, serverID)
275+
server, err := p.client.GetServer(ctx, serviceAccountKey, projectID, region, serverID)
276276
if err != nil {
277277
// Check if server was not found (404)
278278
if errors.Is(err, ErrServerNotFound) {
@@ -310,11 +310,11 @@ func (p *Provider) ListMachines(ctx context.Context, req *driver.ListMachinesReq
310310

311311
// Extract credentials from Secret
312312
projectID := string(req.Secret.Data["projectId"])
313-
token := string(req.Secret.Data["stackitToken"])
313+
serviceAccountKey := string(req.Secret.Data["serviceAccountKey"])
314314
region := string(req.Secret.Data["region"])
315315

316316
// Call STACKIT API to list all servers
317-
servers, err := p.client.ListServers(ctx, token, projectID, region)
317+
servers, err := p.client.ListServers(ctx, serviceAccountKey, projectID, region)
318318
if err != nil {
319319
klog.Errorf("Failed to list servers for MachineClass %q: %v", req.MachineClass.Name, err)
320320
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to list servers: %v", err))

pkg/provider/core_create_machine_basic_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ var _ = Describe("CreateMachine", func() {
4242
secret = &corev1.Secret{
4343
Data: map[string][]byte{
4444
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
45-
"stackitToken": []byte("test-token-123"),
45+
"serviceAccountKey": []byte(`{"credentials":{"iss":"test"}}`),
4646
"region": []byte("eu01-1"),
4747
"networkId": []byte("770e8400-e29b-41d4-a716-446655440000"),
4848
},

0 commit comments

Comments
 (0)