From 7fe604e61da4854e0fada61fc1464c8a94bc486f Mon Sep 17 00:00:00 2001 From: Matthias Wessendorf Date: Wed, 22 Oct 2025 14:42:36 +0200 Subject: [PATCH] feat(auth): add local development environment with Kind and Keycloak for OIDC (#354) * Initial KinD setup Signed-off-by: Matthias Wessendorf * Initial Keycloak container setup Signed-off-by: Matthias Wessendorf * Adding an initial realm setup Signed-off-by: Matthias Wessendorf * Adding OIDC issuer and realm updates, adding cert-manager and handling self-signed certificates Signed-off-by: Matthias Wessendorf * Updates to script b/c of invalid auth config Signed-off-by: Matthias Wessendorf * Adjusting ports and better support for mac/podman Signed-off-by: Matthias Wessendorf * 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 --------- Signed-off-by: Matthias Wessendorf --- Makefile | 40 ++ build/keycloak.mk | 448 ++++++++++++++++++ build/kind.mk | 61 +++ build/tools.mk | 20 + .../cert-manager/selfsigned-issuer.yaml | 31 ++ dev/config/ingress/nginx-ingress.yaml | 386 +++++++++++++++ dev/config/keycloak/deployment.yaml | 71 +++ dev/config/keycloak/ingress.yaml | 34 ++ dev/config/keycloak/rbac.yaml | 20 + dev/config/kind/cluster.yaml | 30 ++ hack/generate-placeholder-ca.sh | 22 + 11 files changed, 1163 insertions(+) create mode 100644 build/keycloak.mk create mode 100644 build/kind.mk create mode 100644 build/tools.mk create mode 100644 dev/config/cert-manager/selfsigned-issuer.yaml create mode 100644 dev/config/ingress/nginx-ingress.yaml create mode 100644 dev/config/keycloak/deployment.yaml create mode 100644 dev/config/keycloak/ingress.yaml create mode 100644 dev/config/keycloak/rbac.yaml create mode 100644 dev/config/kind/cluster.yaml create mode 100755 hack/generate-placeholder-ca.sh diff --git a/Makefile b/Makefile index fe230f1..4973044 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/build/keycloak.mk b/build/keycloak.mk new file mode 100644 index 0000000..2a7a929 --- /dev/null +++ b/build/keycloak.mk @@ -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" diff --git a/build/kind.mk b/build/kind.mk new file mode 100644 index 0000000..fe83f1a --- /dev/null +++ b/build/kind.mk @@ -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) diff --git a/build/tools.mk b/build/tools.mk new file mode 100644 index 0000000..9c9945a --- /dev/null +++ b/build/tools.mk @@ -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 diff --git a/dev/config/cert-manager/selfsigned-issuer.yaml b/dev/config/cert-manager/selfsigned-issuer.yaml new file mode 100644 index 0000000..8bb27f7 --- /dev/null +++ b/dev/config/cert-manager/selfsigned-issuer.yaml @@ -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 diff --git a/dev/config/ingress/nginx-ingress.yaml b/dev/config/ingress/nginx-ingress.yaml new file mode 100644 index 0000000..8405740 --- /dev/null +++ b/dev/config/ingress/nginx-ingress.yaml @@ -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 diff --git a/dev/config/keycloak/deployment.yaml b/dev/config/keycloak/deployment.yaml new file mode 100644 index 0000000..efcb7e0 --- /dev/null +++ b/dev/config/keycloak/deployment.yaml @@ -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 diff --git a/dev/config/keycloak/ingress.yaml b/dev/config/keycloak/ingress.yaml new file mode 100644 index 0000000..d172e09 --- /dev/null +++ b/dev/config/keycloak/ingress.yaml @@ -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 diff --git a/dev/config/keycloak/rbac.yaml b/dev/config/keycloak/rbac.yaml new file mode 100644 index 0000000..6f3f8c7 --- /dev/null +++ b/dev/config/keycloak/rbac.yaml @@ -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 diff --git a/dev/config/kind/cluster.yaml b/dev/config/kind/cluster.yaml new file mode 100644 index 0000000..d78e802 --- /dev/null +++ b/dev/config/kind/cluster.yaml @@ -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 diff --git a/hack/generate-placeholder-ca.sh b/hack/generate-placeholder-ca.sh new file mode 100755 index 0000000..5428304 --- /dev/null +++ b/hack/generate-placeholder-ca.sh @@ -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"