From 27baf66115df7ac7a7d2041f24833b21632202e2 Mon Sep 17 00:00:00 2001 From: Javi Date: Fri, 19 Dec 2025 16:09:10 -0300 Subject: [PATCH 1/4] feat: endpoint exposer feat: implement external dns and httproute feat: change logic to get specification id feat: add switch for different dns types feat: add switch for different dns types feat: add endpoint exposer service specs feat: add domain editable feat: add domain editable feat: implement new route schema feat: implement new route schema feat: implement new route schema feat: implement new route schema feat: implement new route schema feat: implement new route schema fix: get scope fix: get scope fix: path fix: get httproute if exists fix: get httproute if exists fix: get httproute rules feat: implement echo for link and unlink feat: implement echo for link and unlink feat: implement echo for link and unlink feat: implement echo for link and unlink feat: implement echo for link and unlink chore: change service slug feat: update flow feat: update flow feat: update flow feat: update flow feat: update flow feat: update flow feat: update flow feat: update flow feat: update flow feat: accept prefix and reg ex feat: http routes to handle private and public traffic feat: http routes to handle private and public traffic feat: http routes to handle private and public traffic feat: http routes to handle private and public traffic chore: link to public gateway chore: replace gateway chore: link to private gateway chore: remove alb scripts chore: remove alb workflows chore: remove unused scripts and dns endpoints chore: remote DEBUG logs chore: remote DEBUG logs chore: DEBUG logs feat: adapt endpoint exposer to work by service action feat: adapt endpoint exposer to work by service action feat: adapt endpoint exposer to work by service action fix: retries on action creation feat: if no service instance created allow continue without errors feat: remove links feat: public and private domains feat: public and private domains feat: public and private domains fix: private httproute fix: update flow feat: new service spec for avp feat: wip authz plugins feat: basic authorization feat: basic authorization feat: basic authorization feat: unify private and public logic feat: unify private and public logic feat: stable authorization and tests feat: add pre-commit hook to run tests Add git pre-commit hook that automatically runs BATS tests before each commit when endpoint-exposer files are modified. This ensures code quality and prevents breaking changes from being committed. - Create .githooks/pre-commit hook at repo root to run tests - Add scripts/setup-hooks.sh to configure git hooks path - Update test/README.md with hook setup instructions - Hook only runs tests when endpoint-exposer files are changed Co-Authored-By: Claude Sonnet 4.5 feat: wip plugins feat: add echos and remove auth policy from workflows feat: change output dir read feat: adapt overrides path feat: adapt overrides path feat: add subfolder to avoid cli regex issue feat: add change to support plugin chore: change list of domains for PAE chore: adapt documentation and polish code --- .githooks/pre-commit | 25 ++ .gitignore | 3 + endpoint-exposer/README.md | 190 +++++++++ .../deployment/sync_exposer | 172 ++++++++ .../deployment/workflows/blue_green.yaml | 5 + .../deployment/workflows/delete.yaml | 5 + .../deployment/workflows/finalize.yaml | 5 + .../deployment/workflows/initial.yaml | 5 + .../deployment/workflows/rollback.yaml | 5 + .../deployment/workflows/switch_traffic.yaml | 5 + .../container-scope-override/values.yaml | 3 + endpoint-exposer/entrypoint/entrypoint | 57 +++ endpoint-exposer/entrypoint/link | 34 ++ endpoint-exposer/entrypoint/service | 39 ++ endpoint-exposer/github/setup-hooks.sh | 11 + endpoint-exposer/scripts/common/apply | 90 ++++ endpoint-exposer/scripts/istio/build_context | 43 ++ .../scripts/istio/build_httproute | 108 +++++ endpoint-exposer/scripts/istio/build_ingress | 25 ++ .../scripts/istio/build_ingress_with_rule | 384 ++++++++++++++++++ endpoint-exposer/scripts/istio/build_rule | 179 ++++++++ endpoint-exposer/scripts/istio/config | 10 + .../scripts/istio/fetch_provider_data | 17 + endpoint-exposer/scripts/istio/process_routes | 113 ++++++ endpoint-exposer/scripts/istio/read_ingress | 5 + .../scripts/istio/update_httproute_hostname | 34 ++ endpoint-exposer/specs/actions/read.json.tpl | 25 ++ .../specs/notification-channel.json.tpl | 34 ++ endpoint-exposer/specs/service-spec.json.tpl | 234 +++++++++++ .../templates/istio/httproute.yaml.tpl | 26 ++ endpoint-exposer/test/.gitignore | 11 + endpoint-exposer/test/CONTRIBUTING.md | 302 ++++++++++++++ endpoint-exposer/test/README.md | 103 +++++ .../test/fixtures/authorization-disabled.json | 83 ++++ .../test/fixtures/no-public-routes.json | 71 ++++ .../fixtures/public-and-private-routes.json | 95 +++++ .../test/fixtures/simple-public-routes.json | 83 ++++ endpoint-exposer/test/helpers.bash | 298 ++++++++++++++ endpoint-exposer/test/run-tests.sh | 83 ++++ endpoint-exposer/test/test_apply_cleanup.bats | 139 +++++++ endpoint-exposer/test/test_build_context.bats | 98 +++++ .../test/test_build_httproute.bats | 179 ++++++++ endpoint-exposer/test/test_integration.bats | 229 +++++++++++ endpoint-exposer/values.yaml | 2 + endpoint-exposer/workflows/istio/create.yaml | 53 +++ endpoint-exposer/workflows/istio/delete.yaml | 6 + endpoint-exposer/workflows/istio/read.yaml | 30 ++ endpoint-exposer/workflows/istio/update.yaml | 7 + 48 files changed, 3763 insertions(+) create mode 100755 .githooks/pre-commit create mode 100644 endpoint-exposer/README.md create mode 100755 endpoint-exposer/container-scope-override/deployment/sync_exposer create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/blue_green.yaml create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/delete.yaml create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/finalize.yaml create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/initial.yaml create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/rollback.yaml create mode 100644 endpoint-exposer/container-scope-override/deployment/workflows/switch_traffic.yaml create mode 100644 endpoint-exposer/container-scope-override/values.yaml create mode 100755 endpoint-exposer/entrypoint/entrypoint create mode 100755 endpoint-exposer/entrypoint/link create mode 100755 endpoint-exposer/entrypoint/service create mode 100755 endpoint-exposer/github/setup-hooks.sh create mode 100644 endpoint-exposer/scripts/common/apply create mode 100644 endpoint-exposer/scripts/istio/build_context create mode 100755 endpoint-exposer/scripts/istio/build_httproute create mode 100644 endpoint-exposer/scripts/istio/build_ingress create mode 100755 endpoint-exposer/scripts/istio/build_ingress_with_rule create mode 100644 endpoint-exposer/scripts/istio/build_rule create mode 100755 endpoint-exposer/scripts/istio/config create mode 100755 endpoint-exposer/scripts/istio/fetch_provider_data create mode 100755 endpoint-exposer/scripts/istio/process_routes create mode 100644 endpoint-exposer/scripts/istio/read_ingress create mode 100755 endpoint-exposer/scripts/istio/update_httproute_hostname create mode 100644 endpoint-exposer/specs/actions/read.json.tpl create mode 100644 endpoint-exposer/specs/notification-channel.json.tpl create mode 100644 endpoint-exposer/specs/service-spec.json.tpl create mode 100644 endpoint-exposer/templates/istio/httproute.yaml.tpl create mode 100644 endpoint-exposer/test/.gitignore create mode 100644 endpoint-exposer/test/CONTRIBUTING.md create mode 100644 endpoint-exposer/test/README.md create mode 100644 endpoint-exposer/test/fixtures/authorization-disabled.json create mode 100644 endpoint-exposer/test/fixtures/no-public-routes.json create mode 100644 endpoint-exposer/test/fixtures/public-and-private-routes.json create mode 100644 endpoint-exposer/test/fixtures/simple-public-routes.json create mode 100644 endpoint-exposer/test/helpers.bash create mode 100755 endpoint-exposer/test/run-tests.sh create mode 100644 endpoint-exposer/test/test_apply_cleanup.bats create mode 100644 endpoint-exposer/test/test_build_context.bats create mode 100644 endpoint-exposer/test/test_build_httproute.bats create mode 100644 endpoint-exposer/test/test_integration.bats create mode 100644 endpoint-exposer/values.yaml create mode 100644 endpoint-exposer/workflows/istio/create.yaml create mode 100644 endpoint-exposer/workflows/istio/delete.yaml create mode 100644 endpoint-exposer/workflows/istio/read.yaml create mode 100644 endpoint-exposer/workflows/istio/update.yaml 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..83de871 --- /dev/null +++ b/endpoint-exposer/README.md @@ -0,0 +1,190 @@ +# Endpoint Exposer Service + +## Overview + +The **endpoint-exposer** service is a infrastructure component of Nnullplatform 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 HttpRoutes. + +## 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) + +### 4. Scope-Based Access Control +- Map endpoints to specific nullplatform scopes + +## Key Features + +### Route Management +```yaml +routes: + - method: GET + path: /api/users + scope: user-management + visible_on: public +``` + +- **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 + +### Domain Separation + +**Public Domain:** +- Endpoints accessible from the internet +- Typically for public APIs +- Connected to `gateway-public` gateway + +**Private Domain:** +- Internal organization endpoints +- Requires private network access +- Connected to `gateway-private` gateway + +## 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 + +## File Structure + +``` +/endpoint-exposer +├── configure # Service configuration script +├── entrypoint/ # Entry points for actions +│ ├── service-action # Service action handler +├── specs/ # Service specifications +│ └── service-specification.json +├── workflows/istio/ # Workflow definitions +│ └── service-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 +├── test/ # BATS test suite +└── container-scope-override/ # Custom deployment support for override scope agent +``` + +## 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`) + +### Route Configuration Example + +```json +{ + "routes": [ + { + "method": "GET", + "path": "/api/v1/resource/{id}", + "scope": "resource-read", + "visible_on": "public", + }, + { + "method": "POST", + "path": "/api/v1/resource", + "scope": "resource-write", + "visible_on": "private", + } + ], + "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 +``` \ No newline at end of file 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/github/setup-hooks.sh b/endpoint-exposer/github/setup-hooks.sh new file mode 100755 index 0000000..2b9c89c --- /dev/null +++ b/endpoint-exposer/github/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/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/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_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/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..a7675fa --- /dev/null +++ b/endpoint-exposer/specs/service-spec.json.tpl @@ -0,0 +1,234 @@ +{ + "assignable_to": "dimension", + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "publicDomain" + ], + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, + "elements": [ + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern (e.g., `/api/v1/users`) |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } + ] + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "routes": { + "type": "array", + "title": "Routes", + "items": { + "type": "object", + "required": [ + "method", + "path", + "scope", + "visibility" + ], + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug]" + } + }, + "groups": { + "type": "array", + "title": "Authorized Groups", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "Role_one", + "Role_two" + ] + } + }, + "method": { + "type": "string", + "title": "Verb", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ] + }, + "visibility": { + "type": "string", + "title": "Visibility", + "default": "public", + "enum": [ + "public", + "private" + ] + } + } + } + }, + "publicDomain": { + "type": "string", + "title": "Public Domain", + "editableOn": [ + "create", + "update" + ], + "enum": [ + "poc.domain.io" + ] + }, + "privateDomain": { + "type": "string", + "title": "Private Domain", + "editableOn": [ + "create", + "update" + ], + "enum": [ + "poc.domain.io" + ] + } + } + }, + "values": {} + }, + "dimensions": {}, + "scopes": {}, + "name": "Endpoint exposer", + "selectors": { + "category": "any", + "imported": false, + "provider": "any", + "sub_category": "any" + }, + "slug": "endpoint-exposer", + "type": "dependency", + "use_default_actions": true, + "available_actions": [ + "read" + ], + "available_links": [ + ], + "visible_to": [ + "{{ env.Getenv "NRN" }}" + ] +} 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/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..9a6f977 --- /dev/null +++ b/endpoint-exposer/workflows/istio/update.yaml @@ -0,0 +1,7 @@ +include: + - "$SERVICE_PATH/workflows/istio/create.yaml" +steps: + - name: apply + configuration: + ACTION: apply + DRY_RUN: false From 4cc43e8f11bad5c8b16f4beb0e4d165a531a68f1 Mon Sep 17 00:00:00 2001 From: Javi Date: Tue, 27 Jan 2026 09:10:43 -0300 Subject: [PATCH 2/4] chore: adapt documentation of the service spec --- endpoint-exposer/specs/service-spec.json.tpl | 1403 +++++++++++++++--- 1 file changed, 1181 insertions(+), 222 deletions(-) diff --git a/endpoint-exposer/specs/service-spec.json.tpl b/endpoint-exposer/specs/service-spec.json.tpl index a7675fa..1f57fac 100644 --- a/endpoint-exposer/specs/service-spec.json.tpl +++ b/endpoint-exposer/specs/service-spec.json.tpl @@ -1,234 +1,1193 @@ { - "assignable_to": "dimension", - "attributes": { - "schema": { - "type": "object", - "$schema": "http://json-schema.org/draft-07/schema#", - "required": [ - "publicDomain" - ], - "uiSchema": { - "type": "VerticalLayout", - "elements": [ - { - "type": "Categorization", - "options": { - "collapsable": { - "label": "Documentation", - "collapsed": true - } - }, - "elements": [ - { - "type": "Category", - "label": "Domains", - "elements": [ - { - "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", - "type": "Label", - "options": { - "format": "markdown" - } - } - ] - }, - { - "type": "Category", - "label": "Routes", - "elements": [ - { - "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern (e.g., `/api/v1/users`) |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |", - "type": "Label", - "options": { - "format": "markdown" - } - } - ] - }, - { - "type": "Category", - "label": "Examples", + "name": "Endpoint Exposer", + "slug": "endpoint-exposer", + "type": "dependency", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "dimensions": {}, + "scopes": {}, + "assignable_to": "any", + "use_default_actions": true, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "publicDomain" + ], + "uiSchema": { + "type": "VerticalLayout", "elements": [ - { - "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", - "type": "Label", - "options": { - "format": "markdown" - } - } - ] - } - ] - }, - { - "type": "Group", - "label": "Domains", - "elements": [ - { - "type": "Control", - "scope": "#/properties/publicDomain" - }, - { - "type": "Control", - "scope": "#/properties/privateDomain" - } - ] - }, - { - "type": "Group", - "label": "Routes", - "elements": [ - { - "type": "Control", - "scope": "#/properties/routes", - "options": { - "detail": { - "type": "VerticalLayout", - "elements": [ - { - "type": "Control", - "label": "Verb", - "scope": "#/properties/method" - }, - { - "type": "HorizontalLayout", + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, "elements": [ - { - "type": "Control", - "label": "Path", - "scope": "#/properties/path" - }, - { - "type": "Control", - "label": "Scope", - "scope": "#/properties/scope" - }, - { - "type": "Control", - "label": "Visibility", - "scope": "#/properties/visibility" - } + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern. See *Path Types* below |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |\n\n### Path Types\n| Type | Example | Description |\n|------|---------|-------------|\n| **Exact** | `/api/users` | Matches the exact path only |\n| **Parameterized** | `/api/users/{id}` | Matches path with dynamic segments |\n| **Wildcard** | `/api/users/*` | Matches any path starting with the prefix |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } ] - }, - { + }, + { "type": "Control", - "label": "Groups", - "scope": "#/properties/groups" - } - ] - }, - "showSortButtons": true - } - } - ] - } - ] - }, - "properties": { - "routes": { - "type": "array", - "title": "Routes", - "items": { - "type": "object", - "required": [ - "method", - "path", - "scope", - "visibility" - ], + "scope": "#/properties/environment" + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, "properties": { - "path": { - "type": "string", - "title": "Path" - }, - "scope": { - "type": "string", - "title": "Scope", - "additionalKeywords": { - "enum": "[.scopes[]?.slug]" - } - }, - "groups": { - "type": "array", - "title": "Authorized Groups", - "uniqueItems": true, - "items": { - "type": "string", - "enum": [ - "Role_one", - "Role_two" - ] + "routes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "method", + "path", + "scope", + "visibility", + "environment" + ], + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + }, + "groups": { + "type": "array", + "items": { + "enum": [ + "AWS_PlataformaUpstream_Gestor_Desa", + "AWS_PlataformaUpstream_Programador_Desa", + "AWS_PlataformaUpstream_Pulling_Desa", + "AWS_PlataformaUpstream_Workover_Desa", + "AWS_PlataformaUpstream_Visita_Desa", + "AWS_PlataformaUpstream_Administrador_Desa" + ], + "type": "string" + }, + "title": "Authorized Groups", + "uniqueItems": true + }, + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "type": "string", + "title": "Verb" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "title": "Visibility", + "default": "public" + } + } + }, + "title": "Routes" + }, + "environment": { + "type": "string", + "title": "Environment", + "additionalKeywords": { + "enum": "[.scopes[]?.dimensions?.environment] | unique | if length == 0 then [\"No environments available\"] else . end" + } + }, + "publicDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Public Domain", + "editableOn": [ + "create", + "update" + ] + }, + "privateDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Private Domain", + "editableOn": [ + "create", + "update" + ] } - }, - "method": { - "type": "string", - "title": "Verb", - "enum": [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "HEAD", - "OPTIONS" - ] - }, - "visibility": { - "type": "string", - "title": "Visibility", - "default": "public", - "enum": [ - "public", - "private" - ] - } } - } }, - "publicDomain": { - "type": "string", - "title": "Public Domain", - "editableOn": [ - "create", - "update" - ], - "enum": [ - "poc.domain.io" - ] + "values": {} + }, + "selectors": { + "category": "Networking", + "imported": false, + "provider": "K8S", + "sub_category": "HTTP Routing" + }, + "action_specifications": [ + { + "name": "Read", + "slug": "read", + "type": "custom", + "parameters": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "required": [], + "properties": {} + }, + "values": {} + }, + "icon": "", + "annotations": {}, + "enabled_when": "" + }, + { + "name": "delete Endpoint Exposer", + "slug": "delete-endpoint-exposer", + "type": "delete", + "parameters": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, + "elements": [ + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern. See *Path Types* below |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |\n\n### Path Types\n| Type | Example | Description |\n|------|---------|-------------|\n| **Exact** | `/api/users` | Matches the exact path only |\n| **Parameterized** | `/api/users/{id}` | Matches path with dynamic segments |\n| **Wildcard** | `/api/users/*` | Matches any path starting with the prefix |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } + ] + }, + { + "type": "Control", + "scope": "#/properties/environment" + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "routes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + }, + "groups": { + "type": "array", + "items": { + "enum": [ + "AWS_PlataformaUpstream_Gestor_Desa", + "AWS_PlataformaUpstream_Programador_Desa", + "AWS_PlataformaUpstream_Pulling_Desa", + "AWS_PlataformaUpstream_Workover_Desa", + "AWS_PlataformaUpstream_Visita_Desa", + "AWS_PlataformaUpstream_Administrador_Desa" + ], + "type": "string" + }, + "title": "Authorized Groups", + "uniqueItems": true + }, + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "type": "string", + "title": "Verb" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "title": "Visibility", + "default": "public" + } + } + }, + "title": "Routes", + "target": "routes" + }, + "environment": { + "type": "string", + "title": "Environment", + "target": "environment", + "additionalKeywords": { + "enum": "[.scopes[]?.dimensions?.environment] | unique | if length == 0 then [\"No environments available\"] else . end" + } + }, + "publicDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Public Domain", + "target": "publicDomain", + "editableOn": [ + "create", + "update" + ] + }, + "privateDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Private Domain", + "target": "privateDomain", + "editableOn": [ + "create", + "update" + ] + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "publicDomain" + ], + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, + "elements": [ + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern. See *Path Types* below |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |\n\n### Path Types\n| Type | Example | Description |\n|------|---------|-------------|\n| **Exact** | `/api/users` | Matches the exact path only |\n| **Parameterized** | `/api/users/{id}` | Matches path with dynamic segments |\n| **Wildcard** | `/api/users/*` | Matches any path starting with the prefix |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } + ] + }, + { + "type": "Control", + "scope": "#/properties/environment" + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "routes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "method", + "path", + "scope", + "visibility", + "environment" + ], + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + }, + "groups": { + "type": "array", + "items": { + "enum": [ + "AWS_PlataformaUpstream_Gestor_Desa", + "AWS_PlataformaUpstream_Programador_Desa", + "AWS_PlataformaUpstream_Pulling_Desa", + "AWS_PlataformaUpstream_Workover_Desa", + "AWS_PlataformaUpstream_Visita_Desa", + "AWS_PlataformaUpstream_Administrador_Desa" + ], + "type": "string" + }, + "title": "Authorized Groups", + "uniqueItems": true + }, + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "type": "string", + "title": "Verb" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "title": "Visibility", + "default": "public" + } + } + }, + "title": "Routes", + "target": "routes" + }, + "environment": { + "type": "string", + "title": "Environment", + "target": "environment", + "additionalKeywords": { + "enum": "[.scopes[]?.dimensions?.environment] | unique | if length == 0 then [\"No environments available\"] else . end" + } + }, + "publicDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Public Domain", + "target": "publicDomain", + "editableOn": [ + "create", + "update" + ] + }, + "privateDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Private Domain", + "target": "privateDomain", + "editableOn": [ + "create", + "update" + ] + } + } + }, + "values": {} + }, + "icon": "", + "annotations": {}, + "enabled_when": null }, - "privateDomain": { - "type": "string", - "title": "Private Domain", - "editableOn": [ - "create", - "update" - ], - "enum": [ - "poc.domain.io" - ] + { + "name": "create Endpoint Exposer", + "slug": "create-endpoint-exposer", + "type": "create", + "parameters": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "publicDomain" + ], + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, + "elements": [ + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern. See *Path Types* below |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |\n\n### Path Types\n| Type | Example | Description |\n|------|---------|-------------|\n| **Exact** | `/api/users` | Matches the exact path only |\n| **Parameterized** | `/api/users/{id}` | Matches path with dynamic segments |\n| **Wildcard** | `/api/users/*` | Matches any path starting with the prefix |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } + ] + }, + { + "type": "Control", + "scope": "#/properties/environment" + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "routes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "method", + "path", + "scope", + "visibility", + "environment" + ], + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + }, + "groups": { + "type": "array", + "items": { + "enum": [ + "AWS_PlataformaUpstream_Gestor_Desa", + "AWS_PlataformaUpstream_Programador_Desa", + "AWS_PlataformaUpstream_Pulling_Desa", + "AWS_PlataformaUpstream_Workover_Desa", + "AWS_PlataformaUpstream_Visita_Desa", + "AWS_PlataformaUpstream_Administrador_Desa" + ], + "type": "string" + }, + "title": "Authorized Groups", + "uniqueItems": true + }, + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "type": "string", + "title": "Verb" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "title": "Visibility", + "default": "public" + } + } + }, + "title": "Routes" + }, + "environment": { + "type": "string", + "title": "Environment", + "additionalKeywords": { + "enum": "[.scopes[]?.dimensions?.environment] | unique | if length == 0 then [\"No environments available\"] else . end" + } + }, + "publicDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Public Domain", + "editableOn": [ + "create", + "update" + ] + }, + "privateDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Private Domain", + "editableOn": [ + "create", + "update" + ] + } + } + }, + "values": {} + }, + "results": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [ + "publicDomain" + ], + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Categorization", + "options": { + "collapsable": { + "label": "Documentation", + "collapsed": true + } + }, + "elements": [ + { + "type": "Category", + "label": "Domains", + "elements": [ + { + "text": "### Public Domain\nBase domain for routes exposed to external traffic. Requests matching routes with `visibility: public` will be served through this domain.\n\n### Private Domain\nBase domain for routes accessible only within the internal network. Use this for service-to-service communication.", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Routes", + "elements": [ + { + "text": "### Route Configuration\nDefine how incoming requests are matched and forwarded to backend services.\n\n| Field | Description |\n|-------|-------------|\n| **Verb** | HTTP method to match (GET, POST, PUT, etc.) |\n| **Path** | URL path pattern. See *Path Types* below |\n| **Scope** | Target service that will handle the request |\n| **Visibility** | `public` (external) or `private` (internal network only) |\n| **Groups** | Security groups allowed to access this route. Leave empty for unrestricted access |\n\n### Path Types\n| Type | Example | Description |\n|------|---------|-------------|\n| **Exact** | `/api/users` | Matches the exact path only |\n| **Parameterized** | `/api/users/{id}` | Matches path with dynamic segments |\n| **Wildcard** | `/api/users/*` | Matches any path starting with the prefix |", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + }, + { + "type": "Category", + "label": "Examples", + "elements": [ + { + "text": "### Public API Route\n```json\n{\n \"method\": \"GET\",\n \"path\": \"/api/v1/wells\",\n \"scope\": \"wells-service\",\n \"visibility\": \"public\",\n \"groups\": []\n}\n```\n\n### Protected Internal Route\n```json\n{\n \"method\": \"POST\",\n \"path\": \"/internal/sync\",\n \"scope\": \"sync-service\",\n \"visibility\": \"private\",\n \"groups\": [\"AWS_PlataformaUpstream_Administrador_Desa\"]\n}\n```", + "type": "Label", + "options": { + "format": "markdown" + } + } + ] + } + ] + }, + { + "type": "Control", + "scope": "#/properties/environment" + }, + { + "type": "Group", + "label": "Domains", + "elements": [ + { + "type": "Control", + "scope": "#/properties/publicDomain" + }, + { + "type": "Control", + "scope": "#/properties/privateDomain" + } + ] + }, + { + "type": "Group", + "label": "Routes", + "elements": [ + { + "type": "Control", + "scope": "#/properties/routes", + "options": { + "detail": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Verb", + "scope": "#/properties/method" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path", + "scope": "#/properties/path" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + }, + { + "type": "Control", + "label": "Visibility", + "scope": "#/properties/visibility" + } + ] + }, + { + "type": "Control", + "label": "Groups", + "scope": "#/properties/groups" + } + ] + }, + "showSortButtons": true + } + } + ] + } + ] + }, + "properties": { + "routes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "method", + "path", + "scope", + "visibility", + "environment" + ], + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "scope": { + "type": "string", + "title": "Scope", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + }, + "groups": { + "type": "array", + "items": { + "enum": [ + "AWS_PlataformaUpstream_Gestor_Desa", + "AWS_PlataformaUpstream_Programador_Desa", + "AWS_PlataformaUpstream_Pulling_Desa", + "AWS_PlataformaUpstream_Workover_Desa", + "AWS_PlataformaUpstream_Visita_Desa", + "AWS_PlataformaUpstream_Administrador_Desa" + ], + "type": "string" + }, + "title": "Authorized Groups", + "uniqueItems": true + }, + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ], + "type": "string", + "title": "Verb" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "title": "Visibility", + "default": "public" + } + } + }, + "title": "Routes" + }, + "environment": { + "type": "string", + "title": "Environment", + "additionalKeywords": { + "enum": "[.scopes[]?.dimensions?.environment] | unique | if length == 0 then [\"No environments available\"] else . end" + } + }, + "publicDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Public Domain", + "editableOn": [ + "create", + "update" + ] + }, + "privateDomain": { + "enum": [ + "hello.idp.poc.nullapps.io" + ], + "type": "string", + "title": "Private Domain", + "editableOn": [ + "create", + "update" + ] + } + } + }, + "values": {} + }, + "icon": "", + "annotations": {}, + "enabled_when": null } - } - }, - "values": {} - }, - "dimensions": {}, - "scopes": {}, - "name": "Endpoint exposer", - "selectors": { - "category": "any", - "imported": false, - "provider": "any", - "sub_category": "any" - }, - "slug": "endpoint-exposer", - "type": "dependency", - "use_default_actions": true, - "available_actions": [ - "read" - ], - "available_links": [ - ], - "visible_to": [ - "{{ env.Getenv "NRN" }}" - ] + ] } From adaaea78cd86a2cb87d8a8f90f18a4ec846cf7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Klas?= Date: Tue, 27 Jan 2026 11:26:33 -0300 Subject: [PATCH 3/4] Exposer service name can be customized --- endpoint-exposer/specs/service-spec.json.tpl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/endpoint-exposer/specs/service-spec.json.tpl b/endpoint-exposer/specs/service-spec.json.tpl index 1f57fac..b4cab6b 100644 --- a/endpoint-exposer/specs/service-spec.json.tpl +++ b/endpoint-exposer/specs/service-spec.json.tpl @@ -1,6 +1,5 @@ { - "name": "Endpoint Exposer", - "slug": "endpoint-exposer", + "name": "{{ env.Getenv \"SERVICE_NAME\" | default \"Endpoint Exposer\" }}", "type": "dependency", "visible_to": [ "{{ env.Getenv \"NRN\" }}" From 8136a79a1a798dd834a4805d586fac3c022c58b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juli=C3=A1n=20Klas?= Date: Tue, 27 Jan 2026 11:47:03 -0300 Subject: [PATCH 4/4] Changed permission section --- endpoint-exposer/specs/service-spec.json.tpl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/endpoint-exposer/specs/service-spec.json.tpl b/endpoint-exposer/specs/service-spec.json.tpl index b4cab6b..a5c9af7 100644 --- a/endpoint-exposer/specs/service-spec.json.tpl +++ b/endpoint-exposer/specs/service-spec.json.tpl @@ -124,7 +124,7 @@ }, { "type": "Control", - "label": "Groups", + "label": "Required Endpoint Permissions", "scope": "#/properties/groups" } ] @@ -163,13 +163,13 @@ "groups": { "type": "array", "items": { - "enum": [ - "AWS_PlataformaUpstream_Gestor_Desa", - "AWS_PlataformaUpstream_Programador_Desa", - "AWS_PlataformaUpstream_Pulling_Desa", - "AWS_PlataformaUpstream_Workover_Desa", - "AWS_PlataformaUpstream_Visita_Desa", - "AWS_PlataformaUpstream_Administrador_Desa" + "oneOf": [ + { "const": "AWS_PlataformaUpstream_Gestor_Desa", "title": "Gestor Desarrollo" }, + { "const": "AWS_PlataformaUpstream_Programador_Desa", "title": "Programador Desarrollo" }, + { "const": "AWS_PlataformaUpstream_Pulling_Desa", "title": "Pulling Desarrollo" }, + { "const": "AWS_PlataformaUpstream_Workover_Desa", "title": "Workover Desarrollo" }, + { "const": "AWS_PlataformaUpstream_Visita_Desa", "title": "Visita Desarrollo" }, + { "const": "AWS_PlataformaUpstream_Administrador_Desa", "title": "Administrador Desarrollo" } ], "type": "string" },