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
9 changes: 7 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Kubebuilder DevContainer",
"image": "golang:1.25",
"image": "golang:1.26",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": false,
Expand All @@ -13,6 +13,10 @@
},

"runArgs": ["--privileged", "--init"],
"mounts": [
"source=${localWorkspaceFolderBasename}-bashhistory,target=/commandhistory,type=volume",
"source=${localWorkspaceFolderBasename}-docker,target=/var/lib/docker,type=volume"
],

"customizations": {
"vscode": {
Expand All @@ -27,7 +31,8 @@
},

"remoteEnv": {
"GO111MODULE": "on"
"GO111MODULE": "on",
"HISTFILE": "/commandhistory/.bash_history"
},

"onCreateCommand": "bash .devcontainer/post-install.sh"
Expand Down
69 changes: 69 additions & 0 deletions .devcontainer/post-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ if ! grep -q "source /usr/share/bash-completion/bash_completion" ~/.bashrc 2>/de
echo "Added bash-completion to .bashrc"
fi

# Persist bash history on the /commandhistory volume (HISTFILE is set via
# devcontainer.json remoteEnv) and flush it after every command instead of
# only on a clean shell exit.
mkdir -p /commandhistory
if ! grep -q "PROMPT_COMMAND='history -a'" ~/.bashrc 2>/dev/null; then
echo "PROMPT_COMMAND='history -a'" >> ~/.bashrc
echo "Added persistent bash history to .bashrc"
fi

echo ""
echo "------------------------------------"
echo "Installing development tools..."
Expand Down Expand Up @@ -99,6 +108,63 @@ if command -v kubectl &> /dev/null; then
fi
fi

# Install clusterctl
if ! command -v clusterctl &> /dev/null; then
echo "Installing clusterctl..."
curl -Lo /usr/local/bin/clusterctl "https://github.com/kubernetes-sigs/cluster-api/releases/latest/download/clusterctl-linux-${ARCH}"
chmod +x /usr/local/bin/clusterctl
echo "clusterctl installed successfully"
fi

# Generate clusterctl bash completion
if command -v clusterctl &> /dev/null; then
if clusterctl completion bash > "${BASH_COMPLETIONS_DIR}/clusterctl" 2>/dev/null; then
echo "clusterctl completion installed"
else
echo "WARNING: Failed to generate clusterctl completion"
fi
fi

# Install Cilium CLI
if ! command -v cilium &> /dev/null; then
echo "Installing Cilium CLI..."
CILIUM_CLI_VERSION=$(curl -Ls https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
curl -Lo /tmp/cilium-linux-${ARCH}.tar.gz "https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-${ARCH}.tar.gz"
tar xzfC /tmp/cilium-linux-${ARCH}.tar.gz /usr/local/bin
rm -f /tmp/cilium-linux-${ARCH}.tar.gz
echo "Cilium CLI installed successfully"
fi

# Generate Cilium CLI bash completion
if command -v cilium &> /dev/null; then
if cilium completion bash > "${BASH_COMPLETIONS_DIR}/cilium" 2>/dev/null; then
echo "Cilium CLI completion installed"
else
echo "WARNING: Failed to generate Cilium CLI completion"
fi
fi

# Install stackit CLI
if ! command -v stackit &> /dev/null; then
echo "Installing stackit CLI..."
STACKIT_CLI_VERSION=$(curl -Ls https://api.github.com/repos/stackitcloud/stackit-cli/releases/latest | grep '"tag_name"' | cut -d '"' -f4 | sed 's/^v//')
curl -Lo /tmp/stackit-cli.tar.gz "https://github.com/stackitcloud/stackit-cli/releases/download/v${STACKIT_CLI_VERSION}/stackit-cli_${STACKIT_CLI_VERSION}_linux_${ARCH}.tar.gz"
tar -xzf /tmp/stackit-cli.tar.gz -C /tmp stackit
mv /tmp/stackit /usr/local/bin/stackit
chmod +x /usr/local/bin/stackit
rm -f /tmp/stackit-cli.tar.gz
echo "stackit CLI installed successfully"
fi

# Generate stackit CLI bash completion
if command -v stackit &> /dev/null; then
if stackit completion bash > "${BASH_COMPLETIONS_DIR}/stackit" 2>/dev/null; then
echo "stackit completion installed"
else
echo "WARNING: Failed to generate stackit completion"
fi
fi

# Generate Docker bash completion
if command -v docker &> /dev/null; then
if docker completion bash > "${BASH_COMPLETIONS_DIR}/docker" 2>/dev/null; then
Expand Down Expand Up @@ -142,6 +208,9 @@ echo "------------------------------------"
kind version
kubebuilder version
kubectl version --client
clusterctl version
cilium version --client
stackit --version
docker --version
go version

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Lint

on:
push:
branches:
- main
pull_request:

permissions: {}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Tests

on:
push:
branches:
- main
pull_request:

permissions: {}
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
.env
.stackit

# Local dev/debug artifacts
.DS_Store
/cluster*.yaml
/kind-config.yaml
.devcontainer/devcontainer-lock.json
.ssh/

# Binaries for programs and plugins
*.exe
*.exe~
Expand Down
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ linters:
logcheck:
type: "module"
description: Checks Go logging calls for Kubernetes logging conventions.
lll:
line-length: 200
revive:
rules:
- name: comment-spacings
Expand All @@ -45,6 +47,9 @@ linters:
- dupl
- lll
path: internal/*
- linters:
- lll
path: webhook/*
paths:
- third_party$
- builtin$
Expand Down
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ STACKIT-VM based clusters (unmanaged), and aims to support SKE clusters (managed
cmd/main.go Manager entry (registers controllers/webhooks)
api/<version>/*_types.go CRD schemas (+kubebuilder markers)
api/<version>/zz_generated.* Auto-generated (DO NOT EDIT)
internal/controller/* Reconciliation logic
internal/webhook/* Validation/defaulting (if present)
controller/* Reconciliation logic
webhook/* Validation/defaulting (if present)
config/crd/bases/* Generated CRDs (DO NOT EDIT)
config/rbac/role.yaml Generated RBAC (DO NOT EDIT)
config/samples/* Example CRs (edit these)
Expand All @@ -27,17 +27,17 @@ PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT)
**Multi-group layout** (for projects with multiple API groups):
```
api/<group>/<version>/*_types.go CRD schemas by group
internal/controller/<group>/* Controllers by group
internal/webhook/<group>/<version>/* Webhooks by group and version (if present)
controller/<group>/* Controllers by group
webhook/<group>/<version>/* Webhooks by group and version (if present)
```

Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`.

**To convert to multi-group layout:**
1. Run: `kubebuilder edit --multigroup=true`
2. Move APIs: `mkdir -p api/<group> && mv api/<version> api/<group>/`
3. Move controllers: `mkdir -p internal/controller/<group> && mv internal/controller/*.go internal/controller/<group>/`
4. Move webhooks (if present): `mkdir -p internal/webhook/<group> && mv internal/webhook/<version> internal/webhook/<group>/`
3. Move controllers: `mkdir -p controller/<group> && mv controller/*.go controller/<group>/`
4. Move webhooks (if present): `mkdir -p webhook/<group> && mv webhook/<version> webhook/<group>/`
5. Update import paths in all files
6. Fix `path` in `PROJECT` file for each resource
7. Update test suite CRD paths (add one more `..` to relative paths)
Expand Down Expand Up @@ -200,7 +200,7 @@ kubectl logs -n <project>-system deployment/<project>-controller-manager -c mana

### Controller Design

**RBAC markers in** `internal/controller/*_controller.go`:
**RBAC markers in** `controller/*_controller.go`:

```go
// +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build the manager binary
FROM golang:1.25 AS builder
FROM golang:1.26 AS builder
ARG TARGETOS
ARG TARGETARCH

Expand All @@ -19,7 +19,7 @@ COPY . .
# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO
# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,
# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/manager/main.go

# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration

.PHONY: build
build: manifests generate fmt vet ## Build manager binary.
go build -o bin/manager cmd/main.go
go build -o bin/manager cmd/manager/main.go

.PHONY: run
run: manifests generate fmt vet ## Run a controller from your host.
go run ./cmd/main.go
go run ./cmd/manager/main.go

# If you wish to build the manager image targeting other platforms you can use the --platform flag.
# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Kubernetes Cluster API Provider STACKIT (CAPSTK)

<p align="center">
<img src="https://github.com/kubernetes/kubernetes/raw/master/logo/logo.png" width="100x"><a href="https://stackit.com/"><img width="192x" src="https://raw.githubusercontent.com/stackitcloud/cluster-api-provider-stackit/refs/heads/main/docs/src/STACKIT_Logo_RGB_Regular_Navyblue-MZ.svg" alt="STACKIT - A Brand By Schwarz Digits"></a>
<img src="https://github.com/kubernetes/kubernetes/raw/master/logo/logo.png" width="100x"><a href="https://stackit.com/"><img width="192x" src="docs/src/STACKIT_Logo_RGB_Regular_Navyblue-MZ.svg" alt="STACKIT - A Brand By Schwarz Digits"></a>
</p>

<p align="center">
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions pkg/cloud/cleanup_test.go → cloud/cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
"context"
"testing"

"github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud"
cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud/fake"
"github.com/stackitcloud/cluster-api-provider-stackit/cloud"
cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/cloud/fake"
)

func TestCleanupByTagsDeletesMatchingCloudResources(t *testing.T) {
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion pkg/cloud/fake/client.go → cloud/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"slices"
"sync"

"github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud"
"github.com/stackitcloud/cluster-api-provider-stackit/cloud"
)

const (
Expand Down
File renamed without changes.
File renamed without changes.
65 changes: 48 additions & 17 deletions pkg/cloud/sdk_client.go → cloud/sdk_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,11 @@ func (c *SDKClient) EnsureBastion(ctx context.Context, input BastionInput) (*Bas
if err != nil {
return nil, err
}
if err := c.addSecurityGroupToServer(ctx, server.ID, securityGroup.ID); err != nil {
return nil, err
}
// The security group is already part of the CreateServer payload above.
// Attaching it again here used to fail with 404 while the server had no
// port yet, and with 400 "Duplicate items in the list" once it had one —
// and because the error aborted EnsureBastion, the public IP below was
// only assigned a reconcile later.

publicIP, err := c.ensurePublicIP(ctx, input.Tags)
if err != nil {
Expand Down Expand Up @@ -698,14 +700,16 @@ func (c *SDKClient) ensureBastionSecurityGroupRules(ctx context.Context, securit
return classifySDKError("list security group rules", err)
}
existingRules := resp.GetItems()
desired := make(map[string]struct{}, len(cidrs))
for _, cidr := range cidrs {
if cidr == "" {
return fmt.Errorf("%w: empty bastion allowed CIDR", ErrInvalidInput)
}
desired[cidr] = struct{}{}
if hasSSHRule(existingRules, cidr) {
continue
}
protocol := iaas.StringAsCreateProtocol(ptrTo(tcpProtocol))
protocol := iaas.StringAsCreateProtocol(new(tcpProtocol))
rule := iaas.NewCreateSecurityGroupRulePayload("ingress")
rule.SetIpRange(cidr)
rule.SetPortRange(*iaas.NewPortRange(sshPort, sshPort))
Expand All @@ -716,6 +720,30 @@ func (c *SDKClient) ensureBastionSecurityGroupRules(ctx context.Context, securit
return classifySDKError("create security group rule", err)
}
}

// Revoke SSH rules for CIDRs that are no longer desired. Without this the
// rule set only ever grows, so narrowing allowedCIDRs would not actually
// take access away from the previously allowed range.
for _, rule := range existingRules {
if !isSSHRule(rule) {
continue
}
if _, keep := desired[rule.GetIpRange()]; keep {
continue
}
ruleID := rule.GetId()
if ruleID == "" {
continue
}
if err := c.iaasClient.DefaultAPI.
DeleteSecurityGroupRule(ctx, c.projectID, c.region, securityGroupID, ruleID).
Execute(); err != nil {
err := classifySDKError("delete security group rule", err)
if !IsNotFound(err) {
return err
}
}
}
return nil
}

Expand All @@ -733,7 +761,7 @@ func (c *SDKClient) ensureNodeSSHSecurityGroupRule(
if hasRemoteSecurityGroupSSHRule(resp.GetItems(), bastionSecurityGroupID) {
return nil
}
protocol := iaas.StringAsCreateProtocol(ptrTo(tcpProtocol))
protocol := iaas.StringAsCreateProtocol(new(tcpProtocol))
rule := iaas.NewCreateSecurityGroupRulePayload("ingress")
rule.SetRemoteSecurityGroupId(bastionSecurityGroupID)
rule.SetPortRange(*iaas.NewPortRange(sshPort, sshPort))
Expand Down Expand Up @@ -839,20 +867,27 @@ func (c *SDKClient) deleteSecurityGroupRules(ctx context.Context, securityGroupI

func hasSSHRule(rules []iaas.SecurityGroupRule, cidr string) bool {
for _, rule := range rules {
if rule.GetDirection() != "ingress" || rule.GetIpRange() != cidr {
continue
}
portRange := rule.GetPortRange()
if portRange.GetMin() != sshPort || portRange.GetMax() != sshPort {
continue
}
if protocol, ok := rule.GetProtocolOk(); ok && protocol.GetName() == tcpProtocol {
if rule.GetIpRange() == cidr && isSSHRule(rule) {
return true
}
}
return false
}

// isSSHRule reports whether the rule is an ingress TCP/22 rule, regardless of
// which CIDR it allows.
func isSSHRule(rule iaas.SecurityGroupRule) bool {
if rule.GetDirection() != "ingress" {
return false
}
portRange := rule.GetPortRange()
if portRange.GetMin() != sshPort || portRange.GetMax() != sshPort {
return false
}
protocol, ok := rule.GetProtocolOk()
return ok && protocol.GetName() == tcpProtocol
}

func hasRemoteSecurityGroupSSHRule(rules []iaas.SecurityGroupRule, remoteSecurityGroupID string) bool {
for _, rule := range rules {
if rule.GetDirection() != "ingress" || rule.GetRemoteSecurityGroupId() != remoteSecurityGroupID {
Expand Down Expand Up @@ -1037,10 +1072,6 @@ func firstNonEmpty(values ...string) string {
return ""
}

func ptrTo[T any](value T) *T {
return &value
}

func classifySDKError(op string, err error) error {
var oapiErr *oapierror.GenericOpenAPIError
if errors.As(err, &oapiErr) {
Expand Down
Loading