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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,3 @@ vendir.lock.yml
.craig
CLAUDE.md
.claude
ROADMAP.md
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ machine-controller-manager-provider-stackit/
│ ├── provider/
│ │ ├── core.go # Core provider implementation
│ │ ├── provider.go # Driver interface implementation
│ │ ├── stackit_client.go # STACKIT API client
│ │ ├── stackit_client.go # STACKIT client interface
│ │ ├── sdk_client.go # STACKIT SDK wrapper implementation
│ │ ├── helpers.go # SDK type conversion utilities
│ │ ├── apis/
│ │ │ ├── provider_spec.go # ProviderSpec CRD definitions
│ │ │ └── validation/ # Field validation logic
Expand Down Expand Up @@ -97,6 +99,24 @@ kubectl apply -f samples/machine-class.yaml
kubectl apply -f samples/machine.yaml
```

## STACKIT SDK Integration

This provider uses the official [STACKIT Go SDK](https://github.com/stackitcloud/stackit-sdk-go) for all interactions with the STACKIT IaaS API. The SDK provides type-safe API access, built-in authentication handling, and is officially maintained by STACKIT.

The SDK client is stateless and supports different credentials per MachineClass, allowing multi-tenancy scenarios where different machine pools use different STACKIT projects.

### Authentication & Credentials

The provider requires STACKIT credentials to be provided via a Kubernetes Secret. The Secret must contain the following fields:

| Field | Required | Description |
|-------|----------|-------------|
| `projectId` | Yes | STACKIT project UUID |
| `stackitToken` | Yes | STACKIT API authentication token |
| `region` | Yes | STACKIT region (e.g., `eu01-1`, `eu01-2`) |
| `userData` | No | Default cloud-init user data (can be overridden in ProviderSpec) |
| `networkId` | No | Default network UUID (can be overridden in ProviderSpec) |

## Configuration Reference

### ProviderSpec Fields
Expand Down Expand Up @@ -145,11 +165,25 @@ just start

## References

### Machine Controller Manager
- [Machine Controller Manager](https://github.com/gardener/machine-controller-manager) - Core MCM project
- [MCM Provider Development Guide](https://github.com/gardener/machine-controller-manager/blob/master/docs/development/cp_support_new.md) - Guidelines followed to build this provider
- [MCM Sample Provider](https://github.com/gardener/machine-controller-manager-provider-sampleprovider) - Original template used as starting point
- [MCM Driver Interface](https://github.com/gardener/machine-controller-manager/blob/master/pkg/util/provider/driver/driver.go) - Provider contract interface

### STACKIT SDK
- [STACKIT SDK Go](https://github.com/stackitcloud/stackit-sdk-go) - Official STACKIT Go SDK
- [IaaS Service Package](https://github.com/stackitcloud/stackit-sdk-go/tree/main/services/iaas) - IaaS service API documentation
- [SDK Core Package](https://github.com/stackitcloud/stackit-sdk-go/tree/main/core) - Core SDK configuration and authentication
- [SDK Examples](https://github.com/stackitcloud/stackit-sdk-go/tree/main/examples) - Code examples and usage patterns
- [SDK Releases](https://github.com/stackitcloud/stackit-sdk-go/releases) - Release notes and changelog

### STACKIT Platform
- [STACKIT Documentation](https://docs.stackit.cloud/) - STACKIT cloud platform documentation
- [STACKIT Portal](https://portal.stackit.cloud/) - STACKIT management console
- [Service Accounts](https://docs.stackit.cloud/stackit/en/service-accounts-134415819.html) - Creating and managing service accounts
- [Service Account Keys](https://docs.stackit.cloud/stackit/en/usage-of-the-service-account-keys-in-stackit-175112464.html) - API authentication setup
- [IaaS API Documentation](https://docs.stackit.cloud/) - STACKIT IaaS REST API reference

## License

Expand Down
2 changes: 2 additions & 0 deletions config/overlays/e2e/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ kind: Kustomization
resources:
- namespace.yaml
- ../../default
# TODO: replace ref with 'main' once changes merged upstream
- https://github.com/stackit-controllers-k8s/stackit-api-mockservers//config/apis/iaas?ref=main


patches:
# Patch MCM deployment to machine-controller-manager namespace
- target:
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ require (
github.com/onsi/ginkgo/v2 v2.27.2
github.com/onsi/gomega v1.38.2
github.com/spf13/pflag v1.0.5
github.com/stackitcloud/stackit-sdk-go/core v0.18.0
github.com/stackitcloud/stackit-sdk-go/services/iaas v1.0.0
k8s.io/api v0.0.0-20190918155943-95b840bb6a1f
k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655
k8s.io/component-base v0.0.0-20190918160511-547f6c5d7090
Expand All @@ -23,11 +25,13 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.0.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gnostic v0.2.0 // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/imdario/mergo v0.3.5 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20180513044358-24b0969c4cb7 h1:u4bArs140e9+AfE52mFHOXVFnOSBJBRlzTHrOPLOIhE=
Expand Down Expand Up @@ -115,6 +117,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g=
Expand Down Expand Up @@ -220,6 +224,10 @@ github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzu
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stackitcloud/stackit-sdk-go/core v0.18.0 h1:+4v8sjQpQXPihO3crgTp0Kz/XRIi5p7oKV28dw6jsEQ=
github.com/stackitcloud/stackit-sdk-go/core v0.18.0/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ=
github.com/stackitcloud/stackit-sdk-go/services/iaas v1.0.0 h1:qLMpd5whPMLnaLEdFQjK51q/o9V6eMFMORBDSsyGyNI=
github.com/stackitcloud/stackit-sdk-go/services/iaas v1.0.0/go.mod h1:854gnLR92NvAbJAA1xZEumrtNh1DoBP1FXTMvhwYA6w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
Expand Down
19 changes: 19 additions & 0 deletions pkg/provider/apis/validation/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]
// Pattern: lowercase letter(s) followed by digits, dot, then more digits (e.g., c2i.2, m2i.8, g1a.8)
var machineTypeRegex = regexp.MustCompile(`^[a-z]+\d+[a-z]*\.\d+[a-z]*(\.[a-z]+\d+)*$`)

// regionRegex is a regex pattern for validating STACKIT region format
// Pattern: lowercase letters/digits followed by digits, dash, then digit(s) (e.g., eu01-1, eu01-2)
var regionRegex = regexp.MustCompile(`^[a-z0-9]+-\d+$`)

// labelKeyRegex validates Kubernetes label keys (must start/end with alphanumeric, can contain -, _, .)
// Maximum length: 63 characters
var labelKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9]([-a-zA-Z0-9_.]*[a-zA-Z0-9])?$`)
Expand Down Expand Up @@ -63,6 +67,16 @@ func ValidateProviderSpecNSecret(spec *api.ProviderSpec, secrets *corev1.Secret)
errors = append(errors, fmt.Errorf("secret 'stackitToken' cannot be empty"))
}

