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..83eef9e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ *.iml out gen + +# VSCode project files +.vscode/ + +# Terraform files +*.tfstate +*.tfstate.backup +.terraform/ +*.tfvars \ 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/install/installation.md b/endpoint-exposer/install/installation.md new file mode 100644 index 0000000..11cd9d9 --- /dev/null +++ b/endpoint-exposer/install/installation.md @@ -0,0 +1,83 @@ +# Endpoint Exposer — Installation Guide + +This guide walks through registering the Endpoint Exposer service in nullplatform using OpenTofu. + +## Overview + +The installation process creates: +- A **service specification** (the form developers fill in to configure HTTP routes) +- **Action specifications** (read, create, update, delete) +- A **notification channel** (connects nullplatform events to the agent) + +## Prerequisites + +See [prerequisites.md](./prerequisites.md) for agent setup, Kubernetes permissions, and required repositories. + +## Steps + +### 1. Clone required repositories + +```bash +git clone https://github.com/nullplatform/services /root/.np/nullplatform/services +git clone https://github.com/nullplatform/tofu-modules /root/.np/nullplatform/tofu-modules +``` + +> The `repo_path` variable defaults to `/root/.np/nullplatform/services/endpoint-exposer`. Adjust if you clone elsewhere. + +### 2. Configure variables + +```bash +cd install/tofu +cp terraform.tfvars.example terraform.tfvars +``` + +Edit `terraform.tfvars` with your values: + +| Variable | Required | Description | +|---|---|---| +| `nrn` | ✅ | Nullplatform Resource Name (`organization:account`) | +| `np_api_key` | ✅ | Nullplatform API key | +| `tags_selectors` | ✅ | Tags to select the agent (e.g. `{ environment = "production" }`) | +| `github_token` | ✅ | GitHub token with `contents: read` on `nullplatform/services` | +| `git_branch` | — | Branch to fetch specs from (default: `main`) | +| `repo_path` | — | Path where endpoint-exposer is located on the agent | +| `overrides_enabled` | — | Set `true` to enable config overrides | +| `overrides_repo_path` | — | Full path to the overrides directory on the agent | + +### 3. Initialize OpenTofu + +```bash +tofu init \ + -backend-config="bucket=" \ + -backend-config="region=" +``` + +### 4. Plan and apply + +```bash +tofu plan +tofu apply +``` + +## Overrides + +If the account requires local configuration overrides (e.g. from a networking repo), enable the override flag so the agent appends `--overrides-path` to its command: + +```hcl +overrides_enabled = true +overrides_repo_path = "/root/.np/nullplatform/scopes-networking/endpoint-exposer" +``` + +This results in the agent running: +``` +/root/.np/nullplatform/services/endpoint-exposer/entrypoint \ + --service-path=/root/.np/nullplatform/services/endpoint-exposer \ + --overrides-path=/root/.np/nullplatform/scopes-networking/endpoint-exposer +``` + +## Updating specs + +To push spec changes after editing templates in `specs/`: + +1. Merge your branch to `main` (or update `git_branch` in tfvars) +2. Run `tofu apply` — the module fetches templates from GitHub on each run diff --git a/endpoint-exposer/install/prerequisites.md b/endpoint-exposer/install/prerequisites.md new file mode 100644 index 0000000..3416347 --- /dev/null +++ b/endpoint-exposer/install/prerequisites.md @@ -0,0 +1,66 @@ +# Endpoint Exposer — Agent Prerequisites + +## Repositories + +The agent pod must have the following repository cloned at the expected path: + +| Repository | Default path on agent | +|---|---| +| [nullplatform/services](https://github.com/nullplatform/services) | `/root/.np/nullplatform/services/endpoint-exposer` | + +Override the default path via the `repo_path` variable in `terraform.tfvars`. + +## Required tooling on the agent pod + +- `np` CLI (nullplatform CLI) +- `kubectl` +- `jq` +- `gomplate` + +## Kubernetes Access + +The agent runs in a Kubernetes pod and must have `kubectl` access to the cluster. The pod's service account must have RBAC permissions to manage HTTPRoute resources in the target namespace. + +--- + +### Required ClusterRole / Role + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: endpoint-exposer-agent + namespace: nullplatform # or the namespace defined in values.yaml K8S_NAMESPACE +rules: + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get", "list", "create", "update", "patch", "delete"] +``` + +--- + +### Gateway API CRDs + +The Kubernetes cluster must have the [Gateway API CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed: + +```bash +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.0.0/standard-install.yaml +``` + +Verify the CRDs are available: + +```bash +kubectl get crd httproutes.gateway.networking.k8s.io +``` + +--- + +### Gateway resource + +A `Gateway` resource must exist in the cluster for both public and private traffic. The gateway names and namespaces are configured in `values.yaml` (or via the scope's configuration). + +## GitHub Token + +A GitHub personal access token with `contents: read` permission on the `nullplatform/services` repository is required to fetch spec templates during `tofu apply`. + +Set it in `terraform.tfvars` as `github_token`. diff --git a/endpoint-exposer/install/specs/actions/read.json.tpl b/endpoint-exposer/install/specs/actions/read.json.tpl new file mode 100644 index 0000000..f6a4c52 --- /dev/null +++ b/endpoint-exposer/install/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/install/specs/notification-channel.json.tpl b/endpoint-exposer/install/specs/notification-channel.json.tpl new file mode 100644 index 0000000..ee3c798 --- /dev/null +++ b/endpoint-exposer/install/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/install/specs/service-spec.json.tpl b/endpoint-exposer/install/specs/service-spec.json.tpl new file mode 100644 index 0000000..a5c9af7 --- /dev/null +++ b/endpoint-exposer/install/specs/service-spec.json.tpl @@ -0,0 +1,1192 @@ +{ + "name": "{{ env.Getenv \"SERVICE_NAME\" | default \"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": [ + { + "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": "Required Endpoint Permissions", + "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": { + "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" + }, + "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": {} + }, + "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 + }, + { + "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 + } + ] +} diff --git a/endpoint-exposer/install/tofu/.terraform.lock.hcl b/endpoint-exposer/install/tofu/.terraform.lock.hcl new file mode 100644 index 0000000..df1275e --- /dev/null +++ b/endpoint-exposer/install/tofu/.terraform.lock.hcl @@ -0,0 +1,99 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/external" { + version = "2.3.5" + hashes = [ + "h1:VsIY+hWGvWHaGvGTSKZslY13lPeAtSTxfZRPbpLMMhs=", + "zh:1fb9aca1f068374a09d438dba84c9d8ba5915d24934a72b6ef66ef6818329151", + "zh:3eab30e4fcc76369deffb185b4d225999fc82d2eaaa6484d3b3164a4ed0f7c49", + "zh:4f8b7a4832a68080f0bf4f155b56a691832d8a91ce8096dac0f13a90081abc50", + "zh:5ff1935612db62e48e4fe6cfb83dfac401b506a5b7b38342217616fbcab70ce0", + "zh:993192234d327ec86726041eb6d1efb001e41f32e4518ad8b9b162130b65ee9a", + "zh:ce445e68282a2c4b2d1f994a2730406df4ea47914c0932fb4a7eb040a7ec7061", + "zh:e305e17216840c54194141fb852839c2cedd6b41abd70cf8d606d6e88ed40e64", + "zh:edba65fb241d663c09aa2cbf75026c840e963d5195f27000f216829e49811437", + "zh:f306cc6f6ec9beaf75bdcefaadb7b77af320b1f9b56d8f50df5ebd2189a93148", + "zh:fb2ff9e1f86796fda87e1f122d40568912a904da51d477461b850d81a0105f3d", + ] +} + +provider "registry.opentofu.org/hashicorp/http" { + version = "3.5.0" + hashes = [ + "h1:eClUBisXme48lqiUl3U2+H2a2mzDawS9biqfkd9synw=", + "zh:0a2b33494eec6a91a183629cf217e073be063624c5d3f70870456ddb478308e9", + "zh:180f40124fa01b98b3d2f79128646b151818e09d6a1a9ca08e0b032a0b1e9cb1", + "zh:3e29e1de149dc10bf78620526c7cb8c62cd76087f5630dfaba0e93cda1f3aa7b", + "zh:4420950200cf86042ec940d0e2c9b7c89966bf556bf8038ba36217eae663bca5", + "zh:5d1f7d02109b2e2dca7ec626e5563ee765583792d0fd64081286f16f9433bd0d", + "zh:8500b138d338b1994c4206aa577b5c44e1d7260825babcf43245a7075bfa52a5", + "zh:b42165a6c4cfb22825938272d12b676e4a6946ac4e750f85df870c947685df2d", + "zh:b919bf3ee8e3b01051a0da3433b443a925e272893d3724ee8fc0f666ec7012c9", + "zh:d13b81ea6755cae785b3e11634936cdff2dc1ec009dc9610d8e3c7eb32f42e69", + "zh:f1c9d2eb1a6b618ae77ad86649679241bd8d6aacec06d0a68d86f748687f4eb3", + ] +} + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.2.4" + hashes = [ + "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", + "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", + "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", + "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", + "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", + "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", + "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", + "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", + "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", + "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", + "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", + ] +} + +provider "registry.opentofu.org/integrations/github" { + version = "6.11.1" + constraints = "~> 6.0" + hashes = [ + "h1:nanzeesukYMHAFrSaq7rnWx7iRDHMpme5KzQI3m/ZZo=", + "zh:0a5262b033a30d8a77ebf844dc3afd7e726d5f53ac1c9d4072cf9157820d1f73", + "zh:437236181326f92d1a7c56985b2ac3223efd73f75c528323b90f4b7d1b781090", + "zh:49a12c14d1d3a143a124ba81f15fbf18714af90752c993698c76e84fa85da004", + "zh:61eaf17b559a26ca14deb597375a6678d054d739e8b81c586ef1d0391c307916", + "zh:7f3f1e2c36f4787ca9a5aeb5317b8c3f6cc652368d1f8f00fb80f404109d4db1", + "zh:85a232f2e96e5adafa2676f38a96b8cc074e96f715caf6ee1d169431174897d2", + "zh:979d005af2a9003d887413195948c899e9f5aba4a79cce1eed40f3ba50301af1", + "zh:b8c8cd3254504d2184d2b2233ad41b5fdfda91a36fc864926cbc5c7eee1bfea3", + "zh:d00959e62930fb75d2b97c1d66ab0143120541d5a1b3f26d3551f24cb0361f83", + "zh:d0b544eed171c7563387fe87f0af3d238bb3804798159b4d0453c97927237daf", + "zh:ecfa19b1219aa55b1ece98d8cff5b1494dc0387329c8ae0d8f762ec3871fb75d", + "zh:f2c99825f38c92ac599ad36b9d093ea0c0d790fd0c02e861789e14735a605f86", + "zh:f33b5abe14ad5fb9978da5dbd3bc6989f69766150d4b30ed283a2c281871eda3", + "zh:f6c2fe9dd958c554170dc0c35ca41b60fcc6253304cde0b9941c5c872b18ac54", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + ] +} + +provider "registry.opentofu.org/nullplatform/nullplatform" { + version = "0.0.83" + constraints = ">= 0.0.67, < 0.1.0" + hashes = [ + "h1:OLoy7qBT2p/P9pxLL1QBT/NND3A/ik6JordAv8loU8E=", + "zh:0e9c83e413ea5a8b960520805d2bcda2b73fef7532dc9f95f84814b7fb8a0c91", + "zh:27a46f7bfdcd349f45ff38d38bbda9f6a40eb3d4da8e62711cd1e4317f236abb", + "zh:363b17e7386878e757fde5d696145a77036821bea9435d158f18a7b32e7660b8", + "zh:3750f479550b9451e6af7e3e8748d1df9b4f0eb7b1abf623bcc09e8f07efcb33", + "zh:46be8590448ea119024ddccb24e754cf248b216b4acb132a60ce4a8ba2fdc3bf", + "zh:4efa21083c3f18e46c26767b425fd9eadb71fa332fd2d8b2b78d30612038f8a7", + "zh:6275212e7934e65cbc79b45a70831815d13dc45ee79d055417be66a33643116a", + "zh:92c43a2ed70ba8f065a2d971e6ec8ec7b21733af487afec03f22e8d982275c53", + "zh:c2b45662dd0193d946763513f85957451db8a7117e5d83158e95dfd229eceeed", + "zh:c3e99e0c20d2a8e8aa7a303b5ecfb27cb1d5489ca0748eeb7fd33c09e082641f", + "zh:dce5ca843181ca75f6d1473c2a5022e58505f99aa2c0ee1c184589d1a4c765d7", + "zh:e82a2868ba85f8807bf920ff010d8b6a7eb6eb4f364e53cedae087c348b8689f", + "zh:f3d71d9536d9e866c95d7ae7f2b3e5a8052faa60750122d51910ef7187d40a45", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:ffad065fb6df9037ae8995d50a1857b6a5e761a000ff879320b109fbe79fa529", + ] +} diff --git a/endpoint-exposer/install/tofu/backend.tf b/endpoint-exposer/install/tofu/backend.tf new file mode 100644 index 0000000..e0c5242 --- /dev/null +++ b/endpoint-exposer/install/tofu/backend.tf @@ -0,0 +1,5 @@ +terraform { + backend "s3" { + key = "endpoint-exposer/install/terraform.tfstate" + } +} diff --git a/endpoint-exposer/install/tofu/main.tf b/endpoint-exposer/install/tofu/main.tf new file mode 100644 index 0000000..9443186 --- /dev/null +++ b/endpoint-exposer/install/tofu/main.tf @@ -0,0 +1,49 @@ +################################################################################ +# Service Definition +# Registers the service specification and action specs in nullplatform. +################################################################################ + +module "service_definition" { + source = "../../../../tofu-modules/nullplatform/service_definition" + + nrn = var.nrn + np_api_key = var.np_api_key + + # Spec templates are fetched from the nullplatform/services GitHub repository + git_repo = var.git_repo + git_ref = var.git_branch + git_service_path = var.git_service_path + use_tpl_files = true + + git_password = var.github_token + + service_name = var.service_name + service_description = var.service_description +} + +################################################################################ +# Service Definition Agent Association +# Creates the notification channel that connects nullplatform events to the agent. +################################################################################ + +module "service_definition_agent_association" { + source = "../../../../tofu-modules/nullplatform/service_definition_agent_association" + + nrn = var.nrn + api_key = var.np_api_key + + service_specification_id = module.service_definition.service_specification_id + service_specification_slug = module.service_definition.service_specification_slug + + tags_selectors = var.tags_selectors + + agent_command = { + type = "exec" + data = { + cmdline = "${var.repo_path}/entrypoint" + } + } + + service_path = var.repo_path + workflow_override_path = var.overrides_enabled ? var.overrides_repo_path : null +} diff --git a/endpoint-exposer/install/tofu/provider.tf b/endpoint-exposer/install/tofu/provider.tf new file mode 100644 index 0000000..2fece5b --- /dev/null +++ b/endpoint-exposer/install/tofu/provider.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.67, < 0.1.0" + } + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} + +provider "nullplatform" { + api_key = var.np_api_key +} + +provider "github" { + token = var.github_token +} diff --git a/endpoint-exposer/install/tofu/terraform.tfvars.example b/endpoint-exposer/install/tofu/terraform.tfvars.example new file mode 100644 index 0000000..8d04f2e --- /dev/null +++ b/endpoint-exposer/install/tofu/terraform.tfvars.example @@ -0,0 +1,43 @@ +# Copy this file to terraform.tfvars and fill in the values. +# terraform.tfvars is gitignored — never commit secrets. + +################################################################################ +# Required +################################################################################ + +nrn = "organization=2" +np_api_key = "your-nullplatform-api-key" + +tags_selectors = { + environment = "production" +} + +github_token = "your-github-personal-access-token" + +################################################################################ +# Repository (override if using a fork or different branch) +################################################################################ + +# git_repo = "nullplatform/services" +# git_branch = "main" +# git_service_path = "endpoint-exposer/install" + +################################################################################ +# Agent +################################################################################ + +# repo_path = "/root/.np/nullplatform/services/endpoint-exposer" + +################################################################################ +# Service Definition (override to customize the display name) +################################################################################ + +# service_name = "Endpoint Exposer" +# service_description = "HTTP routing management via Kubernetes Gateway API" + +################################################################################ +# Overrides (optional — appends --overrides-path to the agent cmdline) +################################################################################ + +# overrides_enabled = true +# overrides_repo_path = "/root/.np/nullplatform/scopes-networking/endpoint-exposer" diff --git a/endpoint-exposer/install/tofu/variables.tf b/endpoint-exposer/install/tofu/variables.tf new file mode 100644 index 0000000..3c7b481 --- /dev/null +++ b/endpoint-exposer/install/tofu/variables.tf @@ -0,0 +1,90 @@ +################################################################################ +# Required +################################################################################ + +variable "nrn" { + description = "Nullplatform Resource Name (organization:account format)" + type = string +} + +variable "np_api_key" { + description = "Nullplatform API key for authentication" + type = string + sensitive = true +} + +variable "tags_selectors" { + description = "Map of tags used to select the agent that will handle this service's notification channel" + type = map(string) +} + +variable "github_token" { + description = "GitHub personal access token for fetching spec templates from nullplatform/services" + type = string + sensitive = true + default = null +} + +################################################################################ +# Repository +################################################################################ + +variable "git_repo" { + description = "GitHub repository containing spec templates (org/repo format)" + type = string + default = "nullplatform/services" +} + +variable "git_branch" { + description = "Git branch to use when fetching spec templates" + type = string + default = "main" +} + +variable "git_service_path" { + description = "Path within the repository where install/specs/ is located" + type = string + default = "endpoint-exposer/install" +} + +################################################################################ +# Agent +################################################################################ + +variable "repo_path" { + description = "Local path where the endpoint-exposer directory is located on the agent" + type = string + default = "/root/.np/nullplatform/services/endpoint-exposer" +} + +################################################################################ +# Service Definition +################################################################################ + +variable "service_name" { + description = "Display name for the service in nullplatform" + type = string + default = "Endpoint Exposer" +} + +variable "service_description" { + description = "Description of the service" + type = string + default = "HTTP routing management via Kubernetes Gateway API" +} + +################################################################################ +# Overrides +################################################################################ + +variable "overrides_enabled" { + description = "Append --overrides-path to the agent cmdline for local config overrides" + type = bool + default = false +} + +variable "overrides_repo_path" { + description = "Full path to the overrides directory on the agent" + type = string + default = null +} 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..d0b54e2 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_context @@ -0,0 +1,46 @@ +#!/bin/bash + +# Build Context: Extracts service metadata, domains, and routes from service action parameters. +# Splits routes by visibility (public/private) and prepares environment variables for workflow execution. + +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..4ecb710 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_httproute @@ -0,0 +1,111 @@ +#!/bin/bash + +# Build HTTPRoute: Creates Istio HTTPRoute resources for public or private traffic. +# Generates HTTPRoute from template, processes all routes, and handles empty resource cleanup. + +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..0c571f6 --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_ingress @@ -0,0 +1,28 @@ +#!/bin/bash + +# Build Ingress: Generates HTTPRoute resource from gomplate template. +# Legacy script for creating basic HTTPRoute configuration with service domain. + +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..b7a997c --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_ingress_with_rule @@ -0,0 +1,387 @@ +#!/bin/bash + +# Build Ingress with Rule: Updates HTTPRoute by adding or replacing rules for specific paths. +# Supports path types (Exact, PathPrefix, RegularExpression), HTTP methods, and blue/green backends. + +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..b19ab5d --- /dev/null +++ b/endpoint-exposer/scripts/istio/build_rule @@ -0,0 +1,182 @@ +#!/bin/bash + +# Build Rule: Generates routing rules from Kubernetes services for a specific scope. +# Supports single services and blue/green deployments with weighted traffic distribution. + +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..1f11985 --- /dev/null +++ b/endpoint-exposer/scripts/istio/config @@ -0,0 +1,13 @@ +#!/bin/bash + +# Configuration: Defines default gateway names and namespaces for public/private traffic routing. +# These values can be overridden by environment variables to support different cluster configurations. + +# 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..f3fcbbe --- /dev/null +++ b/endpoint-exposer/scripts/istio/fetch_provider_data @@ -0,0 +1,20 @@ +#!/bin/bash + +# Fetch Provider Data: Retrieves Kubernetes namespace from the container orchestration provider. +# Queries provider data based on service dimensions and exports K8S_NAMESPACE for downstream scripts. + +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..69aaaff --- /dev/null +++ b/endpoint-exposer/scripts/istio/process_routes @@ -0,0 +1,116 @@ +#!/bin/bash + +# Process Routes: Iterates through all configured routes and updates HTTPRoute with rules. +# Sorts routes by specificity (Exact > RegularExpression > PathPrefix) and builds rules for each scope. + +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/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