feat(auth): add local development environment with Kind and Keycloak for OIDC (#354)

* Initial KinD setup

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Initial Keycloak container setup

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Adding an initial realm setup

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Adding OIDC issuer and realm updates, adding cert-manager and handling self-signed certificates

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Updates to script b/c of invalid auth config

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Adjusting ports and better support for mac/podman

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

* Addressing review comments:
* do not expose all internal tasks, just keep the important targets documents
* remove the keycloak-forward
* move binaries for dev tools to _output
* generate a configuration TOML file into the _output folder

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>

---------

Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>
This commit is contained in:
Matthias Wessendorf
2025-10-22 14:42:36 +02:00
committed by GitHub
parent 0c78a1e89d
commit 7fe604e61d
11 changed files with 1163 additions and 0 deletions

View File

@@ -113,3 +113,43 @@ lint: golangci-lint ## Lint the code
.PHONY: update-readme-tools
update-readme-tools: ## Update the README.md file with the latest toolsets
go run ./internal/tools/update-readme/main.go README.md
##@ Tools
.PHONY: tools
tools: ## Install all required tools (kind) to ./_output/bin/
@echo "Checking and installing required tools to ./_output/bin/ ..."
@if [ -f _output/bin/kind ]; then echo "[OK] kind already installed"; else echo "Installing kind..."; $(MAKE) -s kind; fi
@echo "All tools ready!"
##@ Local Development
.PHONY: local-env-setup
local-env-setup: ## Setup complete local development environment with Kind cluster
@echo "========================================="
@echo "Kubernetes MCP Server - Local Setup"
@echo "========================================="
$(MAKE) tools
$(MAKE) kind-create-cluster
$(MAKE) keycloak-install
$(MAKE) build
@echo ""
@echo "========================================="
@echo "Local environment ready!"
@echo "========================================="
@echo ""
@echo "Configuration file generated:"
@echo " _output/config.toml"
@echo ""
@echo "Run the MCP server with:"
@echo " ./$(BINARY_NAME) --port 8080 --config _output/config.toml"
@echo ""
@echo "Or run with MCP inspector:"
@echo " npx @modelcontextprotocol/inspector@latest \$$(pwd)/$(BINARY_NAME) --config _output/config.toml"
.PHONY: local-env-teardown
local-env-teardown: ## Tear down the local Kind cluster
$(MAKE) kind-delete-cluster
# Include build configuration files
-include build/*.mk

448
build/keycloak.mk Normal file
View File

@@ -0,0 +1,448 @@
# Keycloak IdP for development and testing
KEYCLOAK_NAMESPACE = keycloak
KEYCLOAK_ADMIN_USER = admin
KEYCLOAK_ADMIN_PASSWORD = admin
.PHONY: keycloak-install
keycloak-install:
@echo "Installing Keycloak (dev mode using official image)..."
@kubectl apply -f dev/config/keycloak/deployment.yaml
@echo "Applying Keycloak ingress (cert-manager will create TLS certificate)..."
@kubectl apply -f dev/config/keycloak/ingress.yaml
@echo "Extracting cert-manager CA certificate..."
@mkdir -p _output/cert-manager-ca
@kubectl get secret selfsigned-ca-secret -n cert-manager -o jsonpath='{.data.ca\.crt}' | base64 -d > _output/cert-manager-ca/ca.crt
@echo "✅ cert-manager CA certificate extracted to _output/cert-manager-ca/ca.crt (bind-mounted to API server)"
@echo "Restarting Kubernetes API server to pick up new CA..."
@docker exec kubernetes-mcp-server-control-plane pkill -f kube-apiserver || \
podman exec kubernetes-mcp-server-control-plane pkill -f kube-apiserver
@echo "Waiting for API server to restart..."
@sleep 5
@echo "Waiting for API server to be ready..."
@for i in $$(seq 1 30); do \
if kubectl get --raw /healthz >/dev/null 2>&1; then \
echo "✅ Kubernetes API server updated with cert-manager CA"; \
break; \
fi; \
sleep 2; \
done
@echo "Waiting for Keycloak to be ready..."
@kubectl wait --for=condition=ready pod -l app=keycloak -n $(KEYCLOAK_NAMESPACE) --timeout=120s || true
@echo "Waiting for Keycloak HTTP endpoint to be available..."
@for i in $$(seq 1 30); do \
STATUS=$$(curl -sk -o /dev/null -w "%{http_code}" https://keycloak.127-0-0-1.sslip.io:8443/realms/master 2>/dev/null || echo "000"); \
if [ "$$STATUS" = "200" ]; then \
echo "✅ Keycloak HTTP endpoint ready"; \
break; \
fi; \
echo " Attempt $$i/30: Waiting for Keycloak (status: $$STATUS)..."; \
sleep 3; \
done
@echo ""
@echo "Setting up OpenShift realm..."
@$(MAKE) -s keycloak-setup-realm
@echo ""
@echo "✅ Keycloak installed and configured!"
@echo "Access at: https://keycloak.127-0-0-1.sslip.io:8443"
.PHONY: keycloak-uninstall
keycloak-uninstall:
@kubectl delete -f dev/config/keycloak/deployment.yaml 2>/dev/null || true
.PHONY: keycloak-status
keycloak-status: ## Show Keycloak status and connection info
@if kubectl get svc -n $(KEYCLOAK_NAMESPACE) keycloak >/dev/null 2>&1; then \
echo "========================================"; \
echo "Keycloak Status"; \
echo "========================================"; \
echo ""; \
echo "Status: Installed"; \
echo ""; \
echo "Admin Console:"; \
echo " URL: https://keycloak.127-0-0-1.sslip.io:8443"; \
echo " Username: $(KEYCLOAK_ADMIN_USER)"; \
echo " Password: $(KEYCLOAK_ADMIN_PASSWORD)"; \
echo ""; \
echo "OIDC Endpoints (openshift realm):"; \
echo " Discovery: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift/.well-known/openid-configuration"; \
echo " Token: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift/protocol/openid-connect/token"; \
echo " Authorize: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift/protocol/openid-connect/auth"; \
echo " UserInfo: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift/protocol/openid-connect/userinfo"; \
echo " JWKS: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift/protocol/openid-connect/certs"; \
echo ""; \
echo "========================================"; \
else \
echo "Keycloak is not installed. Run: make keycloak-install"; \
fi
.PHONY: keycloak-logs
keycloak-logs: ## Tail Keycloak logs
@kubectl logs -n $(KEYCLOAK_NAMESPACE) -l app=keycloak -f --tail=100
.PHONY: keycloak-setup-realm
keycloak-setup-realm:
@echo "========================================="
@echo "Setting up OpenShift Realm for Token Exchange"
@echo "========================================="
@echo "Using Keycloak at https://keycloak.127-0-0-1.sslip.io:8443"
@echo ""
@echo "Getting admin access token..."
@RESPONSE=$$(curl -sk -X POST "https://keycloak.127-0-0-1.sslip.io:8443/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=$(KEYCLOAK_ADMIN_USER)" \
-d "password=$(KEYCLOAK_ADMIN_PASSWORD)" \
-d "grant_type=password" \
-d "client_id=admin-cli"); \
TOKEN=$$(echo "$$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null); \
if [ -z "$$TOKEN" ] || [ "$$TOKEN" = "null" ]; then \
echo "❌ Failed to get access token"; \
echo "Response was: $$RESPONSE" | head -c 200; \
echo ""; \
echo "Check if:"; \
echo " - Keycloak is running (make keycloak-install)"; \
echo " - Keycloak is accessible at https://keycloak.127-0-0-1.sslip.io:8443"; \
echo " - Admin credentials are correct: $(KEYCLOAK_ADMIN_USER)/$(KEYCLOAK_ADMIN_PASSWORD)"; \
exit 1; \
fi; \
echo "✅ Successfully obtained access token"; \
echo ""; \
echo "Creating OpenShift realm..."; \
REALM_RESPONSE=$$(curl -sk -w "%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"realm":"openshift","enabled":true}'); \
REALM_CODE=$$(echo "$$REALM_RESPONSE" | tail -c 4); \
if [ "$$REALM_CODE" = "201" ] || [ "$$REALM_CODE" = "409" ]; then \
if [ "$$REALM_CODE" = "201" ]; then echo "✅ OpenShift realm created"; \
else echo "✅ OpenShift realm already exists"; fi; \
else \
echo "❌ Failed to create OpenShift realm (HTTP $$REALM_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Configuring realm events..."; \
EVENT_CONFIG_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X PUT "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"realm":"openshift","enabled":true,"eventsEnabled":true,"eventsListeners":["jboss-logging"],"adminEventsEnabled":true,"adminEventsDetailsEnabled":true}'); \
EVENT_CONFIG_CODE=$$(echo "$$EVENT_CONFIG_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$EVENT_CONFIG_CODE" = "204" ]; then \
echo "✅ User and admin event logging enabled"; \
else \
echo "⚠️ Could not configure event logging (HTTP $$EVENT_CONFIG_CODE)"; \
fi; \
echo ""; \
echo "Creating mcp:openshift client scope..."; \
SCOPE_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"mcp:openshift","protocol":"openid-connect","attributes":{"display.on.consent.screen":"false","include.in.token.scope":"true"}}'); \
SCOPE_CODE=$$(echo "$$SCOPE_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$SCOPE_CODE" = "201" ] || [ "$$SCOPE_CODE" = "409" ]; then \
if [ "$$SCOPE_CODE" = "201" ]; then echo "✅ mcp:openshift client scope created"; \
else echo "✅ mcp:openshift client scope already exists"; fi; \
else \
echo "❌ Failed to create mcp:openshift scope (HTTP $$SCOPE_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Adding audience mapper to mcp:openshift scope..."; \
SCOPES_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
SCOPE_ID=$$(echo "$$SCOPES_LIST" | jq -r '.[] | select(.name == "mcp:openshift") | .id // empty' 2>/dev/null); \
if [ -z "$$SCOPE_ID" ]; then \
echo "❌ Failed to find mcp:openshift scope"; \
exit 1; \
fi; \
MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes/$$SCOPE_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"openshift-audience","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","config":{"included.client.audience":"openshift","id.token.claim":"true","access.token.claim":"true"}}'); \
MAPPER_CODE=$$(echo "$$MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MAPPER_CODE" = "201" ] || [ "$$MAPPER_CODE" = "409" ]; then \
if [ "$$MAPPER_CODE" = "201" ]; then echo "✅ Audience mapper added"; \
else echo "✅ Audience mapper already exists"; fi; \
else \
echo "❌ Failed to create audience mapper (HTTP $$MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating groups client scope..."; \
GROUPS_SCOPE_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"groups","protocol":"openid-connect","attributes":{"display.on.consent.screen":"false","include.in.token.scope":"true"}}'); \
GROUPS_SCOPE_CODE=$$(echo "$$GROUPS_SCOPE_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$GROUPS_SCOPE_CODE" = "201" ] || [ "$$GROUPS_SCOPE_CODE" = "409" ]; then \
if [ "$$GROUPS_SCOPE_CODE" = "201" ]; then echo "✅ groups client scope created"; \
else echo "✅ groups client scope already exists"; fi; \
else \
echo "❌ Failed to create groups scope (HTTP $$GROUPS_SCOPE_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Adding group membership mapper to groups scope..."; \
SCOPES_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
GROUPS_SCOPE_ID=$$(echo "$$SCOPES_LIST" | jq -r '.[] | select(.name == "groups") | .id // empty' 2>/dev/null); \
if [ -z "$$GROUPS_SCOPE_ID" ]; then \
echo "❌ Failed to find groups scope"; \
exit 1; \
fi; \
GROUPS_MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes/$$GROUPS_SCOPE_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"groups","protocol":"openid-connect","protocolMapper":"oidc-group-membership-mapper","config":{"claim.name":"groups","full.path":"false","id.token.claim":"true","access.token.claim":"true","userinfo.token.claim":"true"}}'); \
GROUPS_MAPPER_CODE=$$(echo "$$GROUPS_MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$GROUPS_MAPPER_CODE" = "201" ] || [ "$$GROUPS_MAPPER_CODE" = "409" ]; then \
if [ "$$GROUPS_MAPPER_CODE" = "201" ]; then echo "✅ Group membership mapper added"; \
else echo "✅ Group membership mapper already exists"; fi; \
else \
echo "❌ Failed to create group mapper (HTTP $$GROUPS_MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating mcp-server client scope..."; \
MCP_SERVER_SCOPE_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"mcp-server","protocol":"openid-connect","attributes":{"display.on.consent.screen":"false","include.in.token.scope":"true"}}'); \
MCP_SERVER_SCOPE_CODE=$$(echo "$$MCP_SERVER_SCOPE_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_SERVER_SCOPE_CODE" = "201" ] || [ "$$MCP_SERVER_SCOPE_CODE" = "409" ]; then \
if [ "$$MCP_SERVER_SCOPE_CODE" = "201" ]; then echo "✅ mcp-server client scope created"; \
else echo "✅ mcp-server client scope already exists"; fi; \
else \
echo "❌ Failed to create mcp-server scope (HTTP $$MCP_SERVER_SCOPE_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Adding audience mapper to mcp-server scope..."; \
SCOPES_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
MCP_SERVER_SCOPE_ID=$$(echo "$$SCOPES_LIST" | jq -r '.[] | select(.name == "mcp-server") | .id // empty' 2>/dev/null); \
if [ -z "$$MCP_SERVER_SCOPE_ID" ]; then \
echo "❌ Failed to find mcp-server scope"; \
exit 1; \
fi; \
MCP_SERVER_MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/client-scopes/$$MCP_SERVER_SCOPE_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"mcp-server-audience","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","config":{"included.client.audience":"mcp-server","id.token.claim":"true","access.token.claim":"true"}}'); \
MCP_SERVER_MAPPER_CODE=$$(echo "$$MCP_SERVER_MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_SERVER_MAPPER_CODE" = "201" ] || [ "$$MCP_SERVER_MAPPER_CODE" = "409" ]; then \
if [ "$$MCP_SERVER_MAPPER_CODE" = "201" ]; then echo "✅ mcp-server audience mapper added"; \
else echo "✅ mcp-server audience mapper already exists"; fi; \
else \
echo "❌ Failed to create mcp-server audience mapper (HTTP $$MCP_SERVER_MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating openshift service client..."; \
OPENSHIFT_CLIENT_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"clientId":"openshift","enabled":true,"publicClient":false,"standardFlowEnabled":true,"directAccessGrantsEnabled":true,"serviceAccountsEnabled":true,"authorizationServicesEnabled":false,"redirectUris":["*"],"defaultClientScopes":["profile","email","groups"],"optionalClientScopes":[]}'); \
OPENSHIFT_CLIENT_CODE=$$(echo "$$OPENSHIFT_CLIENT_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$OPENSHIFT_CLIENT_CODE" = "201" ] || [ "$$OPENSHIFT_CLIENT_CODE" = "409" ]; then \
if [ "$$OPENSHIFT_CLIENT_CODE" = "201" ]; then echo "✅ openshift client created"; \
else echo "✅ openshift client already exists"; fi; \
else \
echo "❌ Failed to create openshift client (HTTP $$OPENSHIFT_CLIENT_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Adding username mapper to openshift client..."; \
OPENSHIFT_CLIENTS_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
OPENSHIFT_CLIENT_ID=$$(echo "$$OPENSHIFT_CLIENTS_LIST" | jq -r '.[] | select(.clientId == "openshift") | .id // empty' 2>/dev/null); \
OPENSHIFT_USERNAME_MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients/$$OPENSHIFT_CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name":"username","protocol":"openid-connect","protocolMapper":"oidc-usermodel-property-mapper","config":{"userinfo.token.claim":"true","user.attribute":"username","id.token.claim":"true","access.token.claim":"true","claim.name":"preferred_username","jsonType.label":"String"}}'); \
OPENSHIFT_USERNAME_MAPPER_CODE=$$(echo "$$OPENSHIFT_USERNAME_MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$OPENSHIFT_USERNAME_MAPPER_CODE" = "201" ] || [ "$$OPENSHIFT_USERNAME_MAPPER_CODE" = "409" ]; then \
if [ "$$OPENSHIFT_USERNAME_MAPPER_CODE" = "201" ]; then echo "✅ Username mapper added to openshift client"; \
else echo "✅ Username mapper already exists on openshift client"; fi; \
else \
echo "❌ Failed to create username mapper (HTTP $$OPENSHIFT_USERNAME_MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating mcp-client public client..."; \
MCP_PUBLIC_CLIENT_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"clientId":"mcp-client","enabled":true,"publicClient":true,"standardFlowEnabled":true,"directAccessGrantsEnabled":true,"serviceAccountsEnabled":false,"authorizationServicesEnabled":false,"redirectUris":["*"],"defaultClientScopes":["profile","email"],"optionalClientScopes":["mcp-server"]}'); \
MCP_PUBLIC_CLIENT_CODE=$$(echo "$$MCP_PUBLIC_CLIENT_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_PUBLIC_CLIENT_CODE" = "201" ] || [ "$$MCP_PUBLIC_CLIENT_CODE" = "409" ]; then \
if [ "$$MCP_PUBLIC_CLIENT_CODE" = "201" ]; then echo "✅ mcp-client public client created"; \
else echo "✅ mcp-client public client already exists"; fi; \
else \
echo "❌ Failed to create mcp-client public client (HTTP $$MCP_PUBLIC_CLIENT_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Adding username mapper to mcp-client..."; \
MCP_PUBLIC_CLIENTS_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
MCP_PUBLIC_CLIENT_ID=$$(echo "$$MCP_PUBLIC_CLIENTS_LIST" | jq -r '.[] | select(.clientId == "mcp-client") | .id // empty' 2>/dev/null); \
MCP_PUBLIC_USERNAME_MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients/$$MCP_PUBLIC_CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"username","protocol":"openid-connect","protocolMapper":"oidc-usermodel-property-mapper","config":{"userinfo.token.claim":"true","user.attribute":"username","id.token.claim":"true","access.token.claim":"true","claim.name":"preferred_username","jsonType.label":"String"}}'); \
MCP_PUBLIC_USERNAME_MAPPER_CODE=$$(echo "$$MCP_PUBLIC_USERNAME_MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_PUBLIC_USERNAME_MAPPER_CODE" = "201" ] || [ "$$MCP_PUBLIC_USERNAME_MAPPER_CODE" = "409" ]; then \
if [ "$$MCP_PUBLIC_USERNAME_MAPPER_CODE" = "201" ]; then echo "✅ Username mapper added to mcp-client"; \
else echo "✅ Username mapper already exists on mcp-client"; fi; \
else \
echo "❌ Failed to create username mapper (HTTP $$MCP_PUBLIC_USERNAME_MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating mcp-server client with token exchange..."; \
MCP_CLIENT_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"clientId":"mcp-server","enabled":true,"publicClient":false,"standardFlowEnabled":true,"directAccessGrantsEnabled":true,"serviceAccountsEnabled":true,"authorizationServicesEnabled":false,"redirectUris":["*"],"defaultClientScopes":["profile","email","groups","mcp-server"],"optionalClientScopes":["mcp:openshift"],"attributes":{"oauth2.device.authorization.grant.enabled":"false","oidc.ciba.grant.enabled":"false","backchannel.logout.session.required":"true","backchannel.logout.revoke.offline.tokens":"false"}}'); \
MCP_CLIENT_CODE=$$(echo "$$MCP_CLIENT_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_CLIENT_CODE" = "201" ] || [ "$$MCP_CLIENT_CODE" = "409" ]; then \
if [ "$$MCP_CLIENT_CODE" = "201" ]; then echo "✅ mcp-server client created"; \
else echo "✅ mcp-server client already exists"; fi; \
else \
echo "❌ Failed to create mcp-server client (HTTP $$MCP_CLIENT_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Enabling standard token exchange for mcp-server..."; \
CLIENTS_LIST=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
MCP_CLIENT_ID=$$(echo "$$CLIENTS_LIST" | jq -r '.[] | select(.clientId == "mcp-server") | .id // empty' 2>/dev/null); \
if [ -z "$$MCP_CLIENT_ID" ]; then \
echo "❌ Failed to find mcp-server client"; \
exit 1; \
fi; \
UPDATE_CLIENT_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X PUT "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients/$$MCP_CLIENT_ID" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"clientId":"mcp-server","enabled":true,"publicClient":false,"standardFlowEnabled":true,"directAccessGrantsEnabled":true,"serviceAccountsEnabled":true,"authorizationServicesEnabled":false,"redirectUris":["*"],"defaultClientScopes":["profile","email","groups","mcp-server"],"optionalClientScopes":["mcp:openshift"],"attributes":{"oauth2.device.authorization.grant.enabled":"false","oidc.ciba.grant.enabled":"false","backchannel.logout.session.required":"true","backchannel.logout.revoke.offline.tokens":"false","standard.token.exchange.enabled":"true"}}'); \
UPDATE_CLIENT_CODE=$$(echo "$$UPDATE_CLIENT_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$UPDATE_CLIENT_CODE" = "204" ]; then \
echo "✅ Standard token exchange enabled for mcp-server client"; \
else \
echo "⚠️ Could not enable token exchange (HTTP $$UPDATE_CLIENT_CODE)"; \
fi; \
echo ""; \
echo "Getting mcp-server client secret..."; \
SECRET_RESPONSE=$$(curl -sk -X GET "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients/$$MCP_CLIENT_ID/client-secret" \
-H "Authorization: Bearer $$TOKEN" \
-H "Accept: application/json"); \
CLIENT_SECRET=$$(echo "$$SECRET_RESPONSE" | jq -r '.value // empty' 2>/dev/null); \
if [ -z "$$CLIENT_SECRET" ]; then \
echo "❌ Failed to get client secret"; \
else \
echo "✅ Client secret retrieved"; \
fi; \
echo ""; \
echo "Adding username mapper to mcp-server client..."; \
MCP_USERNAME_MAPPER_RESPONSE=$$(curl -sk -w "HTTPCODE:%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/clients/$$MCP_CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"username","protocol":"openid-connect","protocolMapper":"oidc-usermodel-property-mapper","config":{"userinfo.token.claim":"true","user.attribute":"username","id.token.claim":"true","access.token.claim":"true","claim.name":"preferred_username","jsonType.label":"String"}}'); \
MCP_USERNAME_MAPPER_CODE=$$(echo "$$MCP_USERNAME_MAPPER_RESPONSE" | grep -o "HTTPCODE:[0-9]*" | cut -d: -f2); \
if [ "$$MCP_USERNAME_MAPPER_CODE" = "201" ] || [ "$$MCP_USERNAME_MAPPER_CODE" = "409" ]; then \
if [ "$$MCP_USERNAME_MAPPER_CODE" = "201" ]; then echo "✅ Username mapper added to mcp-server client"; \
else echo "✅ Username mapper already exists on mcp-server client"; fi; \
else \
echo "❌ Failed to create username mapper (HTTP $$MCP_USERNAME_MAPPER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Creating test user mcp/mcp..."; \
USER_RESPONSE=$$(curl -sk -w "%{http_code}" -X POST "https://keycloak.127-0-0-1.sslip.io:8443/admin/realms/openshift/users" \
-H "Authorization: Bearer $$TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"mcp","email":"mcp@example.com","firstName":"MCP","lastName":"User","enabled":true,"emailVerified":true,"credentials":[{"type":"password","value":"mcp","temporary":false}]}'); \
USER_CODE=$$(echo "$$USER_RESPONSE" | tail -c 4); \
if [ "$$USER_CODE" = "201" ] || [ "$$USER_CODE" = "409" ]; then \
if [ "$$USER_CODE" = "201" ]; then echo "✅ mcp user created"; \
else echo "✅ mcp user already exists"; fi; \
else \
echo "❌ Failed to create mcp user (HTTP $$USER_CODE)"; \
exit 1; \
fi; \
echo ""; \
echo "Setting up RBAC for mcp user..."; \
kubectl apply -f dev/config/keycloak/rbac.yaml; \
echo "✅ RBAC binding created for mcp user"; \
echo ""; \
echo "🎉 OpenShift realm setup complete!"; \
echo ""; \
echo "========================================"; \
echo "Configuration Summary"; \
echo "========================================"; \
echo "Realm: openshift"; \
echo "Authorization URL: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift"; \
echo "Issuer URL (for config.toml): https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift"; \
echo ""; \
echo "Test User:"; \
echo " Username: mcp"; \
echo " Password: mcp"; \
echo " Email: mcp@example.com"; \
echo " RBAC: cluster-admin (full cluster access)"; \
echo ""; \
echo "Clients:"; \
echo " mcp-client (public, for browser-based auth)"; \
echo " Client ID: mcp-client"; \
echo " Optional Scopes: mcp-server"; \
echo " mcp-server (confidential, token exchange enabled)"; \
echo " Client ID: mcp-server"; \
echo " Client Secret: $$CLIENT_SECRET"; \
echo " openshift (service account)"; \
echo " Client ID: openshift"; \
echo ""; \
echo "Client Scopes:"; \
echo " mcp-server (default) - Audience: mcp-server"; \
echo " mcp:openshift (optional) - Audience: openshift"; \
echo " groups (default) - Group membership mapper"; \
echo ""; \
echo "TOML Configuration (config.toml):"; \
echo " require_oauth = true"; \
echo " oauth_audience = \"mcp-server\""; \
echo " oauth_scopes = [\"openid\", \"mcp-server\"]"; \
echo " validate_token = false"; \
echo " authorization_url = \"https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift\""; \
echo " sts_client_id = \"mcp-server\""; \
echo " sts_client_secret = \"$$CLIENT_SECRET\""; \
echo " sts_audience = \"openshift\""; \
echo " sts_scopes = [\"mcp:openshift\"]"; \
echo " certificate_authority = \"_output/cert-manager-ca/ca.crt\""; \
echo "========================================"; \
echo ""; \
echo "Note: The Kubernetes API server is configured with:"; \
echo " --oidc-issuer-url=https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift"; \
echo ""; \
echo "Important: The cert-manager CA certificate was extracted to:"; \
echo " _output/cert-manager-ca/ca.crt"; \
echo ""; \
echo "Writing configuration to _output/config.toml..."; \
mkdir -p _output; \
printf '%s\n' \
'require_oauth = true' \
'oauth_audience = "mcp-server"' \
'oauth_scopes = ["openid", "mcp-server"]' \
'validate_token = false' \
'authorization_url = "https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift"' \
'sts_client_id = "mcp-server"' \
"sts_client_secret = \"$$CLIENT_SECRET\"" \
'sts_audience = "openshift"' \
'sts_scopes = ["mcp:openshift"]' \
'certificate_authority = "_output/cert-manager-ca/ca.crt"' \
> _output/config.toml; \
echo "✅ Configuration written to _output/config.toml"

61
build/kind.mk Normal file
View File

@@ -0,0 +1,61 @@
# Kind cluster management
KIND_CLUSTER_NAME ?= kubernetes-mcp-server
# Detect container engine (docker or podman)
CONTAINER_ENGINE ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
.PHONY: kind-create-certs
kind-create-certs:
@if [ ! -f _output/cert-manager-ca/ca.crt ]; then \
echo "Creating placeholder CA certificate for bind mount..."; \
./hack/generate-placeholder-ca.sh; \
else \
echo "✅ Placeholder CA already exists"; \
fi
.PHONY: kind-create-cluster
kind-create-cluster: kind kind-create-certs
@# Set KIND provider for podman on Linux
@if [ "$(shell uname -s)" != "Darwin" ] && echo "$(CONTAINER_ENGINE)" | grep -q "podman"; then \
export KIND_EXPERIMENTAL_PROVIDER=podman; \
fi; \
if $(KIND) get clusters 2>/dev/null | grep -q "^$(KIND_CLUSTER_NAME)$$"; then \
echo "Kind cluster '$(KIND_CLUSTER_NAME)' already exists, skipping creation"; \
else \
echo "Creating Kind cluster '$(KIND_CLUSTER_NAME)'..."; \
$(KIND) create cluster --name $(KIND_CLUSTER_NAME) --config dev/config/kind/cluster.yaml; \
echo "Adding ingress-ready label to control-plane node..."; \
kubectl label node $(KIND_CLUSTER_NAME)-control-plane ingress-ready=true --overwrite; \
echo "Installing nginx ingress controller..."; \
kubectl apply -f dev/config/ingress/nginx-ingress.yaml; \
echo "Waiting for ingress controller to be ready..."; \
kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=90s; \
echo "✅ Ingress controller ready"; \
echo "Installing cert-manager..."; \
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml; \
echo "Waiting for cert-manager to be ready..."; \
kubectl wait --namespace cert-manager --for=condition=available deployment/cert-manager --timeout=120s; \
kubectl wait --namespace cert-manager --for=condition=available deployment/cert-manager-cainjector --timeout=120s; \
kubectl wait --namespace cert-manager --for=condition=available deployment/cert-manager-webhook --timeout=120s; \
echo "✅ cert-manager ready"; \
echo "Creating cert-manager ClusterIssuer..."; \
sleep 5; \
kubectl apply -f dev/config/cert-manager/selfsigned-issuer.yaml; \
echo "✅ ClusterIssuer created"; \
echo "Adding /etc/hosts entry for Keycloak in control plane..."; \
if command -v docker >/dev/null 2>&1 && docker ps --filter "name=$(KIND_CLUSTER_NAME)-control-plane" --format "{{.Names}}" | grep -q "$(KIND_CLUSTER_NAME)-control-plane"; then \
docker exec $(KIND_CLUSTER_NAME)-control-plane bash -c 'grep -q "keycloak.127-0-0-1.sslip.io" /etc/hosts || echo "127.0.0.1 keycloak.127-0-0-1.sslip.io" >> /etc/hosts'; \
elif command -v podman >/dev/null 2>&1 && podman ps --filter "name=$(KIND_CLUSTER_NAME)-control-plane" --format "{{.Names}}" | grep -q "$(KIND_CLUSTER_NAME)-control-plane"; then \
podman exec $(KIND_CLUSTER_NAME)-control-plane bash -c 'grep -q "keycloak.127-0-0-1.sslip.io" /etc/hosts || echo "127.0.0.1 keycloak.127-0-0-1.sslip.io" >> /etc/hosts'; \
fi; \
echo "✅ /etc/hosts entry added"; \
fi
.PHONY: kind-delete-cluster
kind-delete-cluster: kind
@# Set KIND provider for podman on Linux
@if [ "$(shell uname -s)" != "Darwin" ] && echo "$(CONTAINER_ENGINE)" | grep -q "podman"; then \
export KIND_EXPERIMENTAL_PROVIDER=podman; \
fi; \
$(KIND) delete cluster --name $(KIND_CLUSTER_NAME)

20
build/tools.mk Normal file
View File

@@ -0,0 +1,20 @@
# Tools
# Platform detection
OS := $(shell uname -s | tr '[:upper:]' '[:lower:]')
ARCH := $(shell uname -m | tr '[:upper:]' '[:lower:]')
ifeq ($(ARCH),x86_64)
ARCH = amd64
endif
ifeq ($(ARCH),aarch64)
ARCH = arm64
endif
KIND = _output/bin/kind
KIND_VERSION = v0.30.0
$(KIND):
@mkdir -p _output/bin
GOBIN=$(PWD)/_output/bin go install sigs.k8s.io/kind@$(KIND_VERSION)
.PHONY: kind
kind: $(KIND) ## Download kind locally if necessary

View File

@@ -0,0 +1,31 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-issuer
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: selfsigned-ca
namespace: cert-manager
spec:
isCA: true
commonName: selfsigned-ca
secretName: selfsigned-ca-secret
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-issuer
kind: ClusterIssuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-ca-issuer
spec:
ca:
secretName: selfsigned-ca-secret

View File

@@ -0,0 +1,386 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: ingress-nginx
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
---
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx
namespace: ingress-nginx
---
apiVersion: v1
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx-controller
namespace: ingress-nginx
data:
allow-snippet-annotations: "true"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
name: ingress-nginx
rules:
- apiGroups:
- ""
resources:
- configmaps
- endpoints
- nodes
- pods
- secrets
- namespaces
verbs:
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- list
- watch
- apiGroups:
- ""
resources:
- nodes
verbs:
- get
- apiGroups:
- ""
resources:
- services
verbs:
- get
- list
- watch
- apiGroups:
- networking.k8s.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- networking.k8s.io
resources:
- ingresses/status
verbs:
- update
- apiGroups:
- networking.k8s.io
resources:
- ingressclasses
verbs:
- get
- list
- watch
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- list
- watch
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
name: ingress-nginx
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: ingress-nginx
subjects:
- kind: ServiceAccount
name: ingress-nginx
namespace: ingress-nginx
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx
namespace: ingress-nginx
rules:
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- apiGroups:
- ""
resources:
- configmaps
- pods
- secrets
- endpoints
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- services
verbs:
- get
- list
- watch
- apiGroups:
- networking.k8s.io
resources:
- ingresses
verbs:
- get
- list
- watch
- apiGroups:
- networking.k8s.io
resources:
- ingresses/status
verbs:
- update
- apiGroups:
- networking.k8s.io
resources:
- ingressclasses
verbs:
- get
- list
- watch
- apiGroups:
- coordination.k8s.io
resources:
- leases
resourceNames:
- ingress-nginx-leader
verbs:
- get
- update
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- create
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- list
- watch
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx
namespace: ingress-nginx
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ingress-nginx
subjects:
- kind: ServiceAccount
name: ingress-nginx
namespace: ingress-nginx
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
type: NodePort
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
appProtocol: http
- name: https
port: 443
protocol: TCP
targetPort: https
appProtocol: https
selector:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
selector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
replicas: 1
revisionHistoryLimit: 10
minReadySeconds: 0
template:
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
spec:
dnsPolicy: ClusterFirst
containers:
- name: controller
image: registry.k8s.io/ingress-nginx/controller:v1.11.1
imagePullPolicy: IfNotPresent
lifecycle:
preStop:
exec:
command:
- /wait-shutdown
args:
- /nginx-ingress-controller
- --election-id=ingress-nginx-leader
- --controller-class=k8s.io/ingress-nginx
- --ingress-class=nginx
- --configmap=$(POD_NAMESPACE)/ingress-nginx-controller
- --watch-ingress-without-class=true
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: LD_PRELOAD
value: /usr/local/lib/libmimalloc.so
livenessProbe:
failureThreshold: 5
httpGet:
path: /healthz
port: 10254
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
readinessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: 10254
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
ports:
- name: http
containerPort: 80
protocol: TCP
hostPort: 80
- name: https
containerPort: 443
protocol: TCP
hostPort: 443
- name: https-alt
containerPort: 443
protocol: TCP
hostPort: 8443
- name: webhook
containerPort: 8443
protocol: TCP
resources:
requests:
cpu: 100m
memory: 90Mi
nodeSelector:
ingress-ready: "true"
kubernetes.io/os: linux
serviceAccountName: ingress-nginx
terminationGracePeriodSeconds: 0
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/master
operator: Equal
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
operator: Equal
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
labels:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/component: controller
name: nginx
spec:
controller: k8s.io/ingress-nginx

View File

@@ -0,0 +1,71 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: keycloak
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
spec:
replicas: 1
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
spec:
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.4
args: ["start-dev"]
env:
- name: KC_BOOTSTRAP_ADMIN_USERNAME
value: "admin"
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
value: "admin"
- name: KC_HOSTNAME
value: "https://keycloak.127-0-0-1.sslip.io:8443"
- name: KC_HTTP_ENABLED
value: "true"
- name: KC_HEALTH_ENABLED
value: "true"
- name: KC_PROXY_HEADERS
value: "xforwarded"
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 9000
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 9000
initialDelaySeconds: 60
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
spec:
ports:
- name: http
port: 80
targetPort: 8080
selector:
app: keycloak
type: ClusterIP

View File

@@ -0,0 +1,34 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak
namespace: keycloak
labels:
app: keycloak
annotations:
cert-manager.io/cluster-issuer: "selfsigned-ca-issuer"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
# Required for Keycloak 26.2.0+ to include port in issuer URLs
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8443;
proxy_set_header X-Forwarded-Host $host:8443;
spec:
ingressClassName: nginx
tls:
- hosts:
- keycloak.127-0-0-1.sslip.io
secretName: keycloak-tls-cert
rules:
- host: keycloak.127-0-0-1.sslip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 80

View File

@@ -0,0 +1,20 @@
# RBAC ClusterRoleBinding for mcp user with OIDC authentication
#
# IMPORTANT: This requires Kubernetes API server to be configured with OIDC:
# --oidc-issuer-url=https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift
# --oidc-username-claim=preferred_username
#
# Without OIDC configuration, this binding will not work.
#
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: oidc-mcp-cluster-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift#mcp

View File

@@ -0,0 +1,30 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraMounts:
- hostPath: ./_output/cert-manager-ca/ca.crt
containerPath: /etc/kubernetes/pki/keycloak-ca.crt
readOnly: true
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
kind: ClusterConfiguration
apiServer:
extraArgs:
oidc-issuer-url: https://keycloak.127-0-0-1.sslip.io:8443/realms/openshift
oidc-client-id: openshift
oidc-username-claim: preferred_username
oidc-groups-claim: groups
oidc-ca-file: /etc/kubernetes/pki/keycloak-ca.crt
extraPortMappings:
- containerPort: 80
hostPort: 8080
protocol: TCP
- containerPort: 443
hostPort: 8443
protocol: TCP

22
hack/generate-placeholder-ca.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# Generate a placeholder self-signed CA certificate for KIND cluster startup
# This will be replaced with the real cert-manager CA after the cluster is created
CERT_DIR="_output/cert-manager-ca"
CA_CERT="$CERT_DIR/ca.crt"
CA_KEY="$CERT_DIR/ca.key"
mkdir -p "$CERT_DIR"
# Generate a self-signed CA certificate (valid placeholder)
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$CA_KEY" \
-out "$CA_CERT" \
-days 365 \
-subj "/CN=placeholder-ca" \
2>/dev/null
echo "✅ Placeholder CA certificate created at $CA_CERT"
echo "⚠️ This will be replaced with cert-manager CA after cluster creation"