// Validate region (required for SDK)
region, ok := secrets.Data["region"]
if !ok {
errors = append(errors, fmt.Errorf("secret must contain 'region' field"))
} else if len(region) == 0 {
errors = append(errors, fmt.Errorf("secret 'region' cannot be empty"))
} else if !isValidRegion(string(region)) {
errors = append(errors, fmt.Errorf("secret 'region' has invalid format (expected format: eu01-1, eu01-2, etc.)"))
}

// Validate ProviderSpec
if spec.MachineType == "" {
errors = append(errors, fmt.Errorf("providerSpec.machineType is required"))
Expand Down Expand Up @@ -261,3 +275,8 @@ func isValidEmail(s string) bool {
func isValidMachineType(s string) bool {
return machineTypeRegex.MatchString(s)
}

// isValidRegion checks if a string matches the STACKIT region format
func isValidRegion(s string) bool {
return regionRegex.MatchString(s)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
1 change: 1 addition & 0 deletions pkg/provider/apis/validation/validation_fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
1 change: 1 addition & 0 deletions pkg/provider/apis/validation/validation_networking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
1 change: 1 addition & 0 deletions pkg/provider/apis/validation/validation_secgroup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
1 change: 1 addition & 0 deletions pkg/provider/apis/validation/validation_secret_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
1 change: 1 addition & 0 deletions pkg/provider/apis/validation/validation_volumes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var _ = Describe("ValidateProviderSpecNSecret", func() {
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token"),
"region": []byte("eu01-1"),
},
}
})
Expand Down
26 changes: 21 additions & 5 deletions pkg/provider/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR
// Extract credentials from Secret
projectID := string(req.Secret.Data["projectId"])
token := string(req.Secret.Data["stackitToken"])
region := string(req.Secret.Data["region"])

// Build labels: merge ProviderSpec labels with MCM-specific labels
labels := make(map[string]string)
Expand All @@ -73,12 +74,20 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR
Labels: labels,
}

// Add networking configuration if specified
// Add networking configuration (required in v2 API)
// If not specified in ProviderSpec, try to use networkId from Secret, or use empty
if providerSpec.Networking != nil {
createReq.Networking = &ServerNetworkingRequest{
NetworkID: providerSpec.Networking.NetworkID,
NICIDs: providerSpec.Networking.NICIDs,
}
} else {
// v2 API requires networking field - use networkId from Secret if available
// This allows tests/deployments to specify a default network without modifying each MachineClass
networkID := string(req.Secret.Data["networkId"])
createReq.Networking = &ServerNetworkingRequest{
NetworkID: networkID, // Can be empty string if not in Secret
}
}

