Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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