Crossplane and Localstack

2024-03-21

Crossplane + LocalStack on kind

Goal: Spin up a local Kubernetes cluster with Crossplane, provision AWS resources (S3, SQS, EC2, Lambda + API Gateway) against LocalStack — no real AWS account needed.

🏗️ Why Crossplane + LocalStack?

Challenge | Solution

  • Cloud bills & IAM friction for dev environments | Emulate AWS with LocalStack inside the cluster
  • Declarative multi‑cloud IaC in Git | Crossplane CRDs & GitOps
  • Fast inner‑loop testing for platform engineers | Fast inner‑loop testing for platform engineers

Lab layout

crossplane-localstack-lab/
├── bootstrap.sh              # End‑to‑end cluster + Crossplane + LocalStack installer
├── kind-config.yaml          # (Optional) custom kind config
├── Makefile                  # Convenience targets (up / down / status)
├── manifests/
│   ├── localstack-values.yaml  # Helm values
│   ├── secret.yml              # Fake AWS creds (for ProviderConfig)
│   ├── providerconfig.yml      # Points every provider at LocalStack URL
│   ├── provider‑aws‑*.yml      # Six slim providers (S3, SQS, EC2, Lambda, API GW, IAM)
│   ├── s3-bucket.yml
│   ├── sqs-queue.yml
│   ├── ec2.yml
│   ├── lambda.yml
│   ├── apigw.yml
│   └── role.yml
└── lambda/
    ├── main.py               # basic lambda function
    └── demo-lambda.zip       # artifact (zip of main.py)

⚙️ Prerequisites

  • Docker ≥ 24  (Desktop or Engine)
  • kind ≥ 0.22  brew install kind
  • kubectl ≥ 1.29
  • helm ≥ 3.14
  • awslocal (nice‑to‑have wrapper) pip install awscli-local

No AWS credentials required.

Bootstrap

#!/usr/bin/env bash
set -euo pipefail

CLUSTER_NAME=crossplane-localstack-lab
NAMESPACE=crossplane-system
LOCALSTACK_URL="http://localstack.${NAMESPACE}.svc.cluster.local:4566"

info()  { printf '\033[1;32m[+] %s\033[0m\n' "$1"; }
fail()  { printf '\033[1;31m[✗] %s\033[0m\n' "$1" >&2; exit 1; }
need()  { command -v "$1" >/dev/null || fail "'$1' not installed"; }

info "Checking CLIs …"; for c in kind kubectl helm; do need "$c"; done

info "Creating kind cluster '$CLUSTER_NAME' (idempotent) …"
kind get clusters | grep -q "^${CLUSTER_NAME}$" || kind create cluster --name "$CLUSTER_NAME"

info "Installing Crossplane …"
kubectl create ns "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm upgrade --install crossplane crossplane-stable/crossplane -n "$NAMESPACE"

info "Installing LocalStack (Helm) …"
helm repo add localstack-repo https://helm.localstack.cloud
helm upgrade --install localstack localstack-repo/localstack \
  -n "$NAMESPACE" -f manifests/localstack-values.yaml

info "Waiting for LocalStack …"
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=localstack -n "$NAMESPACE" --timeout=180s

info "Applying secrets + providers …"
for f in secret provider-aws-s3 provider-aws-sqs provider-aws-ec2 provider-aws-apigw provider-aws-lambda provider-aws-iam; do
  kubectl apply -f manifests/${f}.yml
done

info "Waiting for provider reconciliation …"
for p in s3 sqs; do
  kubectl wait provider.pkg.crossplane.io/provider-aws-${p} --for=condition=Healthy --timeout=120s || fail "provider-${p} unhealthy"
done

info "Applying ProviderConfig + sample resources …"
kubectl apply -f manifests/providerconfig.yml
kubectl apply -f manifests/s3-bucket.yml manifests/sqs-queue.yml manifests/ec2.yml manifests/role.yml manifests/lambda.yml manifests/apigw.yml

info "✅  Crossplane & LocalStack are ready.  Try: \n   kubectl get bucket,queue,instance,function -A\n   awslocal s3 ls --endpoint-url $LOCALSTACK_URL"

Give it a shebang (chmod +x bootstrap.sh) and run:

./bootstrap.sh

📜 2 — Helm Values for LocalStack

service:
  type: ClusterIP
image:
  tag: 3.3.0
# Speed up boot — no persistence needed for dev
persistence:
  enabled: false