// Add security groups if specified
Expand Down Expand Up @@ -155,7 +164,7 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR
}

// Call STACKIT API to create server
server, err := p.client.CreateServer(ctx, token, projectID, createReq)
server, err := p.client.CreateServer(ctx, token, projectID, region, createReq)
if err != nil {
klog.Errorf("Failed to create server for machine %q: %v", req.Machine.Name, err)
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to create server: %v", err))
Expand Down Expand Up @@ -196,14 +205,17 @@ func (p *Provider) DeleteMachine(ctx context.Context, req *driver.DeleteMachineR
// Extract token from Secret for authentication
token := string(req.Secret.Data["stackitToken"])

// Extract region from Secret
region := string(req.Secret.Data["region"])

// Parse ProviderID to extract projectID and serverID
projectID, serverID, err := parseProviderID(req.Machine.Spec.ProviderID)
if err != nil {
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("invalid ProviderID format: %v", err))
}

// Call STACKIT API to delete server
err = p.client.DeleteServer(ctx, token, projectID, serverID)
err = p.client.DeleteServer(ctx, token, projectID, region, serverID)
if err != nil {
// Check if server was not found (404) - this is OK for idempotency
if errors.Is(err, ErrServerNotFound) {
Expand Down Expand Up @@ -249,6 +261,9 @@ func (p *Provider) GetMachineStatus(ctx context.Context, req *driver.GetMachineS
// Extract token from Secret for authentication
token := string(req.Secret.Data["stackitToken"])

// Extract region from Secret
region := string(req.Secret.Data["region"])

// Parse ProviderID to extract projectID and serverID
// Expected format: stackit://<projectId>/<serverId>
projectID, serverID, err := parseProviderID(req.Machine.Spec.ProviderID)
Expand All @@ -257,7 +272,7 @@ func (p *Provider) GetMachineStatus(ctx context.Context, req *driver.GetMachineS
}

// Call STACKIT API to get server status
server, err := p.client.GetServer(ctx, token, projectID, serverID)
server, err := p.client.GetServer(ctx, token, projectID, region, serverID)
if err != nil {
// Check if server was not found (404)
if errors.Is(err, ErrServerNotFound) {
Expand Down Expand Up @@ -296,9 +311,10 @@ func (p *Provider) ListMachines(ctx context.Context, req *driver.ListMachinesReq
// Extract credentials from Secret
projectID := string(req.Secret.Data["projectId"])
token := string(req.Secret.Data["stackitToken"])
region := string(req.Secret.Data["region"])

// Call STACKIT API to list all servers
servers, err := p.client.ListServers(ctx, token, projectID)
servers, err := p.client.ListServers(ctx, token, projectID, region)
if err != nil {
klog.Errorf("Failed to list servers for MachineClass %q: %v", req.MachineClass.Name, err)
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to list servers: %v", err))
Expand Down
8 changes: 5 additions & 3 deletions pkg/provider/core_create_machine_basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ var _ = Describe("CreateMachine", func() {
client: mockClient,
}

// Create secret with projectId
// Create secret with projectId and networkId (required for v2 API)
secret = &corev1.Secret{
Data: map[string][]byte{
"projectId": []byte("11111111-2222-3333-4444-555555555555"),
"stackitToken": []byte("test-token-123"),
"region": []byte("eu01-1"),
"networkId": []byte("770e8400-e29b-41d4-a716-446655440000"),
},
}

Expand Down Expand Up @@ -93,7 +95,7 @@ var _ = Describe("CreateMachine", func() {
var capturedReq *CreateServerRequest
var capturedProjectID string

mockClient.createServerFunc = func(ctx context.Context, token, projectID string, req *CreateServerRequest) (*Server, error) {
mockClient.createServerFunc = func(ctx context.Context, token, projectID, region string, req *CreateServerRequest) (*Server, error) {
capturedProjectID = projectID
capturedReq = req
return &Server{
Expand Down Expand Up @@ -163,7 +165,7 @@ var _ = Describe("CreateMachine", func() {

Context("when STACKIT API fails", func() {
It("should return Internal error on API failure", func() {
mockClient.createServerFunc = func(ctx context.Context, token, projectID string, req *CreateServerRequest) (*Server, error) {
mockClient.createServerFunc = func(ctx context.Context, token, projectID, region string, req *CreateServerRequest) (*Server, error) {
return nil, fmt.Errorf("API connection failed")
}

Expand Down
Loading