diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..925520b --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +echo "Running tests before commit..." + +# Change to the repository root +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# Check if endpoint-exposer tests need to be run (if any endpoint-exposer files changed) +if git diff --cached --name-only | grep -q "^endpoint-exposer/"; then + echo "Endpoint-exposer files changed, running tests..." + + if command -v bats &> /dev/null; then + cd endpoint-exposer + bats test/ + else + echo "⚠️ BATS not installed, skipping tests" + echo "Install BATS: brew install bats-core (macOS) or see https://bats-core.readthedocs.io" + exit 0 + fi + + echo "✅ All endpoint-exposer tests passed!" +fi diff --git a/.gitignore b/.gitignore index 4f27a85..4c3570f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ *.iml out gen + +# VSCode project files +.vscode/ \ No newline at end of file diff --git a/endpoint-exposer/README.md b/endpoint-exposer/README.md new file mode 100644 index 0000000..db45d7d --- /dev/null +++ b/endpoint-exposer/README.md @@ -0,0 +1,233 @@ +# Endpoint Exposer Service + +## Overview + +The **endpoint-exposer** service is a critical infrastructure component of Nullplatform that manages dynamic exposure of application endpoints through public and private domains. It functions as a route orchestrator that translates high-level specifications into native Kubernetes configurations using Istio Service Mesh. + +## Core Responsibilities + +### 1. Dynamic Endpoint Management +- Expose application endpoints declaratively +- Configure separate public and private domains for different access levels +- Update route configurations with zero downtime +- Maintain configuration synchronized with desired state + +### 2. Route Configuration +- Define route patterns (exact, regex, wildcards) +- Specify allowed HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) +- Associate routes with Nullplatform scopes for access control +- Control route visibility (public vs. private) + +### 3. Kubernetes and Istio Integration +- Generate HTTPRoute resources (Kubernetes Gateway API v1) +- Create Istio AuthorizationPolicies for access control +- Configure RequestAuthentication for JWT validation +- Apply routing policies in the service mesh + +### 4. Scope-Based Access Control +- Map endpoints to specific Nullplatform scopes +- Integrate with Open Policy Agent (OPA) for external authorization +- Implement granular authorization policies per route +- Validate JWT tokens according to scope configuration + +## Key Features + +### Route Management +```yaml +routes: + - method: GET + path: /api/users + scope: user-management + visible_on: public + enable_authorization: true +``` + +- **Path Types**: + - Exact: `/api/users` + - Regex with parameters: `/api/users/{id}` + - Wildcard: `/api/users/*` + +- **HTTP Methods**: Supports all standard HTTP methods +- **Visibility**: Public or private routes on separate domains +- **Authorization**: Optional per-route with JWT and OPA validation + +### Domain Separation + +**Public Domain:** +- Endpoints accessible from the internet +- Typically for public APIs and unauthenticated resources +- Connected to `gateway-public` gateway + +**Private Domain:** +- Internal organization endpoints +- Requires private network access +- Connected to `gateway-private` gateway + +### Authorization and Security + +- **JWT Validation**: Verifies authentication tokens on each request +- **Authorization Policies**: Istio AuthorizationPolicies per route +- **OPA Integration**: Authorization decisions delegated to Open Policy Agent +- **Scope Control**: Route-to-scope mapping for granular permissions + +## Architecture + +### Workflow + +1. **Build Context** + - Extracts service action parameters + - Retrieves Kubernetes namespace information + - Classifies routes by visibility (public/private) + +2. **Build HTTPRoutes** + - Generates base HTTPRoute templates per domain + - Queries scopes associated with each route + - Constructs Istio routing rules + +3. **Process Routes** + - Sorts routes by specificity (exact > regex > prefix) + - Generates AuthorizationPolicies if authorization is enabled + - Maps scope IDs to backend services + +4. **Apply Configuration** + - Applies generated YAML manifests to the cluster + - Manages cleanup of obsolete resources + - Maintains tracking of applied resources + +### Technologies + +- **Kubernetes**: Orchestration platform (Gateway API v1) +- **Istio**: Service mesh for traffic management and security +- **Bash**: Workflow scripting and automation +- **jq**: JSON processing and manipulation +- **gomplate**: Resource template generation +- **kubectl**: Kubernetes resource management +- **OPA**: Open Policy Agent for external authorization + +### Nullplatform Integration + +- **Scopes Service**: Scope queries for authorization (`np scope list/read`) +- **Notification Channel**: Receives events to trigger actions +- **Service Specification**: Service specification registration +- **Deployment Service**: Coordination with deployment workflows + +## File Structure + +``` +/endpoint-exposer +├── configure # Service configuration script +├── entrypoint/ # Entry points for actions +│ ├── service-action # Service action handler +│ └── link-action # Dependency handler +├── specs/ # Service specifications +│ ├── service-specification.json +│ └── link-specification.json +├── workflows/istio/ # Workflow definitions +│ ├── service-action.json +│ └── link-action.json +├── scripts/istio/ # Core routing logic +│ ├── build_context +│ ├── build_httproute +│ ├── process_routes +│ ├── build_rule +│ └── build_ingress_with_rule +├── scripts/common/ # Shared utilities +│ ├── apply +│ └── delete +├── templates/istio/ # K8s resource templates +│ ├── httproute.yaml.tmpl +│ ├── authz-policy.yaml.tmpl +│ └── request-authn.yaml.tmpl +├── test/ # BATS test suite +├── examples/ # Example configurations +└── container-scope-override/ # Custom deployment support +``` + +## Configuration + +### Environment Variables + +- `K8S_NAMESPACE`: Kubernetes namespace for resources (default: `nullplatform`) +- `PUBLIC_GATEWAY_NAME`: Public gateway name (default: `gateway-public`) +- `PRIVATE_GATEWAY_NAME`: Private gateway name (default: `gateway-private`) +- `GATEWAY_NAMESPACE`: Gateway namespace (default: `gateways`) +- `OPA_PROVIDER_NAME`: OPA authorization provider (default: `opa-ext-authz`) + +### Route Configuration Example + +```json +{ + "routes": [ + { + "method": "GET", + "path": "/api/v1/resource/{id}", + "scope": "resource-read", + "visible_on": "public", + "enable_authorization": true + }, + { + "method": "POST", + "path": "/api/v1/resource", + "scope": "resource-write", + "visible_on": "private", + "enable_authorization": true + } + ], + "public_domain": "api.example.com", + "private_domain": "internal-api.example.com" +} +``` + +## Testing + +The service uses BATS (Bash Automated Testing System) for testing: + +```bash +# Run all tests +./test/run-tests.sh + +# Run specific tests +bats test/istio/ +``` + +Tests cover: +- Simple routes +- Public and private routes +- Authorization scenarios +- JWT configurations +- Manifest generation + +## Operations + +### Create/Update Endpoints + +The service responds to Nullplatform actions: +- `create`: Generates and applies initial configuration +- `update`: Modifies existing configuration +- `delete`: Cleans up Kubernetes resources + +### Monitoring + +Generated resources can be monitored with: + +```bash +# View HTTPRoutes +kubectl get httproutes -n + +# View AuthorizationPolicies +kubectl get authorizationpolicies -n + +# View gateway status +kubectl get gateway -n gateways +``` + +## Use Cases + +1. **Public API**: Expose public REST endpoints with rate limiting and optional authentication +2. **Admin API**: Private endpoints with JWT authentication and scope-based authorization +3. **Internal Microservices**: Private routes between services with authorization policies +4. **Multi-Version APIs**: Path-based routing for API versioning + +## Contributing + +See [test/README.md](test/README.md) for information about testing and development. diff --git a/endpoint-exposer/configure b/endpoint-exposer/configure new file mode 100755 index 0000000..380957d --- /dev/null +++ b/endpoint-exposer/configure @@ -0,0 +1,162 @@ +#!/bin/bash + +set -euo pipefail + +# Colors +GREEN="\033[0;32m" +BLUE="\033[0;34m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +NC="\033[0m" + +# Spinner state +SPINNER_PID="" +SPINNER_MSG="" + +start_spinner() { + SPINNER_MSG="$1" + echo -ne "${BLUE}==>${NC} $SPINNER_MSG..." + ( + while true; do + for c in / - \\ \|; do + echo -ne "\r${BLUE}==>${NC} $SPINNER_MSG... $c" + sleep 0.1 + done + done + ) & + SPINNER_PID=$! + disown +} + +stop_spinner_success() { + kill "$SPINNER_PID" >/dev/null 2>&1 || true + wait "$SPINNER_PID" 2>/dev/null || true + echo -ne "\r\033[K" + echo -e "${GREEN}✔${NC} $SPINNER_MSG" +} + +stop_spinner_error() { + kill "$SPINNER_PID" >/dev/null 2>&1 || true + wait "$SPINNER_PID" 2>/dev/null || true + echo -ne "\r\033[K" + echo -e "${RED}✖${NC} $SPINNER_MSG" + exit 1 +} + +# --- Step 1: Environment Validation --- + +start_spinner "Validating that the NRN has been loaded into the environment." +if [ -z "${NRN:-}" ]; then + stop_spinner_error "NRN is not set. Please export the NRN environment variable before running this script." +fi +stop_spinner_success "NRN found and loaded successfully." + +start_spinner "Validating that the ENVIRONMENT has been loaded into the environment." +if [ -z "${ENVIRONMENT:-}" ]; then + stop_spinner_error "ENVIRONMENT is not set. Please export the ENVIRONMENT environment variable before running this script." +fi +stop_spinner_success "ENVIRONMENT found and loaded successfully." + +start_spinner "Validating that the REPO_PATH has been loaded into the environment." +if [ -z "${REPO_PATH:-}" ]; then + stop_spinner_error "REPO_PATH is not set. Please export the REPO_PATH environment variable before running this script." +fi +stop_spinner_success "REPO_PATH found and loaded successfully." + +# --- Step 2: Generate and Create Service Specification --- + +SERVICE_SPEC_PATH="specs/service-spec.json" +LINK_SPEC_PATH="specs/link-spec.json" +LINK_ACTION_DIR="specs/link-actions" +SERVICE_ACTION_DIR="specs/service-actions" + +gomplate --file "$SERVICE_SPEC_PATH.tpl" --out "$SERVICE_SPEC_PATH" + +start_spinner "Creating the service specification in the platform." +{ + SERVICE_SPEC_BODY=$(cat "$SERVICE_SPEC_PATH") + SERVICE_SPEC=$(np service specification create --body "$SERVICE_SPEC_BODY" --format json) + SERVICE_SPECIFICATION_ID=$(echo "$SERVICE_SPEC" | jq -r .id) + SERVICE_SLUG=$(echo "$SERVICE_SPEC" | jq -r .slug) +} || stop_spinner_error "Failed to create or parse the service specification." +stop_spinner_success "Service specification created successfully (id=$SERVICE_SPECIFICATION_ID, slug=$SERVICE_SLUG)." + +rm "$SERVICE_SPEC_PATH" +export SERVICE_SPECIFICATION_ID +export SERVICE_SLUG + +# --- Step 3: Generate and Create Link Specification --- +gomplate --file "$LINK_SPEC_PATH.tpl" --out "$LINK_SPEC_PATH" + +start_spinner "Creating the link specification in the platform." +{ + LINK_SPEC_BODY=$(cat "$LINK_SPEC_PATH") + LINK_SPEC=$(np link specification create --body "$LINK_SPEC_BODY" --format json) + LINK_SPECIFICATION_ID=$(echo "$LINK_SPEC" | jq -r .id) + LINK_SLUG=$(echo "$LINK_SPEC" | jq -r .slug) +} || stop_spinner_error "Failed to create or parse the service specification." +stop_spinner_success "Link specification created successfully (id=$LINK_SPECIFICATION_ID, slug=$LINK_SLUG)." + +rm "$LINK_SPEC_PATH" +export LINK_SPECIFICATION_ID +export LINK_SLUG + +# --- Step 4: Create Action Specifications --- + +find "$LINK_ACTION_DIR" -type f -name "*.tpl" | while read -r TEMPLATE_FILE; do + REL_PATH="${TEMPLATE_FILE#$LINK_ACTION_DIR/}" + OUTPUT_PATH="$LINK_ACTION_DIR/${REL_PATH%.tpl}" + + gomplate --file "$TEMPLATE_FILE" --out "$OUTPUT_PATH" + + ACTION_SPEC_BODY=$(cat "$OUTPUT_PATH") + + start_spinner "Registering action specification: ${REL_PATH%.json.tpl}." + { + ACTION_SPEC=$(np link specification action specification create \ + --linkSpecificationId "$LINK_SPECIFICATION_ID" \ + --body "$ACTION_SPEC_BODY" \ + --format json) + ACTION_SPEC_ID=$(echo "$ACTION_SPEC" | jq -r .id) + } || stop_spinner_error "Failed to create action specification: $REL_PATH." + + rm "$OUTPUT_PATH" + stop_spinner_success "Action specification created successfully (id=$ACTION_SPEC_ID)." +done + +find "$SERVICE_ACTION_DIR" -type f -name "*.tpl" | while read -r TEMPLATE_FILE; do + REL_PATH="${TEMPLATE_FILE#$SERVICE_ACTION_DIR/}" + OUTPUT_PATH="$SERVICE_ACTION_DIR/${REL_PATH%.tpl}" + + gomplate --file "$TEMPLATE_FILE" --out "$OUTPUT_PATH" + + ACTION_SPEC_BODY=$(cat "$OUTPUT_PATH") + + start_spinner "Registering action specification: ${REL_PATH%.json.tpl}." + { + ACTION_SPEC=$(np service specification action specification create \ + --serviceSpecificationId "$SERVICE_SPECIFICATION_ID" \ + --body "$ACTION_SPEC_BODY" \ + --format json) + ACTION_SPEC_ID=$(echo "$ACTION_SPEC" | jq -r .id) + } || stop_spinner_error "Failed to create action specification: $REL_PATH." + + rm "$OUTPUT_PATH" + stop_spinner_success "Action specification created successfully (id=$ACTION_SPEC_ID)." +done + +# --- Step 5: Create Notification Channel --- + +NOTIFICATION_CHANNEL_PATH="specs/notification-channel.json" + +gomplate --file "$NOTIFICATION_CHANNEL_PATH.tpl" --out "$NOTIFICATION_CHANNEL_PATH" + +start_spinner "Creating the notification channel." +{ +NOTIFICATION_CHANNEL_BODY=$(cat "$NOTIFICATION_CHANNEL_PATH") +NOTIFICATION_CHANNEL=$(np notification channel create --format json --body "$NOTIFICATION_CHANNEL_BODY") +NOTIFICATION_CHANNEL_ID=$(echo "$NOTIFICATION_CHANNEL" | jq -r .id) +} || stop_spinner_error "Failed to create the notification channel." + +rm "$NOTIFICATION_CHANNEL_PATH" +stop_spinner_success "Notification channel created successfully (id=$NOTIFICATION_CHANNEL_ID)." diff --git a/endpoint-exposer/container-scope-override/deployment/sync_exposer b/endpoint-exposer/container-scope-override/deployment/sync_exposer new file mode 100755 index 0000000..06d1058 --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/sync_exposer @@ -0,0 +1,172 @@ +#!/bin/bash + +echo "=== DEBUG: Starting sync_exposer script ===" + +APPLICATION_NRN=$(jq -r .application.nrn <<< "$CONTEXT") + +echo "SERVICE SPECIFICATION SLUG: $SERVICE_SPECIFICATION_SLUG, APPLICATION_NRN: $APPLICATION_NRN" + +# Step 1: Get service specification by slug +echo "DEBUG: Fetching service specifications..." +SERVICE_SPECS=$(np service specification list --nrn "$APPLICATION_NRN" --type dependency --format json) +SERVICE_SPEC=$(jq -c --arg slug "$SERVICE_SPECIFICATION_SLUG" ' + .results + | map(select(.slug == $slug)) + | .[0] +' <<< "$SERVICE_SPECS") + +SERVICE_SPEC_ID=$(jq -r .id <<< "$SERVICE_SPEC") + +if [[ -z "$SERVICE_SPEC_ID" || "$SERVICE_SPEC_ID" == "null" ]]; then + echo "Error: Could not find service specification with slug '$SERVICE_SPECIFICATION_SLUG'" + exit 1 +fi + +echo "DEBUG: SERVICE_SPEC_ID=$SERVICE_SPEC_ID" + +# Step 2: Get service instance that matches the SERVICE_SPEC_ID +echo "DEBUG: Fetching services for application..." +SERVICES=$(np service list --nrn "$APPLICATION_NRN" --format json) + +SERVICE=$(jq -c --arg spec_id "$SERVICE_SPEC_ID" ' + .results + | map(select(.specification_id == $spec_id)) + | .[0] +' <<< "$SERVICES") + +SERVICE_ID=$(jq -r .id <<< "$SERVICE") + +if [[ -z "$SERVICE_ID" || "$SERVICE_ID" == "null" ]]; then + echo "Could not find service instance for specification '$SERVICE_SPEC_ID', skipping exposer sync" + exit 0 +fi + +echo "DEBUG: SERVICE_ID=$SERVICE_ID" + +# Step 3: Get service attributes as parameters +echo "DEBUG: Reading service attributes..." +SERVICE_DATA=$(np service read --id "$SERVICE_ID" --format json) +export PARAMETERS=$(jq -c .attributes <<< "$SERVICE_DATA") + +echo "DEBUG: PARAMETERS=$PARAMETERS" + +# Step 4: Get action specification with slug "update-" +ACTION_SLUG="update-$SERVICE_SPECIFICATION_SLUG" +echo "DEBUG: Fetching action specifications (looking for slug: $ACTION_SLUG)..." +SERVICE_ACTIONS=$(np service specification action specification list --serviceSpecificationId "$SERVICE_SPEC_ID" --format json) + +ACTION_SPEC=$(jq -c --arg slug "$ACTION_SLUG" ' + .results + | map(select(.slug == $slug)) + | .[0] +' <<< "$SERVICE_ACTIONS") + +ACTION_SPEC_ID=$(jq -r .id <<< "$ACTION_SPEC") + +if [[ -z "$ACTION_SPEC_ID" || "$ACTION_SPEC_ID" == "null" ]]; then + echo "Error: Could not find action specification with slug '$ACTION_SLUG' for service specification '$SERVICE_SPEC_ID'" + exit 1 +fi + +echo "DEBUG: ACTION_SPEC_ID=$ACTION_SPEC_ID" + +# Step 5: Create service action with parameters (with retry for concurrency) +echo "DEBUG: Creating service action..." + +MAX_CREATE_RETRIES=10 +RETRY_DELAY=5 +create_attempt=0 +ACTION_ID="" + +while [[ -z "$ACTION_ID" || "$ACTION_ID" == "null" ]]; do + ((create_attempt++)) + echo "DEBUG: Create attempt $create_attempt/$MAX_CREATE_RETRIES" + + if [ "$create_attempt" -gt $MAX_CREATE_RETRIES ]; then + echo "Error: Maximum number of create attempts (${MAX_CREATE_RETRIES}) reached. Could not create action." + exit 1 + fi + + # Add delay before retry (except on first attempt) + if [ "$create_attempt" -gt 1 ]; then + echo "DEBUG: Waiting ${RETRY_DELAY} seconds before retry..." + sleep $RETRY_DELAY + fi + + # Try to create the action + ACTION_RESPONSE=$(np service action create --serviceId "$SERVICE_ID" --body "$(jq -n --argjson params "$PARAMETERS" --arg spec_id "$ACTION_SPEC_ID" '{name: "update", parameters: $params, specification_id: $spec_id}')" --format json 2>&1 || true) + + # Check if response contains an error about action already in progress + if echo "$ACTION_RESPONSE" | grep -q "already an action with status.*in_progress"; then + echo "DEBUG: Action already in progress detected" + + # Try to find the existing in_progress action + echo "DEBUG: Attempting to find existing in_progress action..." + EXISTING_ACTIONS=$(np service action list --serviceId "$SERVICE_ID" --format json) + EXISTING_ACTION=$(echo "$EXISTING_ACTIONS" | jq -c --arg spec_id "$ACTION_SPEC_ID" ' + .results + | map(select(.specification_id == $spec_id and .status == "in_progress")) + | .[0] + ') + + EXISTING_ACTION_ID=$(echo "$EXISTING_ACTION" | jq -r '.id // empty') + + if [[ -n "$EXISTING_ACTION_ID" && "$EXISTING_ACTION_ID" != "null" ]]; then + echo "DEBUG: Found existing in_progress action with ID: $EXISTING_ACTION_ID" + ACTION_ID="$EXISTING_ACTION_ID" + echo "Using existing action instead of creating new one" + break + fi + + echo "DEBUG: No existing action found, will retry..." + elif echo "$ACTION_RESPONSE" | grep -q '"error"'; then + echo "ERROR: Failed to create action: $ACTION_RESPONSE" + echo "DEBUG: Will retry after delay..." + else + # Success - extract action ID + ACTION_ID=$(echo "$ACTION_RESPONSE" | jq -r '.id // empty') + + if [[ -n "$ACTION_ID" && "$ACTION_ID" != "null" ]]; then + echo "DEBUG: ACTION_ID=$ACTION_ID" + echo "Created endpoint exposer update action[id=$ACTION_ID], waiting for its completion" + break + else + echo "DEBUG: Could not extract ACTION_ID from response: $ACTION_RESPONSE" + echo "DEBUG: Will retry after delay..." + fi + fi +done + +# Step 6: Wait for action to complete +MAX_ITERATIONS=20 +iteration=0 + +echo "DEBUG: Starting polling loop for action status..." +while true; do + ((iteration++)) + echo "DEBUG: Iteration $iteration/$MAX_ITERATIONS" + + if [ "$iteration" -gt $MAX_ITERATIONS ]; then + echo "Error: Maximum number of iterations (${MAX_ITERATIONS}) reached. Could not update the endpoint exposer." + exit 1 + fi + + echo "DEBUG: Reading action status..." + ACTION_RESPONSE=$(np service action read --serviceId "$SERVICE_ID" --id "$ACTION_ID" --format json) + ACTION_STATUS=$(jq -r .status <<< "$ACTION_RESPONSE") + + echo "Checking endpoint exposer update action[id=$ACTION_ID, status=$ACTION_STATUS]" + + if [[ "$ACTION_STATUS" == "success" ]]; then + echo "✅ Endpoint exposer successfully updated" + break + elif [[ "$ACTION_STATUS" == "failed" ]]; then + echo "❌ Could not update endpoint exposer, deployment will be rollbacked" + exit 1 + fi + + echo "DEBUG: Sleeping for 5 seconds..." + sleep 5 +done + +echo "=== DEBUG: sync_exposer script completed successfully ===" diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/blue_green.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/blue_green.yaml new file mode 100644 index 0000000..6d43a5e --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/blue_green.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/delete.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/delete.yaml new file mode 100644 index 0000000..6d74ddd --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/delete.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply traffic \ No newline at end of file diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/finalize.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/finalize.yaml new file mode 100644 index 0000000..6d43a5e --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/finalize.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/initial.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/initial.yaml new file mode 100644 index 0000000..6d43a5e --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/initial.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/rollback.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/rollback.yaml new file mode 100644 index 0000000..6d43a5e --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/rollback.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply diff --git a/endpoint-exposer/container-scope-override/deployment/workflows/switch_traffic.yaml b/endpoint-exposer/container-scope-override/deployment/workflows/switch_traffic.yaml new file mode 100644 index 0000000..6d43a5e --- /dev/null +++ b/endpoint-exposer/container-scope-override/deployment/workflows/switch_traffic.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_exposer + type: script + file: "$OVERRIDES_PATH/deployment/sync_exposer" + after: apply diff --git a/endpoint-exposer/container-scope-override/values.yaml b/endpoint-exposer/container-scope-override/values.yaml new file mode 100644 index 0000000..101aee8 --- /dev/null +++ b/endpoint-exposer/container-scope-override/values.yaml @@ -0,0 +1,3 @@ +configuration: + SERVICE_SPECIFICATION_SLUG: endpoint-exposer + \ No newline at end of file diff --git a/endpoint-exposer/entrypoint/entrypoint b/endpoint-exposer/entrypoint/entrypoint new file mode 100755 index 0000000..81c3f2b --- /dev/null +++ b/endpoint-exposer/entrypoint/entrypoint @@ -0,0 +1,57 @@ +#!/bin/bash + +# Check if NP_ACTION_CONTEXT is set +if [ -z "$NP_ACTION_CONTEXT" ]; then + echo "NP_ACTION_CONTEXT is not set. Exiting." + exit 1 +fi + +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") + +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" + +# Parse the JSON properly - remove the extra quotes +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') +export SERVICE_ACTION=$(echo "$CONTEXT" | jq -r '.slug') +export SERVICE_ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type') +export NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action') + +export LINK=$(echo "$CONTEXT" | jq '.link') + +ACTION_SOURCE=service + +IS_LINK_ACTION=$(echo "$CONTEXT" | jq '.link != null') + +if [ "$IS_LINK_ACTION" = "true" ]; then + ACTION_SOURCE=link +fi + +export WORKING_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVICE_PATH="" +OVERRIDES_PATH="" + +for arg in "$@"; do + case $arg in + --service-path=*) + SERVICE_PATH="${arg#*=}" + ;; + --overrides-path=*) + OVERRIDES_PATH="${arg#*=}" + ;; + *) + echo "Unknown argument: $arg" + exit 1 + ;; + esac +done + +OVERRIDES_PATH="${OVERRIDES_PATH:-$SERVICE_PATH/overrides}" + +export SERVICE_PATH +export OVERRIDES_PATH + +# export util functions +#eval "$WORKING_DIRECTORY"/$ACTION_SOURCE + +np service-action exec --live-output --live-report --script="$WORKING_DIRECTORY/$ACTION_SOURCE" diff --git a/endpoint-exposer/entrypoint/link b/endpoint-exposer/entrypoint/link new file mode 100755 index 0000000..253ef01 --- /dev/null +++ b/endpoint-exposer/entrypoint/link @@ -0,0 +1,34 @@ +#!/bin/bash + +echo "Executing link action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; + "create") + ACTION_TO_EXECUTE="link" + ;; + "delete") + ACTION_TO_EXECUTE="unlink" + ;; +esac + +INGRESS_TYPE="${INGRESS_TYPE:-alb}" + +echo "INGRESS_TYPE is set to '$INGRESS_TYPE'" + +WORKFLOW_PATH="$SERVICE_PATH/workflows/$INGRESS_TYPE/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/$INGRESS_TYPE/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH --values $VALUES_PATH" + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" +eval "$CMD" diff --git a/endpoint-exposer/entrypoint/service b/endpoint-exposer/entrypoint/service new file mode 100755 index 0000000..877b2d2 --- /dev/null +++ b/endpoint-exposer/entrypoint/service @@ -0,0 +1,39 @@ +#!/bin/bash + +echo "Executing service action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE" + +ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE" + +case "$SERVICE_ACTION_TYPE" in + "custom") + ACTION_TO_EXECUTE="$SERVICE_ACTION" + ;; +esac + +INGRESS_TYPE="${INGRESS_TYPE:-alb}" + +echo "INGRESS_TYPE is set to '$INGRESS_TYPE'" +echo "OVERRIDES_PATH is set to '$OVERRIDES_PATH'" + +WORKFLOW_PATH="$SERVICE_PATH/workflows/$INGRESS_TYPE/$ACTION_TO_EXECUTE.yaml" +OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/service/workflows/$ACTION_TO_EXECUTE.yaml" +VALUES_PATH="$SERVICE_PATH/values.yaml" + +CMD="np service workflow exec --workflow $WORKFLOW_PATH --values $VALUES_PATH --build-context --include-secrets" + +if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then + CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH" +fi + +echo "Executing command: $CMD" + +# Note: The 'np service workflow exec' CLI automatically extracts OVERRIDES_PATH +# It uses regex /[^/]+/workflows/[^/]+\.yaml$ to strip the /folder/workflows/file.yaml part +# Example: --overrides /root/.np/plugin/service/workflows/create.yaml +# Regex matches: /service/workflows/create.yaml +# Results in: OVERRIDES_PATH=/root/.np/plugin (correct) +# Workflow files should use: $OVERRIDES_PATH/scripts/... (no double nesting needed) +# See: cli/cmd/service/workflow/exec/service_workflow_exec.go getOverridesBasePath() +export OVERRIDES_PATH + +eval "$CMD" diff --git a/endpoint-exposer/examples/jwt-working-solution.yaml b/endpoint-exposer/examples/jwt-working-solution.yaml new file mode 100644 index 0000000..499269c --- /dev/null +++ b/endpoint-exposer/examples/jwt-working-solution.yaml @@ -0,0 +1,60 @@ +--- +# RequestAuthentication: Validates JWT tokens from nullplatform scope +# This validates the JWT signature and extracts claims, but doesn't enforce authentication +apiVersion: security.istio.io/v1 +kind: RequestAuthentication +metadata: + name: nullplatform-scope-jwt-auth + namespace: gateways + labels: + nullplatform.com/managed-by: endpoint-exposer +spec: + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: gateway-public + jwtRules: + - issuer: "https://api.nullplatform.com/scope" + jwksUri: "https://api.nullplatform.com/scope/.well-known/jwks.json" + fromHeaders: + - name: Authorization + prefix: "Bearer " + fromCookies: + - "np_scope_token" + outputClaimToHeaders: + - header: "X-User-ID" + claim: "sub" + - header: "X-User-Email" + claim: "email" + - header: "X-User-Scopes" + claim: "scope" + +--- +# AuthorizationPolicy: Denies access to specific host without valid JWT +# Uses DENY action with notValues to block requests without valid issuer claim +# Other hosts without specific policies remain accessible (Istio default: allow-by-default with only DENY policies) +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: require-jwt-production + namespace: gateways + labels: + nullplatform.com/managed-by: endpoint-exposer +spec: + action: DENY + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: gateway-public + rules: + # Deny requests to production host without valid JWT + - to: + - operation: + hosts: + - "playground-floppy-bird-api-production-kjstb.edenred.nullimplementation.com" + ports: ["443"] + notPaths: + - /health + - /ready + - /metrics + when: + - key: request.auth.claims[aud] + notValues: ["layground-floppy-bird-api-production-kjstb.edenred.nullimplementation.com"] diff --git a/endpoint-exposer/scripts/common/apply b/endpoint-exposer/scripts/common/apply new file mode 100644 index 0000000..1b092ff --- /dev/null +++ b/endpoint-exposer/scripts/common/apply @@ -0,0 +1,90 @@ +#!/bin/bash + +set -euo pipefail + +# Load configuration +source "$SERVICE_PATH/scripts/istio/config" + +echo "TEMPLATE DIR: $OUTPUT_DIR, ACTION: $ACTION, DRY_RUN: $DRY_RUN" + +# Helper function to delete a resource if it exists +delete_if_exists() { + local resource_type="$1" + local resource_name="$2" + local namespace="$3" + + if kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then + echo "Deleting $resource_type: $resource_name in namespace $namespace" + if [[ "$DRY_RUN" == "false" ]]; then + kubectl delete "$resource_type" "$resource_name" -n "$namespace" + fi + else + echo "$resource_type $resource_name not found in namespace $namespace (already deleted or never existed)" + fi +} + +# Check for marker files indicating resources should be deleted +if [[ -f "$OUTPUT_DIR/.httproute-public-deleted" ]]; then + echo "Public HTTPRoute marked for deletion" + delete_if_exists "httproute" "$SERVICE_SLUG-$SERVICE_ID-public" "$K8S_NAMESPACE" + rm "$OUTPUT_DIR/.httproute-public-deleted" +fi + +if [[ -f "$OUTPUT_DIR/.httproute-private-deleted" ]]; then + echo "Private HTTPRoute marked for deletion" + delete_if_exists "httproute" "$SERVICE_SLUG-$SERVICE_ID-private" "$K8S_NAMESPACE" + rm "$OUTPUT_DIR/.httproute-private-deleted" +fi + +if [[ -f "$OUTPUT_DIR/.authz-public-deleted" ]]; then + echo "Public AuthorizationPolicy marked for deletion" + delete_if_exists "authorizationpolicy" "$SERVICE_SLUG-$SERVICE_ID-authz-public" "$GATEWAY_NAMESPACE" + rm "$OUTPUT_DIR/.authz-public-deleted" +fi + +if [[ -f "$OUTPUT_DIR/.authz-private-deleted" ]]; then + echo "Private AuthorizationPolicy marked for deletion" + delete_if_exists "authorizationpolicy" "$SERVICE_SLUG-$SERVICE_ID-authz-private" "$GATEWAY_NAMESPACE" + rm "$OUTPUT_DIR/.authz-private-deleted" +fi + +# Collect all yaml files into a temporary directory for batch apply +TEMP_APPLY_DIR="$OUTPUT_DIR/batch-apply" +mkdir -p "$TEMP_APPLY_DIR" + +# Find all .yaml files that were not yet applied / deleted +find "$OUTPUT_DIR" \( -path "*/apply" -o -path "*/delete" -o -path "*/batch-apply" \) -prune -o -type f -name "*.yaml" -print | while read -r TEMPLATE_FILE; do + FILENAME="$(basename "$TEMPLATE_FILE")" + cp "$TEMPLATE_FILE" "$TEMP_APPLY_DIR/$FILENAME" +done + +# Count files to apply +NUM_FILES=$(find "$TEMP_APPLY_DIR" -type f -name "*.yaml" | wc -l | tr -d ' ') + +if [[ "$NUM_FILES" -gt 0 ]]; then + echo "Applying $NUM_FILES resources..." + echo "kubectl $ACTION -f $TEMP_APPLY_DIR/" + + if [[ "$DRY_RUN" == "false" ]]; then + # Apply all resources + kubectl "$ACTION" -f "$TEMP_APPLY_DIR/" + fi +else + echo "No resources to apply" +fi + +# Move processed files to apply directory +find "$OUTPUT_DIR" \( -path "*/apply" -o -path "*/delete" -o -path "*/batch-apply" \) -prune -o -type f -name "*.yaml" -print | while read -r TEMPLATE_FILE; do + BASE_DIR="$(dirname "$TEMPLATE_FILE")" + FILENAME="$(basename "$TEMPLATE_FILE")" + DEST_DIR="${BASE_DIR}/$ACTION" + + mkdir -p "$DEST_DIR" + mv "$TEMPLATE_FILE" "$DEST_DIR/$FILENAME" +done + +# Cleanup temporary directory +rm -rf "$TEMP_APPLY_DIR" + +# Note: DRY_RUN is for testing - we exit 0 even in dry run mode +exit 0 \ No newline at end of file diff --git a/endpoint-exposer/scripts/istio/build_context b/endpoint-exposer/scripts/istio/build_context new file mode 100644 index 0000000..f414861 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_context @@ -0,0 +1,43 @@ +#!/bin/bash + +set -euo pipefail + +SERVICE_ID=$(echo "$CONTEXT" | jq -r .service.id) +SERVICE_SLUG=$(echo "$CONTEXT" | jq -r .service.slug) + +ACTION_ID=$(echo "$CONTEXT" | jq -r .id) +ACTION_NAME=$(echo "$CONTEXT" | jq -r .slug) + +# Extract domains from parameters +PUBLIC_DOMAIN=$(echo "$CONTEXT" | jq -r '.parameters.publicDomain // .parameters.public_domain // ""') +PRIVATE_DOMAIN=$(echo "$CONTEXT" | jq -r '.parameters.privateDomain // .parameters.private_domain // ""') + +K8S_NAMESPACE="${NAMESPACE_OVERRIDE:-nullplatform}" + +# Extract routes array from parameters +ROUTES_JSON=$(echo "$CONTEXT" | jq -c '.parameters.routes // []') + +# Split routes by visibility +PUBLIC_ROUTES_JSON=$(echo "$ROUTES_JSON" | jq -c '[.[] | select(.visibility == "public" or .visibility == null)]') +PRIVATE_ROUTES_JSON=$(echo "$ROUTES_JSON" | jq -c '[.[] | select(.visibility == "private")]') + +CONTEXT=$(echo "$CONTEXT" | jq \ + --arg k8s_namespace "$K8S_NAMESPACE" \ + '. + {k8s_namespace: $k8s_namespace}') + +# Only set OUTPUT_DIR if not already set (allows tests to override) +if [[ -z "${OUTPUT_DIR:-}" ]]; then + export OUTPUT_DIR="$SERVICE_PATH/output/$SERVICE_SLUG-$SERVICE_ID/$ACTION_NAME-$ACTION_ID" +fi + +mkdir -p "$OUTPUT_DIR" + +export SERVICE_ID +export SERVICE_SLUG +export ACTION_ID +export ACTION_NAME +export PUBLIC_DOMAIN +export PRIVATE_DOMAIN +export ROUTES_JSON +export PUBLIC_ROUTES_JSON +export PRIVATE_ROUTES_JSON \ No newline at end of file diff --git a/endpoint-exposer/scripts/istio/build_httproute b/endpoint-exposer/scripts/istio/build_httproute new file mode 100755 index 0000000..64162f4 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_httproute @@ -0,0 +1,108 @@ +#!/bin/bash + +set -euo pipefail + +# Load configuration +source "$SERVICE_PATH/scripts/istio/config" + +# Parameters (must be set by caller) +VISIBILITY="${VISIBILITY:-public}" # "public" or "private" + +echo "=== Building ${VISIBILITY} HTTPRoute ===" + +# Set variables based on visibility +if [[ "$VISIBILITY" == "public" ]]; then + ROUTES_JSON="$PUBLIC_ROUTES_JSON" + DOMAIN="$PUBLIC_DOMAIN" + GATEWAY_NAME="$PUBLIC_GATEWAY_NAME" + SUFFIX="public" +elif [[ "$VISIBILITY" == "private" ]]; then + ROUTES_JSON="$PRIVATE_ROUTES_JSON" + DOMAIN="$PRIVATE_DOMAIN" + GATEWAY_NAME="$PRIVATE_GATEWAY_NAME" + SUFFIX="private" +else + echo "ERROR: Invalid VISIBILITY value: $VISIBILITY" + exit 1 +fi + +# Check if we have routes and domain +NUM_ROUTES=$(echo "$ROUTES_JSON" | jq 'length') +echo "Number of $VISIBILITY routes: $NUM_ROUTES" +echo "$VISIBILITY Domain: '$DOMAIN'" + +# Check if domain is empty/null +DOMAIN_EMPTY=false +if [[ -z "$DOMAIN" ]] || [[ "$DOMAIN" == "null" ]] || [[ "$DOMAIN" == "\"null\"" ]]; then + DOMAIN_EMPTY=true +fi + +# Determine if we should create an empty resource +CREATE_EMPTY=false +if [[ "$NUM_ROUTES" -eq 0 ]] || [[ "$DOMAIN_EMPTY" == "true" ]]; then + CREATE_EMPTY=true + echo "No $VISIBILITY routes or domain configured, creating empty resource for cleanup..." +fi + +# Create HTTPRoute +HTTPROUTE_FILE="$OUTPUT_DIR/httproute-$SERVICE_ID-$SUFFIX.yaml" + +if [[ "$CREATE_EMPTY" == "true" ]]; then + # Don't create the file - kubectl apply with --prune will delete it + echo "Skipping $VISIBILITY HTTPRoute generation - resource will be pruned if it exists" + # Still export the file path but point to a marker file for cleanup tracking + touch "$OUTPUT_DIR/.httproute-$SUFFIX-deleted" +else + # Create HTTPRoute from template + TEMPLATE="$SERVICE_PATH/templates/istio/httproute.yaml.tpl" + + # Build context for template + HTTPROUTE_CONTEXT=$(jq -n \ + --arg service_slug "$SERVICE_SLUG" \ + --arg service_id "$SERVICE_ID" \ + --arg k8s_namespace "$K8S_NAMESPACE" \ + --arg domain "$DOMAIN" \ + --arg gateway_name "$GATEWAY_NAME" \ + --arg gateway_namespace "$GATEWAY_NAMESPACE" \ + --arg suffix "$SUFFIX" \ + '{ + service_slug: $service_slug, + service_id: $service_id, + k8s_namespace: $k8s_namespace, + domain: $domain, + gateway_name: $gateway_name, + gateway_namespace: $gateway_namespace, + suffix: $suffix + }') + + CONTEXT_PATH="$OUTPUT_DIR/httproute-$SUFFIX-context-$SERVICE_ID.json" + echo "$HTTPROUTE_CONTEXT" > "$CONTEXT_PATH" + + echo "Generating HTTPRoute from template: $TEMPLATE" + gomplate -c .="$CONTEXT_PATH" \ + -f "$TEMPLATE" \ + -o "$HTTPROUTE_FILE" + + rm "$CONTEXT_PATH" + + # Now process routes using the existing logic + # Temporarily override ROUTES_JSON with visibility-specific routes + ORIGINAL_ROUTES_JSON="${ROUTES_JSON:-[]}" + export ROUTES_JSON="$ROUTES_JSON" + export HTTPROUTE_FILE="$HTTPROUTE_FILE" + + # Process routes + source "$SERVICE_PATH/scripts/istio/process_routes" + + # Restore original ROUTES_JSON + export ROUTES_JSON="$ORIGINAL_ROUTES_JSON" + + echo "✅ $VISIBILITY HTTPRoute created: $HTTPROUTE_FILE" +fi + +# Export file path based on visibility +if [[ "$VISIBILITY" == "public" ]]; then + export HTTPROUTE_PUBLIC_FILE="$HTTPROUTE_FILE" +elif [[ "$VISIBILITY" == "private" ]]; then + export HTTPROUTE_PRIVATE_FILE="$HTTPROUTE_FILE" +fi diff --git a/endpoint-exposer/scripts/istio/build_ingress b/endpoint-exposer/scripts/istio/build_ingress new file mode 100644 index 0000000..a6df0d3 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_ingress @@ -0,0 +1,25 @@ +#!/bin/bash + +set -euo pipefail + +# Determine domain and output file +SERVICE_DOMAIN=$(echo "$CONTEXT" | jq -r '.parameters.public_domain // .service.attributes.domain') +HTTPROUTE_FILE="${OUTPUT_FILE:-$OUTPUT_DIR/httproute-$SERVICE_ID-public.yaml}" +echo "Creating HTTPRoute for service $SERVICE_SLUG with domain $SERVICE_DOMAIN" +echo "Output file: $HTTPROUTE_FILE" + +CONTEXT_PATH="$OUTPUT_DIR/context-$SERVICE_ID.json" + +echo "$CONTEXT" > "$CONTEXT_PATH" + +echo "Building Template: $TEMPLATE to $HTTPROUTE_FILE" + +gomplate -c .="$CONTEXT_PATH" \ + --file "$TEMPLATE" \ + --out "$HTTPROUTE_FILE" + +rm "$CONTEXT_PATH" + +# Export the file path for the workflow +export HTTPROUTE_FILE +echo "HTTPRoute created at: $HTTPROUTE_FILE" \ No newline at end of file diff --git a/endpoint-exposer/scripts/istio/build_ingress_with_rule b/endpoint-exposer/scripts/istio/build_ingress_with_rule new file mode 100755 index 0000000..bfbf2ff --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_ingress_with_rule @@ -0,0 +1,384 @@ +#!/bin/bash + +set -euo pipefail + +# Detect path type and convert path value accordingly +# Returns: "type:value" format +detect_path_type() { + local path="$1" + + # Check for wildcard (*) - use PathPrefix + if [[ "$path" == *"*"* ]]; then + # Remove trailing /* or * + local prefix_path="${path%/*}" + if [[ -z "$prefix_path" ]]; then + prefix_path="/" + fi + echo "PathPrefix:$prefix_path" + return + fi + + # Check for path parameters (:param) - use RegularExpression + if [[ "$path" == *:* ]]; then + # Replace :param with [^/]+ + local regex_path="${path//:+([^\/])/[^/]+}" + # For bash pattern replacement, we need to handle it differently + regex_path=$(echo "$path" | sed 's/:[^/]*/[^\/]+/g') + echo "RegularExpression:$regex_path" + return + fi + + # Default: Exact match + echo "Exact:$path" +} + +# Get priority for path type (lower number = higher priority) +get_path_priority() { + local path="$1" + + if [[ "$path" != *":"* && "$path" != *"*"* ]]; then + echo "1" # Exact - highest priority + elif [[ "$path" == *":"* ]]; then + echo "2" # RegularExpression - medium priority + else + echo "3" # PathPrefix - lowest priority + fi +} + +is_httproute_empty() { + local yaml_content="$1" + + local num_rules + local backend_name + local backend_weight + + num_rules=$(yq '.spec.rules | length' <<< "$yaml_content") + backend_name=$(yq '.spec.rules[0].backendRefs[0].name' <<< "$yaml_content") + backend_weight=$(yq '.spec.rules[0].backendRefs[0].weight' <<< "$yaml_content") + + # An HTTPRoute is "empty" if it only has one rule with response-404 backend and weight 0 + if [[ "$num_rules" -eq 1 && \ + "$backend_name" == "response-404" && \ + "$backend_weight" == "0" ]]; then + echo "true" + else + echo "false" + fi +} + +create_http_rule() { + local rule_path="$1" + local service_json="$2" + local blue_green_config="$3" + local method="${4:-}" + + local service_name + local service_port + + service_name=$(echo "$service_json" | jq -r '.name') + service_port=$(echo "$service_json" | jq -r '.port.number // .port.name // 80') + + # Detect path type and get the converted path value + local path_type_value + path_type_value=$(detect_path_type "$rule_path") + local path_type="${path_type_value%%:*}" + local path_value="${path_type_value#*:}" + + echo "DEBUG: Original path='$rule_path', Detected type='$path_type', Converted value='$path_value'" >&2 + + # Build matches array with path and optional method + local matches_json + + # Add method if specified + if [[ -n "$method" && "$method" != "null" ]]; then + matches_json=$(jq -n \ + --arg path "$path_value" \ + --arg path_type "$path_type" \ + --arg method "$method" \ + '[{ + path: { + type: $path_type, + value: $path + }, + method: $method + }]') + else + matches_json=$(jq -n \ + --arg path "$path_value" \ + --arg path_type "$path_type" \ + '[{ + path: { + type: $path_type, + value: $path + } + }]') + fi + + # Check if there's blue/green configuration + if [[ "$blue_green_config" != "null" && -n "$blue_green_config" ]]; then + # Parse blue/green destinations and weights from the annotation + local blue_weight green_weight blue_service green_service + + blue_weight=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[0].weight // 100') + green_weight=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[1].weight // 0') + blue_service=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[0].serviceName') + green_service=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[1].serviceName') + + # Create rule with weighted backends (no URL rewrite) + jq -n \ + --argjson matches "$matches_json" \ + --arg blue_service "$blue_service" \ + --arg green_service "$green_service" \ + --arg service_port "$service_port" \ + --argjson blue_weight "$blue_weight" \ + --argjson green_weight "$green_weight" \ + '{ + matches: $matches, + backendRefs: [ + { + name: $blue_service, + port: ($service_port | tonumber), + weight: $blue_weight + }, + { + name: $green_service, + port: ($service_port | tonumber), + weight: $green_weight + } + ] + }' + else + # Single destination without blue/green (no URL rewrite) + jq -n \ + --argjson matches "$matches_json" \ + --arg service_name "$service_name" \ + --arg service_port "$service_port" \ + '{ + matches: $matches, + backendRefs: [ + { + name: $service_name, + port: ($service_port | tonumber) + } + ] + }' + fi +} + +update_httproute_rule() { + local hr_yaml="$1" + local rule_path="$2" + local service_json="$3" + local blue_green_config="$4" + + local service_name + local service_port + local updated_hr + + service_name=$(echo "$service_json" | jq -r '.name') + service_port=$(echo "$service_json" | jq -r '.port.number // .port.name // 80') + + # Update the first rule's path with Exact type (no URL rewrite) + updated_hr=$(echo "$hr_yaml" | yq eval ".spec.rules[0].matches[0].path.type = \"Exact\"") + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].matches[0].path.value = \"$rule_path\"") + + # Remove filters (no URL rewrite needed) + updated_hr=$(echo "$updated_hr" | yq eval "del(.spec.rules[0].filters)") + + # Check if there's blue/green configuration + if [[ "$blue_green_config" != "null" && -n "$blue_green_config" ]]; then + # Parse blue/green destinations and weights + local blue_weight green_weight blue_service green_service + + blue_weight=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[0].weight // 100') + green_weight=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[1].weight // 0') + blue_service=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[0].serviceName') + green_service=$(echo "$blue_green_config" | jq -r '.forward.targetGroups[1].serviceName') + + # Set blue backend + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs[0].name = \"${blue_service}\"") + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs[0].port = $service_port") + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs[0].weight = $blue_weight") + + # Add green backend + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs += [{\"name\": \"${green_service}\", \"port\": $service_port, \"weight\": $green_weight}]") + else + # Single destination + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs[0].name = \"${service_name}\"") + updated_hr=$(echo "$updated_hr" | yq eval ".spec.rules[0].backendRefs[0].port = $service_port") + updated_hr=$(echo "$updated_hr" | yq eval "del(.spec.rules[0].backendRefs[0].weight)") + fi + + echo "$updated_hr" +} + +find_rule_index() { + local hr_yaml="$1" + local target_path="$2" + local target_method="${3:-}" + + local num_rules + local i + local current_path + local current_method + + num_rules=$(yq '.spec.rules | length' <<< "$hr_yaml") + + for ((i=0; i "$HTTPROUTE_FILE" +else + # Detect the converted path value to match against existing rules + PATH_TYPE_VALUE=$(detect_path_type "$RULE_PATH") + CONVERTED_PATH="${PATH_TYPE_VALUE#*:}" + + RULE_INDEX=$(find_rule_index "$HTTPROUTE" "$CONVERTED_PATH" "${METHOD:-}") + echo "Found rule index for path '$CONVERTED_PATH' with method '${METHOD:-none}': $RULE_INDEX" + + # if there is a rule for the path we replace it + if [[ "$RULE_INDEX" != "-1" ]]; then + echo "Case 2: Replacing existing rule at index $RULE_INDEX" + UPDATED_HR=$(replace_existing_rule "$HTTPROUTE" "$RULE_PATH" "$SERVICE" "$BLUE_GREEN_CONFIG" "$RULE_INDEX" "${METHOD:-}") + echo "$UPDATED_HR" | yq "." > "$HTTPROUTE_FILE" + else + # if there is no rule for the path we add a new one + echo "Case 3: Adding new rule" + UPDATED_HR=$(add_new_rule "$HTTPROUTE" "$RULE_PATH" "$SERVICE" "$BLUE_GREEN_CONFIG" "${METHOD:-}") + + # Debug: Check if hostnames and parentRefs are present before saving + echo "DEBUG: Checking HTTPRoute before saving..." + HOSTNAMES=$(echo "$UPDATED_HR" | yq eval '.spec.hostnames | length' -) + PARENTREFS=$(echo "$UPDATED_HR" | yq eval '.spec.parentRefs | length' -) + echo "DEBUG: Number of hostnames: $HOSTNAMES" + echo "DEBUG: Number of parentRefs: $PARENTREFS" + + echo "$UPDATED_HR" | yq "." > "$HTTPROUTE_FILE" + fi +fi + +echo "" +echo "=== HTTPRoute configuration saved to: $HTTPROUTE_FILE ===" diff --git a/endpoint-exposer/scripts/istio/build_rule b/endpoint-exposer/scripts/istio/build_rule new file mode 100644 index 0000000..ef5675a --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_rule @@ -0,0 +1,179 @@ +#!/bin/bash + +set -euo pipefail + +echo "=== DEBUG: Starting build_rule script ===" +echo "DEBUG: K8S_NAMESPACE=$K8S_NAMESPACE" +echo "DEBUG: SCOPE_ID=$SCOPE_ID" + +# Check for in-progress deployment +echo "DEBUG: Checking for in-progress deployment..." +SCOPE_JSON=$(np scope read --id "$SCOPE_ID" --format json) +echo "DEBUG: Scope JSON retrieved" + +IN_PROGRESS_DEPLOYMENT=$(echo "$SCOPE_JSON" | jq -r '.in_progress_deployment // "null"') +echo "DEBUG: IN_PROGRESS_DEPLOYMENT=$IN_PROGRESS_DEPLOYMENT" + +DEPLOYMENT_STATUS="" +SWITCHED_TRAFFIC=0 + +if [[ "$IN_PROGRESS_DEPLOYMENT" != "null" ]]; then + echo "DEBUG: Found in-progress deployment, fetching details..." + DEPLOYMENT_JSON=$(np deployment read --id "$IN_PROGRESS_DEPLOYMENT" --format json) + DEPLOYMENT_STATUS=$(echo "$DEPLOYMENT_JSON" | jq -r '.status') + SWITCHED_TRAFFIC=$(echo "$DEPLOYMENT_JSON" | jq -r '.strategy_data.desired_switched_traffic // 0') + echo "DEBUG: DEPLOYMENT_STATUS=$DEPLOYMENT_STATUS" + echo "DEBUG: SWITCHED_TRAFFIC=$SWITCHED_TRAFFIC" +fi + +# Get all services and filter by scope_id in selector +# Note: kubectl -l only filters by labels, not by selectors. scope_id is in the selector field. +echo "DEBUG: Fetching all services from namespace..." +ALL_SERVICES=$(kubectl get services -n "$K8S_NAMESPACE" -o json 2>&1) + +# Try to sanitize the JSON by removing any control characters or ANSI escape codes +echo "DEBUG: Sanitizing JSON output..." +ALL_SERVICES_CLEAN=$(echo "$ALL_SERVICES" | sed $'s/\x1b\\[[0-9;]*m//g' | tr -d '\000-\011\013-\037') +echo "DEBUG: Cleaned JSON length: ${#ALL_SERVICES_CLEAN} characters" + +# Check if we have valid JSON +if echo "$ALL_SERVICES_CLEAN" | jq empty 2>/dev/null; then + echo "DEBUG: JSON is valid after cleaning" + ALL_SERVICES="$ALL_SERVICES_CLEAN" +else + echo "DEBUG: WARNING - JSON may still have issues, attempting to parse anyway" + ALL_SERVICES="$ALL_SERVICES_CLEAN" +fi + +# Filter services by scope_id in selector (not label) +echo "DEBUG: Filtering services with scope_id=$SCOPE_ID in selector..." +SERVICES_JSON=$(echo "$ALL_SERVICES" | jq --arg scope_id "$SCOPE_ID" '{ + apiVersion: .apiVersion, + kind: .kind, + metadata: .metadata, + items: [.items[] | select(.spec.selector.scope_id == $scope_id)] +}') +echo "DEBUG: Filtered services JSON" + +NUM_SERVICES=$(echo "$SERVICES_JSON" | jq '.items | length') +echo "DEBUG: NUM_SERVICES=$NUM_SERVICES" + +if [[ "$NUM_SERVICES" -eq 0 ]]; then + echo "There is no service for scope_id=$SCOPE_ID. Publishing the rule with an empty backend" + + SCOPE_RULE='{"service": {"name": "response-404", "port": { "number": 80} }}' + echo "DEBUG: SCOPE_RULE (no services)=$SCOPE_RULE" +elif [[ "$NUM_SERVICES" -eq 1 ]]; then + echo "Found single service for scope_id=$SCOPE_ID" + + echo "DEBUG: Extracting service name and port..." + SERVICE_NAME=$(echo "$SERVICES_JSON" | jq -r '.items[0].metadata.name') + SERVICE_PORT=$(echo "$SERVICES_JSON" | jq -r '.items[0].spec.ports[0].port') + + echo "Service: $SERVICE_NAME, Port: $SERVICE_PORT" + echo "DEBUG: SERVICE_NAME=$SERVICE_NAME, SERVICE_PORT=$SERVICE_PORT" + + echo "DEBUG: Building SCOPE_RULE for single service..." + SCOPE_RULE=$(jq -n \ + --arg name "$SERVICE_NAME" \ + --argjson port "$SERVICE_PORT" \ + '{ + service: { + name: $name, + port: { + number: $port + } + } + }') + echo "DEBUG: SCOPE_RULE (single service)=$SCOPE_RULE" +else + echo "Detected blue/green deployment with $NUM_SERVICES services for scope_id=$SCOPE_ID" + + # Check if deployment is finalized - if so, only use the latest service + if [[ "$DEPLOYMENT_STATUS" == "finalized" ]]; then + echo "DEBUG: Deployment is finalized, using only the latest service" + + # Use only the first service (latest deployment) + BLUE_SERVICE=$(echo "$SERVICES_JSON" | jq -r '.items[0].metadata.name') + BLUE_PORT=$(echo "$SERVICES_JSON" | jq -r '.items[0].spec.ports[0].port') + + echo "Deployment finalized. Using service: $BLUE_SERVICE" + echo "DEBUG: SERVICE_NAME=$BLUE_SERVICE, SERVICE_PORT=$BLUE_PORT" + + SCOPE_RULE=$(jq -n \ + --arg name "$BLUE_SERVICE" \ + --argjson port "$BLUE_PORT" \ + '{ + service: { + name: $name, + port: { + number: $port + } + } + }') + echo "DEBUG: SCOPE_RULE (finalized deployment)=$SCOPE_RULE" + else + # Extract blue and green services + echo "DEBUG: Extracting blue service details..." + BLUE_SERVICE=$(echo "$SERVICES_JSON" | jq -r '.items[0].metadata.name') + BLUE_PORT=$(echo "$SERVICES_JSON" | jq -r '.items[0].spec.ports[0].port') + echo "DEBUG: BLUE_SERVICE=$BLUE_SERVICE, BLUE_PORT=$BLUE_PORT" + + echo "DEBUG: Extracting green service details..." + GREEN_SERVICE=$(echo "$SERVICES_JSON" | jq -r '.items[1].metadata.name') + GREEN_PORT=$(echo "$SERVICES_JSON" | jq -r '.items[1].spec.ports[0].port') + echo "DEBUG: GREEN_SERVICE=$GREEN_SERVICE, GREEN_PORT=$GREEN_PORT" + + # Determine weights based on deployment status + if [[ "$DEPLOYMENT_STATUS" == "running" ]]; then + echo "DEBUG: Deployment is running, using switched_traffic for weights" + # New service (green) gets the switched traffic percentage + GREEN_WEIGHT=$SWITCHED_TRAFFIC + # Old service (blue) gets the remaining traffic + BLUE_WEIGHT=$((100 - SWITCHED_TRAFFIC)) + echo "DEBUG: Using deployment weights - BLUE_WEIGHT=$BLUE_WEIGHT, GREEN_WEIGHT=$GREEN_WEIGHT" + else + # Fallback to annotation-based weights + echo "DEBUG: No running deployment, using annotation-based weights" + BLUE_WEIGHT=$(echo "$SERVICES_JSON" | jq -r '.items[0].metadata.annotations["weight"] // "100"' | sed 's/"//g') + GREEN_WEIGHT=$(echo "$SERVICES_JSON" | jq -r '.items[1].metadata.annotations["weight"] // "0"' | sed 's/"//g') + echo "DEBUG: BLUE_WEIGHT=$BLUE_WEIGHT (from annotation), GREEN_WEIGHT=$GREEN_WEIGHT (from annotation)" + fi + + echo "Blue: $BLUE_SERVICE (weight: $BLUE_WEIGHT), Green: $GREEN_SERVICE (weight: $GREEN_WEIGHT)" + + # Build blue/green annotation similar to ALB format + echo "DEBUG: Building SCOPE_RULE for blue/green deployment..." + SCOPE_RULE=$(jq -n \ + --arg blue_service "$BLUE_SERVICE" \ + --argjson blue_weight "$BLUE_WEIGHT" \ + --arg green_service "$GREEN_SERVICE" \ + --argjson green_weight "$GREEN_WEIGHT" \ + --argjson port "$BLUE_PORT" \ + '{ + blue_green_annotation: { + forward: { + targetGroups: [ + { + serviceName: $blue_service, + weight: $blue_weight + }, + { + serviceName: $green_service, + weight: $green_weight + } + ] + } + }, + service: { + name: $blue_service, + port: { + number: $port + } + } + }') + echo "DEBUG: SCOPE_RULE (blue/green)=$SCOPE_RULE" + fi +fi + +export SCOPE_RULE \ No newline at end of file diff --git a/endpoint-exposer/scripts/istio/clean_httproute_rules b/endpoint-exposer/scripts/istio/clean_httproute_rules new file mode 100755 index 0000000..85179f6 --- /dev/null +++ b/endpoint-exposer/scripts/istio/clean_httproute_rules @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +echo "=== Cleaning HTTPRoute rules ===" +echo "HTTPROUTE_FILE: $HTTPROUTE_FILE" + +# Read the HTTPRoute +HTTPROUTE=$(cat "$HTTPROUTE_FILE") +echo "$HTTPROUTE" > "$HTTPROUTE_FILE.before" + +# Keep only the first rule (the fallback rule with response-404) +# Reset to empty state with just the fallback rule +CLEANED_HR=$(echo "$HTTPROUTE" | yq eval 'del(.spec.rules[1:])' -) + +# Ensure the first rule is the fallback rule +FIRST_RULE_BACKEND=$(echo "$CLEANED_HR" | yq eval '.spec.rules[0].backendRefs[0].name' -) + +if [[ "$FIRST_RULE_BACKEND" != "response-404" ]]; then + echo "WARNING: First rule is not the fallback rule. Resetting to fallback rule..." + + # Create a clean HTTPRoute with only the fallback rule + CLEANED_HR=$(echo "$CLEANED_HR" | yq eval '.spec.rules = [{"matches": [{"path": {"type": "PathPrefix", "value": "/"}}], "backendRefs": [{"name": "response-404", "port": 80, "weight": 0}]}]' -) +fi + +# Save the cleaned HTTPRoute +echo "$CLEANED_HR" > "$HTTPROUTE_FILE" +echo "$CLEANED_HR" > "$HTTPROUTE_FILE.cleaned" + +echo "HTTPRoute rules cleaned. Only fallback rule remains." diff --git a/endpoint-exposer/scripts/istio/config b/endpoint-exposer/scripts/istio/config new file mode 100755 index 0000000..c64693b --- /dev/null +++ b/endpoint-exposer/scripts/istio/config @@ -0,0 +1,10 @@ +#!/bin/bash + +# Gateway configuration +# These values can be overridden by environment variables +export PUBLIC_GATEWAY_NAME="${PUBLIC_GATEWAY_NAME:-gateway-public}" +export PRIVATE_GATEWAY_NAME="${PRIVATE_GATEWAY_NAME:-gateway-private}" +export GATEWAY_NAMESPACE="${GATEWAY_NAMESPACE:-gateways}" + +# OPA configuration +export OPA_PROVIDER_NAME="${OPA_PROVIDER_NAME:-opa-ext-authz}" diff --git a/endpoint-exposer/scripts/istio/fetch_httproute b/endpoint-exposer/scripts/istio/fetch_httproute new file mode 100755 index 0000000..dd1800a --- /dev/null +++ b/endpoint-exposer/scripts/istio/fetch_httproute @@ -0,0 +1,52 @@ +#!/bin/bash + +set -euo pipefail + +echo "=== Fetching existing HTTPRoute from Kubernetes ===" +echo "SERVICE_ID: $SERVICE_ID" +echo "SERVICE_SLUG: $SERVICE_SLUG" +echo "K8S_NAMESPACE: $K8S_NAMESPACE" + +# Build the HTTPRoute name using the same pattern as the template +HTTPROUTE_NAME="${SERVICE_SLUG}-${SERVICE_ID}-route" +TEMPLATE="$SERVICE_PATH/templates/istio/empty.yaml.tpl" +echo "HTTPRoute name: $HTTPROUTE_NAME" + +# Define output file +if [[ -n "${OUTPUT_FILE:-}" ]]; then + HTTPROUTE_FILE="$OUTPUT_FILE" +else + HTTPROUTE_FILE="$OUTPUT_DIR/httproute-$SERVICE_ID-public.yaml" +fi + +# Create output directory if it doesn't exist +mkdir -p "$OUTPUT_DIR" + +# Fetch the existing HTTPRoute from Kubernetes +echo "Fetching HTTPRoute '$HTTPROUTE_NAME' from namespace '$K8S_NAMESPACE'..." +if kubectl get httproute "$HTTPROUTE_NAME" -n "$K8S_NAMESPACE" -o yaml > "$HTTPROUTE_FILE" 2>/dev/null; then + echo "HTTPRoute fetched successfully" + + # Remove only managed fields and status, but keep important metadata + yq eval 'del(.metadata.managedFields, .status)' -i "$HTTPROUTE_FILE" + + echo "HTTPRoute cleaned and saved to: $HTTPROUTE_FILE" +else + echo "ERROR: HTTPRoute '$HTTPROUTE_NAME' not found in namespace '$K8S_NAMESPACE'" + echo "Creating new HTTPRoute from template instead..." + + # If HTTPRoute doesn't exist, create from template as fallback + CONTEXT_PATH="$OUTPUT_DIR/context-$SERVICE_ID.json" + echo "$CONTEXT" > "$CONTEXT_PATH" + + gomplate -c .="$CONTEXT_PATH" \ + --file "$SERVICE_PATH/templates/istio/empty.yaml.tpl" \ + --out "$HTTPROUTE_FILE" + + rm "$CONTEXT_PATH" + echo "New HTTPRoute created from template" +fi + +# Export the file path for the workflow +export HTTPROUTE_FILE +echo "HTTPRoute file ready at: $HTTPROUTE_FILE" diff --git a/endpoint-exposer/scripts/istio/fetch_provider_data b/endpoint-exposer/scripts/istio/fetch_provider_data new file mode 100755 index 0000000..0b29ce3 --- /dev/null +++ b/endpoint-exposer/scripts/istio/fetch_provider_data @@ -0,0 +1,17 @@ +#!/bin/bash + +NRN=$(echo "$CONTEXT" | jq -r .entity_nrn) + +DIMENSIONS=$(echo "$CONTEXT" | jq .service.dimensions) + +DIMENSION_FILTER=$(echo "$DIMENSIONS" | jq -r 'to_entries | map("\(.key):\(.value)") | join(",")') + +if [ -z "$DIMENSION_FILTER" ] || [ "$DIMENSION_FILTER" = "" ]; then + PROVIDER_DATA=$(np provider list --categories container-orchestration --nrn "$NRN" --format json | jq -r ".results[0]") +else + PROVIDER_DATA=$(np provider list --categories container-orchestration --nrn "$NRN" --dimensions "$DIMENSION_FILTER" --format json | jq -r ".results[0]") +fi + +# K8S_NAMESPACE=$(echo "$PROVIDER_DATA" | jq -r .attributes.cluster.namespace) + +export K8S_NAMESPACE \ No newline at end of file diff --git a/endpoint-exposer/scripts/istio/process_routes b/endpoint-exposer/scripts/istio/process_routes new file mode 100755 index 0000000..80b78cb --- /dev/null +++ b/endpoint-exposer/scripts/istio/process_routes @@ -0,0 +1,113 @@ +#!/bin/bash + +set -euo pipefail + +echo "=== Starting process_routes script ===" +echo "SERVICE_ID: $SERVICE_ID" +echo "SERVICE_SLUG: $SERVICE_SLUG" +echo "K8S_NAMESPACE: $K8S_NAMESPACE" +echo "ROUTES_JSON: $ROUTES_JSON" + +# Check if we have any routes to process +NUM_ROUTES=$(echo "$ROUTES_JSON" | jq 'length') +echo "Number of routes to process: $NUM_ROUTES" + +if [[ "$NUM_ROUTES" -eq 0 ]]; then + echo "No routes to process" + exit 0 +fi + +# Get application ID once +APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // empty') +if [[ -n "$APPLICATION_ID" ]]; then + echo "Application ID: $APPLICATION_ID" +else + echo "No Application ID found in context" + exit 1 +fi + +# Fetch all scopes once +echo "Fetching scopes for application $APPLICATION_ID..." +SCOPES_JSON=$(np scope list --application_id "$APPLICATION_ID" --format json | jq -rs ".[].results") +echo "Scopes fetched successfully" + +# Sort routes by path specificity (Exact > RegularExpression > PathPrefix) +# Priority: 1=Exact, 2=RegularExpression, 3=PathPrefix +echo "" +echo "=== Sorting routes by specificity ===" +SORTED_ROUTES=$(echo "$ROUTES_JSON" | jq 'sort_by( + if (.path | contains("*")) then 3 + elif (.path | contains(":")) then 2 + else 1 + end +)') +ROUTES_JSON="$SORTED_ROUTES" +echo "Routes sorted by specificity (Exact > RegularExpression > PathPrefix)" + +# Use existing HTTPROUTE_FILE if set, otherwise default to public +if [[ -z "${HTTPROUTE_FILE:-}" ]]; then + HTTPROUTE_FILE="$OUTPUT_DIR/httproute-$SERVICE_ID-public.yaml" +fi + +HTTPROUTE_NAME="${SERVICE_SLUG}-${SERVICE_ID}-route" + +export HTTPROUTE_FILE +echo "HTTPRoute file: $HTTPROUTE_FILE" +echo "HTTPRoute name: $HTTPROUTE_NAME" + +# Read the HTTPRoute from the file created in the previous step +if [[ ! -f "$HTTPROUTE_FILE" ]]; then + echo "ERROR: HTTPRoute file not found at $HTTPROUTE_FILE" + exit 1 +fi + +# Process each route +for ((i=0; i "$HTTPROUTE_FILE" + +echo "HTTPRoute hostname updated to: $DOMAIN" +echo "HTTPRoute parentRefs set to: $GATEWAY" + +# Debug: Verify the file was saved correctly +echo "DEBUG: Verifying saved file..." +SAVED_HOSTNAMES=$(cat "$HTTPROUTE_FILE" | yq eval '.spec.hostnames | length' -) +SAVED_PARENTREFS=$(cat "$HTTPROUTE_FILE" | yq eval '.spec.parentRefs | length' -) +echo "DEBUG: Saved file has $SAVED_HOSTNAMES hostnames" +echo "DEBUG: Saved file has $SAVED_PARENTREFS parentRefs" diff --git a/endpoint-exposer/scripts/setup-hooks.sh b/endpoint-exposer/scripts/setup-hooks.sh new file mode 100755 index 0000000..2b9c89c --- /dev/null +++ b/endpoint-exposer/scripts/setup-hooks.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Get the git repository root +REPO_ROOT="$(git rev-parse --show-toplevel)" + +# Configure git to use .githooks directory instead of .git/hooks +cd "$REPO_ROOT" +git config core.hooksPath .githooks + +echo "✅ Git hooks configured successfully!" +echo "Pre-commit hook will run endpoint-exposer tests before each commit when endpoint-exposer files are changed" diff --git a/endpoint-exposer/specs/actions/read.json.tpl b/endpoint-exposer/specs/actions/read.json.tpl new file mode 100644 index 0000000..f6a4c52 --- /dev/null +++ b/endpoint-exposer/specs/actions/read.json.tpl @@ -0,0 +1,25 @@ +{ + "name": "Read", + "slug": "read", + "type": "custom", + "annotations": {}, + "enabled_when": "", + "retryable": false, + "service_specification_id": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}", + "parameters": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + } +} \ No newline at end of file diff --git a/endpoint-exposer/specs/notification-channel.json.tpl b/endpoint-exposer/specs/notification-channel.json.tpl new file mode 100644 index 0000000..ee3c798 --- /dev/null +++ b/endpoint-exposer/specs/notification-channel.json.tpl @@ -0,0 +1,34 @@ +{ + "nrn": "{{ env.Getenv "NRN" }}", + "status": "active", + "type": "agent", + "source": [ + "telemetry", + "service" + ], + "configuration": { + "api_key": "{{ env.Getenv "NP_API_KEY" }}", + "command": { + "data": { + "cmdline": "{{ env.Getenv "REPO_PATH" }}/entrypoint --service-path={{ env.Getenv "REPO_PATH" }}/{{ env.Getenv "SERVICE_PATH" }}", + "environment": { + "NP_ACTION_CONTEXT": "'${NOTIFICATION_CONTEXT}'" + } + }, + "type": "exec" + }, + "selector": { + "environment": "{{ env.Getenv "ENVIRONMENT" }}" + } + }, + "filters": { + "$or": [ + { + "service.specification.slug": "{{ env.Getenv "SERVICE_SLUG" }}" + }, + { + "arguments.scope_provider": "{{ env.Getenv "SERVICE_SPECIFICATION_ID" }}" + } + ] + } +} \ No newline at end of file diff --git a/endpoint-exposer/specs/service-spec.json.tpl b/endpoint-exposer/specs/service-spec.json.tpl new file mode 100644 index 0000000..b8e8152 --- /dev/null +++ b/endpoint-exposer/specs/service-spec.json.tpl @@ -0,0 +1,196 @@ +{ + "assignable_to": "dimension", + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Authorization", + "elements": [ + { + "type": "Control", + "scope": "#/properties/authorization/properties/enabled" + }, + { + "type": "Control", + "scope": "#/properties/authorization/properties/headerName", + "rule": { + "effect": "SHOW", + "condition": { + "scope": "#/properties/authorization/properties/enabled", + "schema": { "const": true } + } + } + }, + { + "type": "Control", + "scope": "#/properties/authorization/properties/allowedValues", + "rule": { + "effect": "SHOW", + "condition": { + "scope": "#/properties/authorization/properties/enabled", + "schema": { "const": true } + } + } + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "GridLayout", + "columns": 4, + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/items/properties/method" + }, + { + "type": "Control", + "label": "Path", + "scope": "#/items/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/items/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/items/properties/visibility" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "publicDomain": { + "type": "string", + "title": "Public Domain", + "description": "Domain for public routes", + "enum": [ + "birds.edenred.nullimplementation.com", + "api.edenred.nullimplementation.com" + ], + "editableOn": ["create", "update"] + }, + "privateDomain": { + "type": "string", + "title": "Private Domain", + "description": "Domain for private routes", + "enum": [ + "birds-private.edenred.nullimplementation.com", + "api-private.edenred.nullimplementation.com" + ], + "editableOn": ["create", "update"] + }, + "authorization": { + "type": "object", + "title": "Authorization", + "description": "JWT authorization policy configuration using OPA", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enable Authorization Policy", + "description": "Enable JWT validation via OPA for all routes in this service", + "default": false + } + } + }, + "routes": { + "items": { + "properties": { + "method": { + "type": "string", + "title": "Verb", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + }, + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "description": "The scope slug", + "additionalKeywords": { + "enum": "[.scopes[]?.slug]" + } + }, + "visibility": { + "type": "string", + "title": "Visibility", + "description": "Route visibility level", + "enum": ["public", "private"], + "default": "public" + } + }, + "required": [ + "method", + "path", + "scope", + "visibility" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "publicDomain" + ], + "type": "object" + }, + "values": {} + }, + "dimensions": {}, + "name": "Service exposer V2", + "selectors": { + "category": "any", + "imported": false, + "provider": "any", + "sub_category": "any" + }, + "slug": "service-exposer", + "type": "dependency", + "use_default_actions": true, + "available_actions": [ + "read" + ], + "available_links": [ + ], + "visible_to": [ + "{{ env.Getenv "NRN" }}" + ] +} diff --git a/endpoint-exposer/templates/istio/empty.yaml.tpl b/endpoint-exposer/templates/istio/empty.yaml.tpl new file mode 100644 index 0000000..d0dd6de --- /dev/null +++ b/endpoint-exposer/templates/istio/empty.yaml.tpl @@ -0,0 +1,24 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ .service.slug }}-{{ .service.id }}-route + namespace: {{ .k8s_namespace }} + labels: + nullplatform: "true" + service: {{ .service.slug }} + service_id: {{ .service.id }} +spec: + parentRefs: + - name: gateway-public + namespace: gateways + hostnames: + - {{ if has . "parameters" }}{{ if has .parameters "public_domain" }}{{ .parameters.public_domain }}{{ else if has .parameters "domain" }}{{ .parameters.domain }}{{ else }}{{ .service.attributes.domain }}{{ end }}{{ else }}{{ .service.attributes.domain }}{{ end }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: response-404 + port: 80 + weight: 0 \ No newline at end of file diff --git a/endpoint-exposer/templates/istio/httproute.yaml.tpl b/endpoint-exposer/templates/istio/httproute.yaml.tpl new file mode 100644 index 0000000..728c0aa --- /dev/null +++ b/endpoint-exposer/templates/istio/httproute.yaml.tpl @@ -0,0 +1,26 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ .service_slug }}-{{ .service_id }}-{{ .suffix }} + namespace: {{ .k8s_namespace }} + labels: + nullplatform.com/managed-by: endpoint-exposer + nullplatform.com/service-id: "{{ .service_id }}" + app.kubernetes.io/name: {{ .service_slug }} +spec: + parentRefs: + - name: {{ .gateway_name }} + namespace: {{ .gateway_namespace }} + group: gateway.networking.k8s.io + kind: Gateway + hostnames: + - {{ .domain }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: response-404 + port: 80 + weight: 0 diff --git a/endpoint-exposer/templates/istio/opa-authz.yaml b/endpoint-exposer/templates/istio/opa-authz.yaml new file mode 100644 index 0000000..53abe56 --- /dev/null +++ b/endpoint-exposer/templates/istio/opa-authz.yaml @@ -0,0 +1,196 @@ +# ============================================================================= +# OPA External Authorization usando AuthorizationPolicy CUSTOM +# Esta es la forma RECOMENDADA por Istio +# +# IMPORTANTE: Requiere configurar el extensionProvider en el mesh config +# Ver instrucciones al final del archivo +# ============================================================================= +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opa-jwt-policies + namespace: istio-system + labels: + app: opa-ext-authz +data: + policy.rego: | + package envoy.authz + import future.keywords.if + import future.keywords.in + # Default: denegar + default allow := { + "allowed": false, + "http_status": 401, + "body": "Authorization required" + } + # Sin token + allow := { + "allowed": false, + "http_status": 401, + "body": "Authorization header required" + } if { + not bearer_token + } + # Token inválido + allow := { + "allowed": false, + "http_status": 401, + "body": "Invalid or expired token" + } if { + bearer_token + not is_token_valid + } + # Token válido pero sin claims + allow := { + "allowed": false, + "http_status": 403, + "body": "Missing required claim: foo=bar" + } if { + is_token_valid + not has_required_claims + } + # Método no permitido + allow := { + "allowed": false, + "http_status": 405, + "body": "Method not allowed" + } if { + is_token_valid + has_required_claims + not is_valid_method + } + # Todo válido - PERMITIR + allow := { + "allowed": true, + "headers": { + "x-user-id": token.payload.sub, + "x-validated-by": "opa-ext-authz" + } + } if { + is_token_valid + has_required_claims + is_valid_method + } + # ========================================================================= + # HELPERS + # ========================================================================= + is_valid_method if { + input.attributes.request.http.method in ["GET", "POST"] + } + is_token_valid if { + token.valid + token.payload.iss == "testing@secure.istio.io" + now := time.now_ns() / 1000000000 + token.payload.exp > now + } + has_required_claims if { + token.payload.foo == "bar" + } + token := {"valid": valid, "payload": payload} if { + [valid, _, payload] := io.jwt.decode_verify(bearer_token, { + "cert": data.keys.jwks, + "iss": "testing@secure.istio.io" + }) + } + token := {"valid": false, "payload": {}} if { + not bearer_token + } + bearer_token := t if { + auth_header := input.attributes.request.http.headers.authorization + startswith(auth_header, "Bearer ") + t := substring(auth_header, 7, -1) + } + data.json: | + { + "keys": { + "jwks": "{\"keys\":[{\"e\":\"AQAB\",\"kid\":\"DHFbpoIUqrY8t2zpA2qXfCmr5VO5ZEr4RzHU_-envvQ\",\"kty\":\"RSA\",\"n\":\"xAE7eB6qugXyCAG3yhh7pkDkT65pHymX-P7KfIupjf59vsdo91bSP9C8H07pSAGQO1MV_xFj9VswgsCg4R6otmg5PV2He95lZdHtOcU5DXIg_pbhLdKXbi66GlVeK6ABZOUW3WYtnNHD-91gVuoeJT_DwtGGcp4ignkgXfkiEm4sw-4sfb4qdt5oLbyVpmW6x9cfa7vs2WTfURiCrBoUqgBo_-4WTiULmmHSGZHOjzwa8WtrtOQGsAFjIbno85jp6MnGGGZPYZbDAa_b3y5u-YpW7ypZrvD8BgtKVjgtQgZhLAGezMt0ua3DRrWnKqTZ0BJ_EyxOGuHJrLsn00fnMQ\"}]}" + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opa-ext-authz + namespace: istio-system + labels: + app: opa-ext-authz +spec: + replicas: 2 + selector: + matchLabels: + app: opa-ext-authz + template: + metadata: + labels: + app: opa-ext-authz + annotations: + sidecar.istio.io/inject: "false" + spec: + containers: + - name: opa + image: openpolicyagent/opa:0.60.0-envoy + args: + - "run" + - "--server" + - "--addr=0.0.0.0:8181" + - "--diagnostic-addr=0.0.0.0:8282" + - "--set=plugins.envoy_ext_authz_grpc.addr=:9191" + - "--set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow" + - "--set=decision_logs.console=true" + - "--ignore=.*" + - "/policies/policy.rego" + - "/policies/data.json" + ports: + - name: grpc + containerPort: 9191 + - name: http + containerPort: 8181 + - name: diagnostics + containerPort: 8282 + volumeMounts: + - name: policies + mountPath: /policies + readOnly: true + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + livenessProbe: + httpGet: + path: /health?plugins + port: 8282 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health?plugins + port: 8282 + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: policies + configMap: + name: opa-jwt-policies +--- +apiVersion: v1 +kind: Service +metadata: + name: opa-ext-authz + namespace: istio-system + labels: + app: opa-ext-authz +spec: + selector: + app: opa-ext-authz + ports: + - name: grpc + port: 9191 + targetPort: 9191 + - name: http + port: 8181 + targetPort: 8181 + type: ClusterIP \ No newline at end of file diff --git a/endpoint-exposer/templates/istio/policies.yaml b/endpoint-exposer/templates/istio/policies.yaml new file mode 100644 index 0000000..e312861 --- /dev/null +++ b/endpoint-exposer/templates/istio/policies.yaml @@ -0,0 +1,50 @@ +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: birds-35f2062a-8cbf-447c-bc4e-d2d0b8577e75-authz + namespace: nullplatform + labels: + app.kubernetes.io/name: birds + nullplatform.com/service-id: "35f2062a-8cbf-447c-bc4e-d2d0b8577e75" + nullplatform.com/managed-by: endpoint-exposer +spec: + selector: + matchLabels: + nullplatform: "true" + action: CUSTOM + provider: + name: opa-ext-authz + rules: + # Aplicar OPA solo a /api/config + - to: + - operation: + hosts: + - birds.edenred.nullimplementation.com + methods: + - GET + paths: + - /api/config +--- +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: birds-leaderboard-allow + namespace: nullplatform + labels: + app.kubernetes.io/name: birds + nullplatform.com/managed-by: endpoint-exposer +spec: + selector: + matchLabels: + nullplatform: "true" + action: ALLOW + rules: + # Permitir /api/leaderboard sin validación + - to: + - operation: + hosts: + - birds.edenred.nullimplementation.com + methods: + - GET + paths: + - /api/leaderboard \ No newline at end of file diff --git a/endpoint-exposer/test/.gitignore b/endpoint-exposer/test/.gitignore new file mode 100644 index 0000000..f426236 --- /dev/null +++ b/endpoint-exposer/test/.gitignore @@ -0,0 +1,11 @@ +# Test temporary files +*.tmp +*.log + +# BATS test outputs +test-*.tap +test-*.xml + +# Temporary directories created during tests +tmp/ +temp/ diff --git a/endpoint-exposer/test/CONTRIBUTING.md b/endpoint-exposer/test/CONTRIBUTING.md new file mode 100644 index 0000000..7da1759 --- /dev/null +++ b/endpoint-exposer/test/CONTRIBUTING.md @@ -0,0 +1,302 @@ +# Contributing to Tests + +## Adding New Tests + +### 1. Create a New Test File + +Create a new file named `test_.bats`: + +```bash +#!/usr/bin/env bats + +load helpers + +@test "feature: description of what is being tested" { + # Setup test data + export CONTEXT=$(load_fixture "fixture-name") + source "$SERVICE_PATH/scripts/istio/build_context" + + # Execute the code under test + run bash "$SERVICE_PATH/scripts/your-script" + + # Assert results + assert_success + assert_output --partial "expected output" + assert_file_exists "$OUTPUT_DIR/expected-file.yaml" + assert_file_contains "$OUTPUT_DIR/expected-file.yaml" "expected content" +} +``` + +### 2. Add Test Fixtures + +Create fixture files in `fixtures/` directory: + +```bash +# fixtures/my-new-scenario.json +{ + "service": { + "id": "test-id", + "slug": "test-service" + }, + "parameters": { + "publicDomain": "test.example.com", + "privateDomain": "test-private.example.com", + "authorization": { + "enabled": true + } + }, + "routes": [ + { + "path": "/api/test", + "method": "GET", + "scope": "test:read", + "visibility": "public" + } + ] +} +``` + +### 3. Use Helper Functions + +Available helpers from `helpers.bash`: + +#### Setup/Teardown +- `setup()` - Automatically called before each test +- `teardown()` - Automatically called after each test + +#### File Assertions +- `assert_file_exists ` - Assert file exists +- `assert_file_not_exists ` - Assert file does not exist +- `assert_file_contains ` - Assert file contains string +- `assert_file_not_contains ` - Assert file does not contain string +- `assert_yaml_contains ` - Assert YAML has key-value pair + +#### Fixtures +- `load_fixture ` - Load a fixture JSON file +- `create_test_context ` - Create minimal context +- `add_route_to_context ` - Add route to context + +#### Mocking +- `mock_kubectl()` - Create a mock kubectl command + +### 4. Test Structure Best Practices + +#### Arrange-Act-Assert Pattern + +```bash +@test "description" { + # Arrange - Setup test data + export CONTEXT=$(load_fixture "scenario") + source "$SERVICE_PATH/scripts/istio/build_context" + + # Act - Execute the code + run bash "$SERVICE_PATH/scripts/my-script" + + # Assert - Verify results + assert_success + assert_file_exists "$OUTPUT_DIR/output.yaml" +} +``` + +#### Test One Thing + +Each test should verify one specific behavior: + +```bash +# Good - tests one thing +@test "build_httproute: creates public HTTPRoute when routes exist" { + # ... +} + +# Good - tests one thing +@test "build_httproute: creates marker when no routes exist" { + # ... +} + +# Bad - tests multiple things +@test "build_httproute: handles all scenarios" { + # ... tests too many things +} +``` + +#### Descriptive Test Names + +Use the format: `: ` + +```bash +@test "build_httproute: creates HTTPRoute when routes exist" +@test "build_httproute: creates marker when no routes exist" +@test "build_httproute: fails with invalid visibility parameter" +``` + +### 5. Testing Different Scenarios + +#### Test Success Cases + +```bash +@test "script: succeeds with valid input" { + export CONTEXT=$(load_fixture "valid-scenario") + run bash "$SERVICE_PATH/scripts/my-script" + assert_success +} +``` + +#### Test Failure Cases + +```bash +@test "script: fails with invalid input" { + export CONTEXT='{"invalid": "data"}' + run bash "$SERVICE_PATH/scripts/my-script" + assert_failure +} +``` + +#### Test Edge Cases + +```bash +@test "script: handles empty routes array" { + export CONTEXT=$(create_test_context "id" "slug" "" "") + # ... +} + +@test "script: handles missing optional parameters" { + export CONTEXT='{ + "service": {"id": "test", "slug": "test"}, + "parameters": {}, + "routes": [] + }' + # ... +} +``` + +### 6. Integration Tests + +For end-to-end workflow tests: + +```bash +@test "integration: complete update workflow" { + export CONTEXT=$(load_fixture "complete-scenario") + + # Step 1: Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Step 2: Build httproutes + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Step 3: Apply + run bash "$SERVICE_PATH/scripts/common/apply" + + # Assert complete workflow + assert_success + assert_file_exists "$OUTPUT_DIR/httproute-*-public.yaml" + # ... more assertions +} +``` + +### 7. Running Your Tests + +Run a specific test file: +```bash +bats test_my_feature.bats +``` + +Run all tests: +```bash +./run-tests.sh +``` + +Run with verbose output: +```bash +bats -t test_my_feature.bats +``` + +### 8. Debugging Tests + +Add debug output: +```bash +@test "my test" { + # Print variable values + echo "CONTEXT: $CONTEXT" >&3 + echo "OUTPUT_DIR: $OUTPUT_DIR" >&3 + + # Show file contents + cat "$OUTPUT_DIR/somefile.yaml" >&3 + + # ... rest of test +} +``` + +Run with trace: +```bash +bats -x test_my_feature.bats +``` + +### 9. Common Patterns + +#### Testing with Different Contexts + +```bash +@test "script: handles scenario A" { + export CONTEXT=$(load_fixture "scenario-a") + # ... test +} + +@test "script: handles scenario B" { + export CONTEXT=$(load_fixture "scenario-b") + # ... test +} +``` + +#### Testing File Generation + +```bash +@test "script: generates correct file" { + # ... run script + + # Check file exists + assert_file_exists "$OUTPUT_DIR/generated.yaml" + + # Check content + assert_file_contains "$OUTPUT_DIR/generated.yaml" "expected: value" + + # Check YAML structure + assert_yaml_contains "$OUTPUT_DIR/generated.yaml" ".metadata.name" "expected-name" +} +``` + +#### Testing Cleanup Behavior + +```bash +@test "script: creates cleanup marker when needed" { + # ... run script that should create marker + + assert_file_exists "$OUTPUT_DIR/.marker-deleted" + assert_file_not_exists "$OUTPUT_DIR/actual-resource.yaml" +} +``` + +### 10. Adding Tests to CI/CD + +The test suite can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +- name: Run tests + run: | + cd test + ./run-tests.sh +``` + +### 11. Test Coverage Guidelines + +Aim to test: +- ✅ Happy paths (normal operation) +- ✅ Error conditions (invalid input, missing data) +- ✅ Edge cases (empty arrays, null values, special characters) +- ✅ Integration scenarios (complete workflows) +- ✅ Cleanup behavior (resource deletion) +- ✅ Configuration variations (enabled/disabled features) diff --git a/endpoint-exposer/test/README.md b/endpoint-exposer/test/README.md new file mode 100644 index 0000000..7fc102b --- /dev/null +++ b/endpoint-exposer/test/README.md @@ -0,0 +1,103 @@ +# Endpoint Exposer Tests + +This directory contains tests for the endpoint-exposer service using BATS (Bash Automated Testing System). + +## Prerequisites + +Install BATS: +```bash +# macOS +brew install bats-core + +# Linux +git clone https://github.com/bats-core/bats-core.git +cd bats-core +sudo ./install.sh /usr/local +``` + +## Running Tests + +Run all tests: +```bash +cd test +./run-tests.sh +``` + +Run a specific test file: +```bash +bats test_istio_workflows.bats +``` + +## Git Hooks + +The repository includes a pre-commit hook that automatically runs tests before each commit. + +Setup the git hooks: +```bash +./scripts/setup-hooks.sh +``` + +This configures git to use the `.githooks` directory. The pre-commit hook will: +- Run all BATS tests before allowing a commit +- Skip tests if BATS is not installed (with a warning) +- Prevent commits if tests fail + +## Test Structure + +- `fixtures/` - Test data and context files +- `helpers.bash` - Common test helper functions +- `test_*.bats` - Test files +- `run-tests.sh` - Script to run all tests + +## Writing Tests + +Tests validate that given a specific context, the correct output files are generated without actually applying to Kubernetes. + +### Context Structure + +The test fixtures use the full nullplatform action context structure: + +```json +{ + "action": "service:action:update", + "id": "action-id", + "parameters": { + "routes": [...], + "public_domain": "...", + "private_domain": "...", + "authorization": { "enabled": true/false } + }, + "service": { + "id": "service-id", + "slug": "service-slug", + "attributes": { + "routes": [...], + "public_domain": "...", + "authorization": { "enabled": true/false } + } + }, + "tags": {...}, + ... +} +``` + +### Example Test + +```bash +@test "description" { + # Load a fixture with the full context structure + export CONTEXT=$(load_fixture "simple-public-routes") + + # Run workflow step + run bash "$SERVICE_PATH/scripts/istio/build_context" + + # Assert results + assert_success + assert_output --partial "expected output" + + # Verify generated files + assert_file_exists "$OUTPUT_DIR/httproute-service-id-public.yaml" + assert_file_contains "$OUTPUT_DIR/httproute-service-id-public.yaml" "expected content" +} +``` +# Test diff --git a/endpoint-exposer/test/fixtures/authorization-disabled.json b/endpoint-exposer/test/fixtures/authorization-disabled.json new file mode 100644 index 0000000..d9ed236 --- /dev/null +++ b/endpoint-exposer/test/fixtures/authorization-disabled.json @@ -0,0 +1,83 @@ +{ + "action": "service:action:update", + "id": "5b7636e1-304b-4ef9-92a9-2a0b102686f4", + "name": "update-api", + "slug": "update-api", + "status": "pending", + "created_at": "2026-01-12T19:50:09.357Z", + "updated_at": "2026-01-12T19:50:09.357Z", + "parameters": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": false + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "results": {}, + "type": "update", + "specification": { + "id": "4c85dfb2-b489-4cd0-af0f-651f670ac32a", + "slug": "update-endpoint-exposer" + }, + "service": { + "id": "fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd", + "slug": "api", + "attributes": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": false + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "type": "dependency", + "specification": { + "id": "7e71962b-1282-4131-84ae-bf7687238c74", + "slug": "endpoint-exposer" + }, + "dimensions": {} + }, + "link": null, + "user": { + "id": 1621446846, + "email": "javier.solis+edenred@nullplatform.io" + }, + "tags": { + "organization_id": "1858797910", + "organization": "edenred", + "namespace_id": "1340017944", + "namespace": "playground", + "account_id": "758973013", + "account": "playground", + "application_id": "179976948", + "application": "floppy-bird-api" + }, + "entity_nrn": "organization=1858797910:account=758973013:namespace=1340017944:application=179976948" +} diff --git a/endpoint-exposer/test/fixtures/no-public-routes.json b/endpoint-exposer/test/fixtures/no-public-routes.json new file mode 100644 index 0000000..0980832 --- /dev/null +++ b/endpoint-exposer/test/fixtures/no-public-routes.json @@ -0,0 +1,71 @@ +{ + "action": "service:action:update", + "id": "5b7636e1-304b-4ef9-92a9-2a0b102686f4", + "name": "update-api", + "slug": "update-api", + "status": "pending", + "created_at": "2026-01-12T19:50:09.357Z", + "updated_at": "2026-01-12T19:50:09.357Z", + "parameters": { + "routes": [ + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + } + ], + "public_domain": "", + "authorization": { + "enabled": false + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "results": {}, + "type": "update", + "specification": { + "id": "4c85dfb2-b489-4cd0-af0f-651f670ac32a", + "slug": "update-endpoint-exposer" + }, + "service": { + "id": "fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd", + "slug": "api", + "attributes": { + "routes": [ + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + } + ], + "public_domain": "", + "authorization": { + "enabled": false + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "type": "dependency", + "specification": { + "id": "7e71962b-1282-4131-84ae-bf7687238c74", + "slug": "endpoint-exposer" + }, + "dimensions": {} + }, + "link": null, + "user": { + "id": 1621446846, + "email": "javier.solis+edenred@nullplatform.io" + }, + "tags": { + "organization_id": "1858797910", + "organization": "edenred", + "namespace_id": "1340017944", + "namespace": "playground", + "account_id": "758973013", + "account": "playground", + "application_id": "179976948", + "application": "floppy-bird-api" + }, + "entity_nrn": "organization=1858797910:account=758973013:namespace=1340017944:application=179976948" +} diff --git a/endpoint-exposer/test/fixtures/public-and-private-routes.json b/endpoint-exposer/test/fixtures/public-and-private-routes.json new file mode 100644 index 0000000..efff3d4 --- /dev/null +++ b/endpoint-exposer/test/fixtures/public-and-private-routes.json @@ -0,0 +1,95 @@ +{ + "action": "service:action:update", + "id": "5b7636e1-304b-4ef9-92a9-2a0b102686f4", + "name": "update-api", + "slug": "update-api", + "status": "pending", + "created_at": "2026-01-12T19:50:09.357Z", + "updated_at": "2026-01-12T19:50:09.357Z", + "parameters": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin/users", + "scope": "admin:users:write", + "method": "POST" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": true + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "results": {}, + "type": "update", + "specification": { + "id": "4c85dfb2-b489-4cd0-af0f-651f670ac32a", + "slug": "update-endpoint-exposer" + }, + "service": { + "id": "fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd", + "slug": "api", + "attributes": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin", + "scope": "admin:read", + "method": "GET" + }, + { + "visibility": "private", + "path": "/api/admin/users", + "scope": "admin:users:write", + "method": "POST" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": true + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "type": "dependency", + "specification": { + "id": "7e71962b-1282-4131-84ae-bf7687238c74", + "slug": "endpoint-exposer" + }, + "dimensions": {} + }, + "link": null, + "user": { + "id": 1621446846, + "email": "javier.solis+edenred@nullplatform.io" + }, + "tags": { + "organization_id": "1858797910", + "organization": "edenred", + "namespace_id": "1340017944", + "namespace": "playground", + "account_id": "758973013", + "account": "playground", + "application_id": "179976948", + "application": "floppy-bird-api" + }, + "entity_nrn": "organization=1858797910:account=758973013:namespace=1340017944:application=179976948" +} diff --git a/endpoint-exposer/test/fixtures/simple-public-routes.json b/endpoint-exposer/test/fixtures/simple-public-routes.json new file mode 100644 index 0000000..1a1d16e --- /dev/null +++ b/endpoint-exposer/test/fixtures/simple-public-routes.json @@ -0,0 +1,83 @@ +{ + "action": "service:action:update", + "id": "5b7636e1-304b-4ef9-92a9-2a0b102686f4", + "name": "update-api", + "slug": "update-api", + "status": "pending", + "created_at": "2026-01-12T19:50:09.357Z", + "updated_at": "2026-01-12T19:50:09.357Z", + "parameters": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "public", + "path": "/api/users", + "scope": "users:write", + "method": "POST" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": false + }, + "private_domain": "" + }, + "results": {}, + "type": "update", + "specification": { + "id": "4c85dfb2-b489-4cd0-af0f-651f670ac32a", + "slug": "update-endpoint-exposer" + }, + "service": { + "id": "fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd", + "slug": "api", + "attributes": { + "routes": [ + { + "visibility": "public", + "path": "/api/users", + "scope": "users:read", + "method": "GET" + }, + { + "visibility": "public", + "path": "/api/users", + "scope": "users:write", + "method": "POST" + } + ], + "public_domain": "api.edenred.nullimplementation.com", + "authorization": { + "enabled": false + }, + "private_domain": "api-private.edenred.nullimplementation.com" + }, + "type": "dependency", + "specification": { + "id": "7e71962b-1282-4131-84ae-bf7687238c74", + "slug": "endpoint-exposer" + }, + "dimensions": {} + }, + "link": null, + "user": { + "id": 1621446846, + "email": "javier.solis+edenred@nullplatform.io" + }, + "tags": { + "organization_id": "1858797910", + "organization": "edenred", + "namespace_id": "1340017944", + "namespace": "playground", + "account_id": "758973013", + "account": "playground", + "application_id": "179976948", + "application": "floppy-bird-api" + }, + "entity_nrn": "organization=1858797910:account=758973013:namespace=1340017944:application=179976948" +} diff --git a/endpoint-exposer/test/helpers.bash b/endpoint-exposer/test/helpers.bash new file mode 100644 index 0000000..115840e --- /dev/null +++ b/endpoint-exposer/test/helpers.bash @@ -0,0 +1,298 @@ +#!/bin/bash + +# Test helpers for endpoint-exposer tests + +# Setup function called before each test +setup() { + # Create temporary output directory + export TEST_TEMP_DIR="$(mktemp -d)" + export OUTPUT_DIR="$TEST_TEMP_DIR/output" + mkdir -p "$OUTPUT_DIR" + + # Set SERVICE_PATH to parent directory + export SERVICE_PATH="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + + # Mock DRY_RUN to true by default to avoid actual kubectl calls + export DRY_RUN="${DRY_RUN:-true}" + export ACTION="${ACTION:-apply}" + + # Load bats support libraries if available + load_bats_support_libraries +} + +# Teardown function called after each test +teardown() { + # Clean up temporary directory + if [[ -n "$TEST_TEMP_DIR" ]] && [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Load bats support libraries or define basic assertions +load_bats_support_libraries() { + # Try to load bats-support and bats-assert if available + local loaded=false + if [[ -f "/usr/local/lib/bats-support/load.bash" ]]; then + load "/usr/local/lib/bats-support/load.bash" + loaded=true + fi + if [[ -f "/usr/local/lib/bats-assert/load.bash" ]]; then + load "/usr/local/lib/bats-assert/load.bash" + loaded=true + fi + + # If libraries not loaded, define basic assertion functions + if [[ "$loaded" == "false" ]]; then + # Define assert_success + assert_success() { + if [[ "$status" -ne 0 ]]; then + echo "Expected success (exit 0) but got: $status" >&2 + echo "Output: $output" >&2 + return 1 + fi + } + + # Define assert_failure + assert_failure() { + if [[ "$status" -eq 0 ]]; then + echo "Expected failure (non-zero exit) but got: $status" >&2 + echo "Output: $output" >&2 + return 1 + fi + } + + # Define assert_output + assert_output() { + local expected="" + local partial=false + + while [[ $# -gt 0 ]]; do + case $1 in + --partial) + partial=true + shift + ;; + *) + expected="$1" + shift + ;; + esac + done + + if [[ "$partial" == "true" ]]; then + if [[ "$output" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" >&2 + echo "Actual output: $output" >&2 + return 1 + fi + else + if [[ "$output" != "$expected" ]]; then + echo "Expected output: $expected" >&2 + echo "Actual output: $output" >&2 + return 1 + fi + fi + } + fi +} + +# Assert that a file exists +assert_file_exists() { + local file="$1" + if [[ ! -f "$file" ]]; then + echo "File does not exist: $file" >&2 + return 1 + fi +} + +# Assert that a file does not exist +assert_file_not_exists() { + local file="$1" + if [[ -f "$file" ]]; then + echo "File exists but should not: $file" >&2 + return 1 + fi +} + +# Assert that a file contains a string +assert_file_contains() { + local file="$1" + local expected="$2" + + if [[ ! -f "$file" ]]; then + echo "File does not exist: $file" >&2 + return 1 + fi + + if ! grep -q "$expected" "$file"; then + echo "File does not contain expected string: $expected" >&2 + echo "File contents:" >&2 + cat "$file" >&2 + return 1 + fi +} + +# Assert that a file does not contain a string +assert_file_not_contains() { + local file="$1" + local unexpected="$2" + + if [[ ! -f "$file" ]]; then + echo "File does not exist: $file" >&2 + return 1 + fi + + if grep -q "$unexpected" "$file"; then + echo "File contains unexpected string: $unexpected" >&2 + echo "File contents:" >&2 + cat "$file" >&2 + return 1 + fi +} + +# Assert that a YAML file has a specific key-value pair +assert_yaml_contains() { + local file="$1" + local key="$2" + local expected_value="$3" + + if [[ ! -f "$file" ]]; then + echo "File does not exist: $file" >&2 + return 1 + fi + + local actual_value + actual_value=$(yq eval "$key" "$file" 2>/dev/null || echo "") + + if [[ "$actual_value" != "$expected_value" ]]; then + echo "YAML key '$key' has unexpected value" >&2 + echo "Expected: $expected_value" >&2 + echo "Actual: $actual_value" >&2 + return 1 + fi +} + +# Count the number of YAML documents in a file +count_yaml_documents() { + local file="$1" + grep -c "^---" "$file" || echo "0" +} + +# Load a fixture context file +load_fixture() { + local fixture_name="$1" + local fixture_file="$BATS_TEST_DIRNAME/fixtures/$fixture_name.json" + + if [[ ! -f "$fixture_file" ]]; then + echo "Fixture not found: $fixture_file" >&2 + return 1 + fi + + cat "$fixture_file" +} + +# Mock kubectl to avoid actual API calls +mock_kubectl() { + # Create a mock kubectl script + cat > "$TEST_TEMP_DIR/kubectl" << 'EOF' +#!/bin/bash +echo "Mock kubectl called with: $@" >&2 +exit 0 +EOF + chmod +x "$TEST_TEMP_DIR/kubectl" + export PATH="$TEST_TEMP_DIR:$PATH" +} + +# Create a minimal valid context for testing with full structure +create_test_context() { + local service_id="${1:-test-service-id}" + local service_slug="${2:-test-service}" + local public_domain="${3:-test.example.com}" + local private_domain="${4:-test-private.example.com}" + + cat < /dev/null; then + echo -e "${RED}Error: bats is not installed${NC}" + echo "" + echo "Install bats:" + echo " macOS: brew install bats-core" + echo " Linux: git clone https://github.com/bats-core/bats-core.git && cd bats-core && sudo ./install.sh /usr/local" + echo "" + exit 1 +fi + +# Check if jq is installed (required by tests) +if ! command -v jq &> /dev/null; then + echo -e "${RED}Error: jq is not installed${NC}" + echo "" + echo "Install jq:" + echo " macOS: brew install jq" + echo " Linux: sudo apt-get install jq" + echo "" + exit 1 +fi + +# Change to test directory +cd "$(dirname "$0")" + +# Run tests +echo "Running tests..." +echo "" + +TEST_FILES=( + "test_build_context.bats" + "test_build_httproute.bats" + "test_authorization_policy.bats" + "test_apply_cleanup.bats" + "test_integration.bats" +) + +FAILED=0 +PASSED=0 + +for test_file in "${TEST_FILES[@]}"; do + if [[ -f "$test_file" ]]; then + echo -e "${YELLOW}Running $test_file...${NC}" + if bats "$test_file"; then + ((PASSED++)) + echo -e "${GREEN}✓ $test_file passed${NC}" + else + ((FAILED++)) + echo -e "${RED}✗ $test_file failed${NC}" + fi + echo "" + fi +done + +echo "================================================" +echo " Test Summary" +echo "================================================" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo "" + +if [[ $FAILED -gt 0 ]]; then + echo -e "${RED}Some tests failed${NC}" + exit 1 +else + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +fi diff --git a/endpoint-exposer/test/test_apply_cleanup.bats b/endpoint-exposer/test/test_apply_cleanup.bats new file mode 100644 index 0000000..cfc76bd --- /dev/null +++ b/endpoint-exposer/test/test_apply_cleanup.bats @@ -0,0 +1,139 @@ +#!/usr/bin/env bats + +load helpers + +setup() { + export TEST_TEMP_DIR="$(mktemp -d)" + export OUTPUT_DIR="$TEST_TEMP_DIR/output" + mkdir -p "$OUTPUT_DIR" + export SERVICE_PATH="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + + # Load assert functions + load_bats_support_libraries + + export K8S_NAMESPACE="test-namespace" + export SERVICE_ID="test-service-id" + export SERVICE_SLUG="test-service" + export ACTION="apply" + export DRY_RUN="true" + + # Mock kubectl + mock_kubectl +} + +@test "apply: detects public httproute marker and attempts deletion" { + # Create marker file + touch "$OUTPUT_DIR/.httproute-public-deleted" + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Public HTTPRoute marked for deletion" + assert_output --partial "httproute" + assert_output --partial "$SERVICE_SLUG-$SERVICE_ID-public" +} + +@test "apply: detects private httproute marker and attempts deletion" { + # Create marker file + touch "$OUTPUT_DIR/.httproute-private-deleted" + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Private HTTPRoute marked for deletion" + assert_output --partial "httproute" + assert_output --partial "$SERVICE_SLUG-$SERVICE_ID-private" +} + +@test "apply: detects public authz marker and attempts deletion" { + # Create marker file + touch "$OUTPUT_DIR/.authz-public-deleted" + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Public AuthorizationPolicy marked for deletion" + assert_output --partial "authorizationpolicy" + assert_output --partial "$SERVICE_SLUG-$SERVICE_ID-authz-public" +} + +@test "apply: detects private authz marker and attempts deletion" { + # Create marker file + touch "$OUTPUT_DIR/.authz-private-deleted" + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Private AuthorizationPolicy marked for deletion" + assert_output --partial "authorizationpolicy" + assert_output --partial "$SERVICE_SLUG-$SERVICE_ID-authz-private" +} + +@test "apply: handles multiple marker files" { + # Create multiple marker files + touch "$OUTPUT_DIR/.httproute-public-deleted" + touch "$OUTPUT_DIR/.httproute-private-deleted" + touch "$OUTPUT_DIR/.authz-public-deleted" + touch "$OUTPUT_DIR/.authz-private-deleted" + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Public HTTPRoute marked for deletion" + assert_output --partial "Private HTTPRoute marked for deletion" + assert_output --partial "Public AuthorizationPolicy marked for deletion" + assert_output --partial "Private AuthorizationPolicy marked for deletion" +} + +@test "apply: applies yaml files when present" { + # Create a test yaml file + cat > "$OUTPUT_DIR/test-resource.yaml" << EOF +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +EOF + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "Applying 1 resources" +} + +@test "apply: handles no resources to apply" { + # No yaml files, no markers + + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + assert_output --partial "No resources to apply" +} + +@test "apply: removes marker files after processing" { + # Create marker files + touch "$OUTPUT_DIR/.httproute-public-deleted" + touch "$OUTPUT_DIR/.authz-private-deleted" + + bash "$SERVICE_PATH/scripts/common/apply" + + # Marker files should be removed + assert_file_not_exists "$OUTPUT_DIR/.httproute-public-deleted" + assert_file_not_exists "$OUTPUT_DIR/.authz-private-deleted" +} + +@test "apply: moves yaml files to apply directory after processing" { + # Create a test yaml file + cat > "$OUTPUT_DIR/test-resource.yaml" << EOF +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +EOF + + bash "$SERVICE_PATH/scripts/common/apply" + + # Original file should be moved + assert_file_not_exists "$OUTPUT_DIR/test-resource.yaml" + # Should be in apply directory + assert_file_exists "$OUTPUT_DIR/apply/test-resource.yaml" +} diff --git a/endpoint-exposer/test/test_build_context.bats b/endpoint-exposer/test/test_build_context.bats new file mode 100644 index 0000000..a337351 --- /dev/null +++ b/endpoint-exposer/test/test_build_context.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +load helpers + +setup() { + # Call parent setup + export TEST_TEMP_DIR="$(mktemp -d)" + export OUTPUT_DIR="$TEST_TEMP_DIR/output" + mkdir -p "$OUTPUT_DIR" + export SERVICE_PATH="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + + # Load assert functions + load_bats_support_libraries + + # Mock K8S_NAMESPACE (required by build_context) + export K8S_NAMESPACE="test-namespace" +} + +teardown() { + if [[ -n "$TEST_TEMP_DIR" ]] && [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +@test "build_context: extracts service id and slug correctly" { + export CONTEXT=$(load_fixture "simple-public-routes") + + source "$SERVICE_PATH/scripts/istio/build_context" + + [[ "$SERVICE_ID" == "fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd" ]] + [[ "$SERVICE_SLUG" == "api" ]] +} + +@test "build_context: extracts public and private domains" { + export CONTEXT=$(load_fixture "public-and-private-routes") + + source "$SERVICE_PATH/scripts/istio/build_context" + + [[ "$PUBLIC_DOMAIN" == "api.edenred.nullimplementation.com" ]] + [[ "$PRIVATE_DOMAIN" == "api-private.edenred.nullimplementation.com" ]] +} + +@test "build_context: splits routes by visibility" { + export CONTEXT=$(load_fixture "public-and-private-routes") + + source "$SERVICE_PATH/scripts/istio/build_context" + + # Check public routes + local num_public=$(echo "$PUBLIC_ROUTES_JSON" | jq 'length') + [[ "$num_public" == "1" ]] + + # Check private routes + local num_private=$(echo "$PRIVATE_ROUTES_JSON" | jq 'length') + [[ "$num_private" == "2" ]] +} + +@test "build_context: handles missing visibility as public" { + export CONTEXT='{ + "service": {"id": "test-id", "slug": "test"}, + "parameters": {"publicDomain": "test.com", "privateDomain": ""}, + "routes": [ + {"path": "/test", "method": "GET", "scope": "test:read"} + ] + }' + + source "$SERVICE_PATH/scripts/istio/build_context" + + # Route without visibility should be treated as public + local num_public=$(echo "$PUBLIC_ROUTES_JSON" | jq 'length') + [[ "$num_public" == "1" ]] + + local num_private=$(echo "$PRIVATE_ROUTES_JSON" | jq 'length') + [[ "$num_private" == "0" ]] +} + +@test "build_context: handles empty private domain" { + export CONTEXT=$(load_fixture "simple-public-routes") + + source "$SERVICE_PATH/scripts/istio/build_context" + + [[ "$PUBLIC_DOMAIN" == "api.edenred.nullimplementation.com" ]] + [[ -z "$PRIVATE_DOMAIN" ]] +} + +@test "build_context: exports all required variables" { + export CONTEXT=$(load_fixture "public-and-private-routes") + + source "$SERVICE_PATH/scripts/istio/build_context" + + # Check that all required variables are exported + [[ -n "$SERVICE_ID" ]] + [[ -n "$SERVICE_SLUG" ]] + [[ -n "$PUBLIC_DOMAIN" ]] + [[ -n "$PRIVATE_DOMAIN" ]] + [[ -n "$ROUTES_JSON" ]] + [[ -n "$PUBLIC_ROUTES_JSON" ]] + [[ -n "$PRIVATE_ROUTES_JSON" ]] +} diff --git a/endpoint-exposer/test/test_build_httproute.bats b/endpoint-exposer/test/test_build_httproute.bats new file mode 100644 index 0000000..6a9bf23 --- /dev/null +++ b/endpoint-exposer/test/test_build_httproute.bats @@ -0,0 +1,179 @@ +#!/usr/bin/env bats + +load helpers + +setup() { + # Call parent setup + export TEST_TEMP_DIR="$(mktemp -d)" + export OUTPUT_DIR="$TEST_TEMP_DIR/output" + mkdir -p "$OUTPUT_DIR" + export SERVICE_PATH="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + + # Load assert functions + load_bats_support_libraries + + # Mock kubectl and provider data + export K8S_NAMESPACE="test-namespace" + export ALB_NAME="test-alb" + + # Mock gomplate + cat > "$TEST_TEMP_DIR/gomplate" << 'EOF' +#!/bin/bash +# Simple gomplate mock - just copy template to output +TEMPLATE_FILE="" +OUTPUT_FILE="" + +while [[ $# -gt 0 ]]; do + case $1 in + -f) TEMPLATE_FILE="$2"; shift 2 ;; + -o) OUTPUT_FILE="$2"; shift 2 ;; + -c) shift 2 ;; # Ignore context + *) shift ;; + esac +done + +if [[ -n "$TEMPLATE_FILE" ]] && [[ -n "$OUTPUT_FILE" ]]; then + # For testing, just create a valid YAML with the service info + cat > "$OUTPUT_FILE" << YAML +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ${SERVICE_SLUG}-${SERVICE_ID}-${SUFFIX:-public} + namespace: ${K8S_NAMESPACE} +spec: + hostnames: + - ${DOMAIN} +YAML +fi +EOF + chmod +x "$TEST_TEMP_DIR/gomplate" + export PATH="$TEST_TEMP_DIR:$PATH" + + # Mock process_routes script + if [[ ! -f "$SERVICE_PATH/scripts/istio/process_routes.bak" ]]; then + if [[ -f "$SERVICE_PATH/scripts/istio/process_routes" ]]; then + cp "$SERVICE_PATH/scripts/istio/process_routes" "$SERVICE_PATH/scripts/istio/process_routes.bak" + fi + fi + cat > "$SERVICE_PATH/scripts/istio/process_routes" << 'MOCKEOF' +#!/bin/bash +# Mock - does nothing +# Use return instead of exit so it doesn't exit the sourcing shell +return 0 2>/dev/null || true +MOCKEOF + chmod +x "$SERVICE_PATH/scripts/istio/process_routes" +} + +teardown() { + # Always restore original process_routes if backup exists + if [[ -f "$SERVICE_PATH/scripts/istio/process_routes.bak" ]]; then + mv -f "$SERVICE_PATH/scripts/istio/process_routes.bak" "$SERVICE_PATH/scripts/istio/process_routes" + fi + + # Clean up temp directory + if [[ -n "$TEST_TEMP_DIR" ]] && [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +@test "build_httproute: generates public HTTPRoute with routes" { + export CONTEXT=$(load_fixture "simple-public-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="public" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_success + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" +} + +@test "build_httproute: generates private HTTPRoute with routes" { + export CONTEXT=$(load_fixture "public-and-private-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="private" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_success + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" +} + +@test "build_httproute: creates marker file when no public routes" { + export CONTEXT=$(load_fixture "no-public-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="public" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_success + assert_file_not_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" + assert_file_exists "$OUTPUT_DIR/.httproute-public-deleted" +} + +@test "build_httproute: creates marker file when no public domain" { + export CONTEXT='{ + "service": {"id": "test-id", "slug": "test"}, + "parameters": {"publicDomain": "", "privateDomain": "private.test.com"}, + "routes": [{"path": "/test", "method": "GET", "scope": "test", "visibility": "public"}] + }' + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="public" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_success + assert_file_not_exists "$OUTPUT_DIR/httproute-test-id-public.yaml" + assert_file_exists "$OUTPUT_DIR/.httproute-public-deleted" +} + +@test "build_httproute: creates marker file when no private routes" { + export CONTEXT=$(load_fixture "simple-public-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="private" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_success + assert_file_not_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" + assert_file_exists "$OUTPUT_DIR/.httproute-private-deleted" +} + +@test "build_httproute: fails with invalid visibility" { + export CONTEXT=$(load_fixture "simple-public-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="invalid" + + run bash "$SERVICE_PATH/scripts/istio/build_httproute" + + assert_failure +} + +@test "build_httproute: exports HTTPROUTE_PUBLIC_FILE for public" { + export CONTEXT=$(load_fixture "simple-public-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="public" + + source "$SERVICE_PATH/scripts/istio/build_httproute" + + [[ -n "$HTTPROUTE_PUBLIC_FILE" ]] + [[ "$HTTPROUTE_PUBLIC_FILE" == *"public.yaml" ]] +} + +@test "build_httproute: exports HTTPROUTE_PRIVATE_FILE for private" { + export CONTEXT=$(load_fixture "public-and-private-routes") + source "$SERVICE_PATH/scripts/istio/build_context" + + export VISIBILITY="private" + + source "$SERVICE_PATH/scripts/istio/build_httproute" + + [[ -n "$HTTPROUTE_PRIVATE_FILE" ]] + [[ "$HTTPROUTE_PRIVATE_FILE" == *"private.yaml" ]] +} diff --git a/endpoint-exposer/test/test_integration.bats b/endpoint-exposer/test/test_integration.bats new file mode 100644 index 0000000..d094fe5 --- /dev/null +++ b/endpoint-exposer/test/test_integration.bats @@ -0,0 +1,229 @@ +#!/usr/bin/env bats + +load helpers + +setup() { + export TEST_TEMP_DIR="$(mktemp -d)" + export OUTPUT_DIR="$TEST_TEMP_DIR/output" + mkdir -p "$OUTPUT_DIR" + export SERVICE_PATH="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + + # Load assert functions + load_bats_support_libraries + + export K8S_NAMESPACE="test-namespace" + export ALB_NAME="test-alb" + export ACTION="apply" + export DRY_RUN="true" + + # Mock kubectl + mock_kubectl + + # Mock gomplate + cat > "$TEST_TEMP_DIR/gomplate" << 'EOF' +#!/bin/bash +TEMPLATE_FILE="" +OUTPUT_FILE="" +CONTEXT_FILE="" + +while [[ $# -gt 0 ]]; do + case $1 in + -f) TEMPLATE_FILE="$2"; shift 2 ;; + -o) OUTPUT_FILE="$2"; shift 2 ;; + -c) CONTEXT_FILE="${2#.=}"; shift 2 ;; + *) shift ;; + esac +done + +if [[ -n "$TEMPLATE_FILE" ]] && [[ -n "$OUTPUT_FILE" ]]; then + # Read context if provided + if [[ -n "$CONTEXT_FILE" ]] && [[ -f "$CONTEXT_FILE" ]]; then + CONTEXT_JSON=$(cat "$CONTEXT_FILE") + SERVICE_SLUG=$(echo "$CONTEXT_JSON" | jq -r '.service_slug // ""') + SERVICE_ID=$(echo "$CONTEXT_JSON" | jq -r '.service_id // ""') + SUFFIX=$(echo "$CONTEXT_JSON" | jq -r '.suffix // ""') + DOMAIN=$(echo "$CONTEXT_JSON" | jq -r '.domain // ""') + NAMESPACE=$(echo "$CONTEXT_JSON" | jq -r '.k8s_namespace // .gateway_namespace // ""') + fi + + # Determine resource type from template + if [[ "$TEMPLATE_FILE" == *"httproute"* ]]; then + cat > "$OUTPUT_FILE" << YAML +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ${SERVICE_SLUG}-${SERVICE_ID}-${SUFFIX} + namespace: ${NAMESPACE} + labels: + nullplatform.com/managed-by: endpoint-exposer + nullplatform.com/service-id: "${SERVICE_ID}" + app.kubernetes.io/name: ${SERVICE_SLUG} +spec: + hostnames: + - ${DOMAIN} +YAML + elif [[ "$TEMPLATE_FILE" == *"authorization"* ]]; then + cat > "$OUTPUT_FILE" << YAML +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: ${SERVICE_SLUG}-${SERVICE_ID}-authz-${SUFFIX} + namespace: ${NAMESPACE} + labels: + nullplatform.com/managed-by: endpoint-exposer + nullplatform.com/service-id: "${SERVICE_ID}" + app.kubernetes.io/name: ${SERVICE_SLUG} +spec: + action: CUSTOM +YAML + fi +fi +EOF + chmod +x "$TEST_TEMP_DIR/gomplate" + export PATH="$TEST_TEMP_DIR:$PATH" + + # Mock process_routes script (it's sourced by build_httproute) + mkdir -p "$SERVICE_PATH/scripts/istio" + if [[ ! -f "$SERVICE_PATH/scripts/istio/process_routes.bak" ]]; then + # Backup original if exists + if [[ -f "$SERVICE_PATH/scripts/istio/process_routes" ]]; then + cp "$SERVICE_PATH/scripts/istio/process_routes" "$SERVICE_PATH/scripts/istio/process_routes.bak" + fi + fi + + # Create a minimal mock that does nothing (for testing we just need the HTTPRoute YAML) + cat > "$SERVICE_PATH/scripts/istio/process_routes" << 'MOCKEOF' +#!/bin/bash +# Mock process_routes for testing - does nothing +# In real tests, the gomplate mock already creates the YAML we need +# Use return instead of exit so it doesn't exit the sourcing shell +return 0 2>/dev/null || true +MOCKEOF + chmod +x "$SERVICE_PATH/scripts/istio/process_routes" +} + +teardown() { + # Always restore original process_routes if backup exists + if [[ -f "$SERVICE_PATH/scripts/istio/process_routes.bak" ]]; then + mv -f "$SERVICE_PATH/scripts/istio/process_routes.bak" "$SERVICE_PATH/scripts/istio/process_routes" + fi + + # Clean up temp directory + if [[ -n "$TEST_TEMP_DIR" ]] && [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +@test "integration: complete workflow with public routes only" { + export CONTEXT=$(load_fixture "simple-public-routes") + + # Step 1: Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Step 2: Build public httproute + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Step 3: Build private httproute (should create marker) + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Verify outputs + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" + assert_file_not_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" + assert_file_exists "$OUTPUT_DIR/.httproute-private-deleted" + + # Verify public HTTPRoute content + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" "HTTPRoute" + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" "api.edenred.nullimplementation.com" +} + +@test "integration: complete workflow with public and private routes" { + export CONTEXT=$(load_fixture "public-and-private-routes") + + # Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Build httproutes + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Verify all resources created + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" + + # Verify no marker files (all resources should be created) + assert_file_not_exists "$OUTPUT_DIR/.httproute-public-deleted" + assert_file_not_exists "$OUTPUT_DIR/.httproute-private-deleted" + + # Verify content + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" "api.edenred.nullimplementation.com" + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" "api-private.edenred.nullimplementation.com" +} + +@test "integration: workflow with authorization disabled creates cleanup markers" { + export CONTEXT=$(load_fixture "authorization-disabled") + + # Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Build httproutes + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Verify httproutes created + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" + assert_file_exists "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" +} + +@test "integration: apply step handles markers and resources correctly" { + export CONTEXT=$(load_fixture "simple-public-routes") + + # Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Build httproutes + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Run apply + run bash "$SERVICE_PATH/scripts/common/apply" + + assert_success + + # Should detect and process markers + assert_output --partial "Private HTTPRoute marked for deletion" + + # Should apply the public httproute + assert_output --partial "Applying 1 resources" +} + +@test "integration: all resources have correct labels for management" { + export CONTEXT=$(load_fixture "public-and-private-routes") + + # Build context + source "$SERVICE_PATH/scripts/istio/build_context" + + # Build httproutes + export VISIBILITY="public" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + export VISIBILITY="private" + bash "$SERVICE_PATH/scripts/istio/build_httproute" + + # Verify all resources have required labels + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" "nullplatform.com/managed-by: endpoint-exposer" + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" "nullplatform.com/managed-by: endpoint-exposer" + + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-public.yaml" "nullplatform.com/service-id:" + assert_file_contains "$OUTPUT_DIR/httproute-fbcf7a60-8ca8-4bf2-b1b5-5c59bb5bc4fd-private.yaml" "nullplatform.com/service-id:" +} diff --git a/endpoint-exposer/values.yaml b/endpoint-exposer/values.yaml new file mode 100644 index 0000000..6831afc --- /dev/null +++ b/endpoint-exposer/values.yaml @@ -0,0 +1,2 @@ +configuration: + K8S_NAMESPACE: nullplatform \ No newline at end of file diff --git a/endpoint-exposer/workflows/istio/create.yaml b/endpoint-exposer/workflows/istio/create.yaml new file mode 100644 index 0000000..6c633bc --- /dev/null +++ b/endpoint-exposer/workflows/istio/create.yaml @@ -0,0 +1,53 @@ +steps: + - name: "find k8s namespace" + type: script + file: "$SERVICE_PATH/scripts/istio/fetch_provider_data" + output: + - name: K8S_NAMESPACE + type: environment + - name: "build context" + type: script + file: "$SERVICE_PATH/scripts/istio/build_context" + output: + - name: SERVICE_ID + type: environment + - name: SERVICE_SLUG + type: environment + - name: ACTION_ID + type: environment + - name: ACTION_NAME + type: environment + - name: PUBLIC_DOMAIN + type: environment + - name: PRIVATE_DOMAIN + type: environment + - name: ROUTES_JSON + type: environment + - name: PUBLIC_ROUTES_JSON + type: environment + - name: PRIVATE_ROUTES_JSON + type: environment + - name: "build public httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + configuration: + VISIBILITY: "public" + output: + - name: HTTPROUTE_PUBLIC_FILE + type: file + file: "$OUTPUT_DIR/httproute-$SERVICE_ID-public.yaml" + - name: "build private httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + configuration: + VISIBILITY: "private" + output: + - name: HTTPROUTE_PRIVATE_FILE + type: file + file: "$OUTPUT_DIR/httproute-$SERVICE_ID-private.yaml" + - name: apply + type: script + file: "$SERVICE_PATH/scripts/common/apply" + configuration: + ACTION: apply + DRY_RUN: false \ No newline at end of file diff --git a/endpoint-exposer/workflows/istio/delete.yaml b/endpoint-exposer/workflows/istio/delete.yaml new file mode 100644 index 0000000..1f4d320 --- /dev/null +++ b/endpoint-exposer/workflows/istio/delete.yaml @@ -0,0 +1,6 @@ +include: + - "$SERVICE_PATH/workflows/istio/create.yaml" +steps: + - name: apply + configuration: + ACTION: delete \ No newline at end of file diff --git a/endpoint-exposer/workflows/istio/read.yaml b/endpoint-exposer/workflows/istio/read.yaml new file mode 100644 index 0000000..8de54ea --- /dev/null +++ b/endpoint-exposer/workflows/istio/read.yaml @@ -0,0 +1,30 @@ +steps: + - name: "find k8s namespace" + type: script + file: "$SERVICE_PATH/scripts/istio/fetch_provider_data" + output: + - name: K8S_NAMESPACE + type: environment + - name: "build context" + type: script + file: "$SERVICE_PATH/scripts/istio/build_context" + output: + - name: SERVICE_ID + type: environment + - name: SERVICE_SLUG + type: environment + - name: ACTION_ID + type: environment + - name: ACTION_NAME + type: environment + - name: LINK_ID + type: environment + - name: LINK_NAME + type: environment + - name: SCOPE_ID + type: environment + - name: RULE_PATH + type: environment + - name: read + type: script + file: "$SERVICE_PATH/scripts/istio/read_ingress" \ No newline at end of file diff --git a/endpoint-exposer/workflows/istio/update.yaml b/endpoint-exposer/workflows/istio/update.yaml new file mode 100644 index 0000000..85067b5 --- /dev/null +++ b/endpoint-exposer/workflows/istio/update.yaml @@ -0,0 +1,53 @@ +steps: + - name: "find k8s namespace" + type: script + file: "$SERVICE_PATH/scripts/istio/fetch_provider_data" + output: + - name: K8S_NAMESPACE + type: environment + - name: "build context" + type: script + file: "$SERVICE_PATH/scripts/istio/build_context" + output: + - name: SERVICE_ID + type: environment + - name: SERVICE_SLUG + type: environment + - name: ACTION_ID + type: environment + - name: ACTION_NAME + type: environment + - name: PUBLIC_DOMAIN + type: environment + - name: PRIVATE_DOMAIN + type: environment + - name: ROUTES_JSON + type: environment + - name: PUBLIC_ROUTES_JSON + type: environment + - name: PRIVATE_ROUTES_JSON + type: environment + - name: "build public httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + configuration: + VISIBILITY: "public" + output: + - name: HTTPROUTE_PUBLIC_FILE + type: file + file: "$OUTPUT_DIR/httproute-$SERVICE_ID-public.yaml" + - name: "build private httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + configuration: + VISIBILITY: "private" + output: + - name: HTTPROUTE_PRIVATE_FILE + type: file + file: "$OUTPUT_DIR/httproute-$SERVICE_ID-private.yaml" + - name: apply + type: script + file: "$SERVICE_PATH/scripts/common/apply" + configuration: + ACTION: apply + DRY_RUN: false