startServices: "s3,sqs,ec2,lambda,apigateway,iam"
extraEnv:
  - name: AWS_ACCESS_KEY_ID
    value: test
  - name: AWS_SECRET_ACCESS_KEY
    value: test
  - name: DEFAULT_REGION
    value: us-east-1 # coz localstack is in us-east-1

🗝️ 3 — AWS Cred Secret

Mock secrets for Localstack

apiVersion: v1
kind: Secret
metadata:
  name: localstack-aws-creds
  namespace: crossplane-system
stringData:
  creds: |
    [default]
    aws_access_key_id = test
    aws_secret_access_key = test

🌐 4 — ProviderConfig (Points to LocalStack)

apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: localstack-aws-creds
      key: creds
  endpoint:
    url: http://localstack.crossplane-system.svc.cluster.local:4566
    hostnameImmutable: true
    signingRegion: us-east-1

📦 5 — AWS Providers

S3 Provider

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: xpkg.upbound.io/crossplane-contrib/provider-aws-s3:v0.47.0

SQS Provider

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-sqs
spec:
  package: xpkg.upbound.io/crossplane-contrib/provider-aws-sqs:v0.47.0

(…repeat for EC2, Lambda, API GW, IAM — change s3 above to the service name.)

🏗️ 6 — AWS Sample Managed Resources

S3 Bucket

apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: crossplane-test-bucket
spec:
  forProvider:
    acl: private
  providerConfigRef:
    name: default

SQS Queue

apiVersion: sqs.aws.upbound.io/v1beta1
kind: Queue
metadata:
  name: crossplane-demo-queue
spec:
  forProvider:
    delaySeconds: 0
    messageRetentionSeconds: 86400
  providerConfigRef:
    name: default

EC2 Instance

apiVersion: ec2.aws.upbound.io/v1beta1
kind: Instance
metadata:
  name: crossplane-demo
spec:
  forProvider:
    ami: "ami-12345678" ## localstack fake ami
    instanceType: t2.micro
    tags:
      Name: crossplane-demo
  providerConfigRef:
    name: default

IAM role & Lambda function

apiVersion: iam.aws.upbound.io/v1beta1
kind: Role
metadata:
  name: demo-lambda-role
spec:
  forProvider:
    assumeRolePolicy: |
      {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
  providerConfigRef:
    name: default

## Lambda func
apiVersion: lambda.aws.upbound.io/v1beta1
kind: Function
metadata:
  name: demo-lambda
spec:
  forProvider:
    region: us-east-1
    s3Bucket: crossplane-test-bucket
    s3Key: demo-lambda.zip
    handler: main.handler
    runtime: python3.9
    roleRef:
      name: demo-lambda-role
  providerConfigRef:
    name: default

API Gateway REST API & Integration

apiVersion: apigateway.aws.upbound.io/v1beta1
kind: RestAPI
metadata:
  name: demo-api
spec:
  forProvider:
    name: demo-api
    region: us-east-1
  providerConfigRef:
    name: default
---
apiVersion: apigateway.aws.upbound.io/v1beta1
kind: Deployment
metadata:
  name: demo-api-deploy
spec:
  forProvider:
    restApiIdRef:
      name: demo-api
    stageName: test
  providerConfigRef:
    name: default

(A minimal API that will auto‑wire the root “/” resource to demo-lambda using the provider’s default behaviour. For full path/method resources see the upstream examples.)

Tiny Lambda Code

def handler(event, context):
    return {
        "statusCode": 200,
        "body": "👋  from Crossplane‑managed Lambda!"
    }
pushd lambda && zip demo-lambda.zip main.py && popd

🛠️ 8 — Makefile

up: bootstrap.sh
	./bootstrap.sh

down:
	kind delete cluster --name crossplane-localstack-lab

status:
	kubectl get pkg,providerconfig,bucket,queue,instance,function -A

Testing

# Inside the cluster (CRDs)
kubectl get bucket,queue,instance,function -A

# Against LocalStack emulated AWS
export AWS_ENDPOINT=http://localhost:4566
awslocal --endpoint-url $AWS_ENDPOINT s3 ls
awslocal --endpoint-url $AWS_ENDPOINT sqs list-queues

# Hit the API Gateway - needs work.... localstack doesn't support this yet i think
API_ID=$(awslocal apigateway get-rest-apis --query 'items[?name==`demo-api`].id' --output text)
curl http://localhost:4566/restapis/$API_ID/test/_user_request_/

Clean up

make down # nuke everything