From 37c364c71080bed00ad5c7fb3b54b0ae67a99d2c Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 12:28:21 -0300 Subject: [PATCH 1/7] feat: import path-router from services monorepo Brings in the path-router service (istio Gateway API HTTPRoute routing, container-scope-override blue/green sync) from nullplatform/services, flattened to the repo root, along with the standard CI workflow set. --- .github/workflows/branch-validation.yml | 10 + .github/workflows/conventional-commit.yml | 10 + .github/workflows/release.yml | 15 + .github/workflows/shellcheck.yml | 10 + .github/workflows/trivy.yml | 14 + README.md | 230 +++++++++- .../deployment/sync_router | 172 ++++++++ .../deployment/workflows/blue_green.yaml | 5 + .../deployment/workflows/delete.yaml | 5 + .../deployment/workflows/finalize.yaml | 5 + .../deployment/workflows/initial.yaml | 5 + .../deployment/workflows/rollback.yaml | 5 + .../deployment/workflows/switch_traffic.yaml | 5 + container-scope-override/values.yaml | 2 + entrypoint/entrypoint | 66 +++ entrypoint/service | 39 ++ scripts/common/apply | 90 ++++ scripts/istio/build_context | 23 + scripts/istio/build_delete_context | 6 + scripts/istio/build_httproute | 105 +++++ scripts/istio/build_ingress_with_rule | 406 ++++++++++++++++++ scripts/istio/build_rule | 186 ++++++++ scripts/istio/check_path_conflict | 23 + scripts/istio/config | 13 + scripts/istio/delete_httproutes | 16 + scripts/istio/fetch_provider_data | 20 + specs/links/connect.json.tpl | 16 + specs/service-spec.json.tpl | 82 ++++ templates/istio/httproute.yaml.tpl | 38 ++ values.yaml | 2 + workflows/istio/create.yaml | 41 ++ workflows/istio/delete.yaml | 18 + workflows/istio/update.yaml | 44 ++ 33 files changed, 1709 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/branch-validation.yml create mode 100644 .github/workflows/conventional-commit.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/shellcheck.yml create mode 100644 .github/workflows/trivy.yml create mode 100755 container-scope-override/deployment/sync_router create mode 100644 container-scope-override/deployment/workflows/blue_green.yaml create mode 100644 container-scope-override/deployment/workflows/delete.yaml create mode 100644 container-scope-override/deployment/workflows/finalize.yaml create mode 100644 container-scope-override/deployment/workflows/initial.yaml create mode 100644 container-scope-override/deployment/workflows/rollback.yaml create mode 100644 container-scope-override/deployment/workflows/switch_traffic.yaml create mode 100644 container-scope-override/values.yaml create mode 100755 entrypoint/entrypoint create mode 100755 entrypoint/service create mode 100755 scripts/common/apply create mode 100755 scripts/istio/build_context create mode 100755 scripts/istio/build_delete_context create mode 100755 scripts/istio/build_httproute create mode 100755 scripts/istio/build_ingress_with_rule create mode 100755 scripts/istio/build_rule create mode 100755 scripts/istio/check_path_conflict create mode 100755 scripts/istio/config create mode 100755 scripts/istio/delete_httproutes create mode 100755 scripts/istio/fetch_provider_data create mode 100644 specs/links/connect.json.tpl create mode 100644 specs/service-spec.json.tpl create mode 100644 templates/istio/httproute.yaml.tpl create mode 100644 values.yaml create mode 100644 workflows/istio/create.yaml create mode 100644 workflows/istio/delete.yaml create mode 100644 workflows/istio/update.yaml diff --git a/.github/workflows/branch-validation.yml b/.github/workflows/branch-validation.yml new file mode 100644 index 0000000..6d75b77 --- /dev/null +++ b/.github/workflows/branch-validation.yml @@ -0,0 +1,10 @@ +name: branch-validation + +on: + pull_request: + branches: + - main + +jobs: + branch-validation: + uses: nullplatform/actions-nullplatform/.github/workflows/branch-validation.yml@main diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml new file mode 100644 index 0000000..92952e1 --- /dev/null +++ b/.github/workflows/conventional-commit.yml @@ -0,0 +1,10 @@ +name: conventional-commit + +on: + pull_request: + branches: + - main + +jobs: + conventional-commit: + uses: nullplatform/actions-nullplatform/.github/workflows/conventional-commit.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a65e73 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,15 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release: + uses: nullplatform/actions-nullplatform/.github/workflows/release.yml@main + secrets: inherit diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..abf7bd9 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,10 @@ +name: shellcheck + +on: + pull_request: + branches: + - main + +jobs: + shellcheck: + uses: nullplatform/actions-nullplatform/.github/workflows/shellcheck.yml@main diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 0000000..1a3c210 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,14 @@ +name: trivy + +on: + pull_request: + branches: + - main + +permissions: + contents: read + security-events: write + +jobs: + trivy: + uses: nullplatform/actions-nullplatform/.github/workflows/trivy-tofu-scan.yml@main diff --git a/README.md b/README.md index 4e481b6..f358715 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,212 @@ -

- - nullplatform - -
-
- Nullplatform "Any Technology" Template -
-

- -This is a minimalistic sample on how you can create an application on arbitrary technology. -In particular, we're spinning up an image that contains an echo server. -You can check *Echo Server* documentation [here](https://ealenn.github.io/Echo-Server/). - -## How do I modify this template to build my own application? - -1. Change the Dockerfile to run the application / binary that you are building -2. Deploy your application in nullplatform +# path-router + +A nullplatform `dependency` service that exposes an application's scope under a shared domain using **path-based routing** (Gateway API `HTTPRoute` on Istio). It lets multiple applications/scopes share one public or private domain, each mounted under its own path prefix (e.g. `/api-private`, `/billing`). + +## What It Does + +- Creates a Kubernetes Gateway API `HTTPRoute` that matches `base_domain` + `path_prefix` and forwards traffic to the target `scope`'s backend `Service`. +- Optionally strips the path prefix before forwarding (`strip_prefix`), so `/api-private/health` reaches the backend as `/health`. +- Infers which Istio Gateway to attach to (`gateway-public` or `gateway-private`) from the **target scope's own visibility** — no manual gateway selection needed. +- Detects and prevents path-prefix collisions on the same `base_domain` before creating a route. +- Hooks into the target scope's deployment lifecycle (via `container-scope-override`) to keep the `HTTPRoute` in sync during blue/green deployments, traffic switches, rollbacks, and finalization — including weighted traffic splitting between blue and green backends while a deployment is in progress. + +## Architecture + +``` +nullplatform Application + │ + │ service (base_domain, path_prefix, scope, strip_prefix) + ▼ + path-router ──────► HTTPRoute (Gateway API) + (this service) │ hostnames: [base_domain] + │ │ matches: PathPrefix(path_prefix) + │ │ parentRef: gateway-public | gateway-private + │ │ backendRefs: → target scope's Service(s) + │ + └─ container-scope-override: + hooks the target scope's own deployment workflow + (initial / blue_green / switch_traffic / rollback / finalize / delete) + to rebuild the HTTPRoute whenever the scope deploys +``` + +Unlike `rds-postgres-server` or other infra-provisioning services, path-router creates **no AWS infrastructure** — only Kubernetes `HTTPRoute` objects. + +## Attributes + +| Attribute | Type | Description | +|---|---|---| +| `base_domain` | string (enum) | Shared domain to route on. Must be pre-registered — see [Adding a New Domain](#adding-a-new-domain) below. | +| `path_prefix` | string | Path prefix to route to the target scope, e.g. `/api-private`. Must match `^/[a-zA-Z0-9_\-]+$`. | +| `scope` | string | Slug of the target scope to receive traffic. | +| `strip_prefix` | boolean | If `true` (default), the prefix is stripped before forwarding (`/api-private/health` → `/health`). | + +## Requirements + +Getting path-router working end-to-end requires infra outside this repo, in this order: + +### 1. Agent environment variables + +Set on the nullplatform agent (`extra_envs` in the agent's tofu module): + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `INGRESS_TYPE` | No | `istio` | Selects `workflows/$INGRESS_TYPE/*.yaml`. Only `istio` is implemented today. | +| `PUBLIC_GATEWAY_NAME` | No | `gateway-public` | Name of the Istio Gateway used for scopes with `visibility: public`. | +| `PRIVATE_GATEWAY_NAME` | No | `gateway-private` | Name of the Istio Gateway used for scopes with `visibility: private`. | +| `GATEWAY_NAMESPACE` | No | `gateways` | Namespace where the Gateways live. | + +These all have working defaults (`scripts/istio/config`) — you only need to set them if your cluster uses different Gateway names/namespace. + +> **`PATH_ROUTER_DOMAINS` is not currently used by the code.** An earlier iteration read a comma-separated domain list from this env var to populate the `base_domain` dropdown at spec-registration time, but the Gomplate array syntax it relied on (`{{ env.Getenv "PATH_ROUTER_DOMAINS" | strings.Split "," | conv.ToJSON }}`) broke `jsondecode()` in the tofu `service_definition` module used to register the spec. It was replaced with a hardcoded JSON array (see below). If this env var is still set in an agent's `extra_envs`, it's harmless but has no effect — remove it once confirmed dead, or wire it back in if the enum is reworked. + +### 2. Adding a new domain + +`base_domain` is a fixed enum in [`specs/service-spec.json.tpl`](specs/service-spec.json.tpl): + +```json +"base_domain": { + "type": "string", + "title": "Base Domain", + "description": "Shared domain for path-based routing.", + "enum": ["path-router.api-private.playground.nullapps.io"] +} +``` + +To support a new domain: +1. Add it to this `enum` array. +2. Re-register the service specification with nullplatform (however your environment installs `specs/*.json.tpl` — e.g. re-running the tofu `service_definition` module that renders and applies this template). +3. Complete the DNS step below **before** anyone tries to create a path-router service with that domain — otherwise the route will exist in the cluster but nothing will resolve to it. + +### 3. DNS record pointing at the load balancer + +**path-router does not create or manage DNS records.** It only creates the `HTTPRoute`; something external has to route requests for `base_domain` to the cluster's ingress load balancer, or the route is unreachable no matter how correctly it's configured. + +For every domain added to the `base_domain` enum, create a DNS record (A/ALIAS if using Route53, or CNAME otherwise) pointing at the **ALB that fronts the gateway matching the domain's intended visibility**: + +| Visibility | Gateway | Ingress (namespace `gateways`) | ALB | +|---|---|---|---| +| Public | `gateway-public` | `gateway-alb-public` | Internet-facing ALB (`k8s-nullplatform-internet-facing-*.elb.amazonaws.com`) | +| Private | `gateway-private` | `gateway-alb-private` | Internal ALB (`internal-k8s-nullplatform-internal-*.elb.amazonaws.com`) | + +Get the exact hostname for your cluster with: + +```bash +kubectl get ingress -n gateways gateway-alb-public -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +kubectl get ingress -n gateways gateway-alb-private -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' +``` + +Example Route53 alias record: + +``` +path-router.api-private.playground.nullapps.io. A (Alias) → dualstack.k8s-nullplatform-internet-facing-xxxxxxxxxx.us-east-1.elb.amazonaws.com. +``` + +Note: **which gateway/ALB to point at depends on the visibility of the scopes you intend to route to**, not on the domain name itself — path-router inspects the target scope's `visibility` at request-routing time (`scripts/istio/build_httproute`) and attaches the `HTTPRoute` to the matching Gateway automatically. If a single `base_domain` is ever used to route to scopes of mixed visibility, only one Gateway/ALB will be reachable at a time per route — keep one domain per visibility tier to avoid surprises. + +## Tofu Implementation (Registering the Service) + +path-router itself creates no AWS infrastructure, but it still needs to be **registered with nullplatform and wired to the agent** via `nullplatform/tofu-modules`. This is normally done in the account's platform-provisioning tofu project (e.g. `services-testing` / `ifr-platformups-*`), not in this repo. Three module calls are involved: + +### 1. Register the service specification + +Renders and pushes `specs/service-spec.json.tpl` (and its `available_links`) to nullplatform as a service specification: + +```hcl +module "service_definition_path_router" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1" + + nrn = var.nrn # target namespace/account NRN + repository_org = "nullplatform" + repository_name = "services-path-router" + repository_branch = "main" # or the branch you're testing + repository_token = var.repository_token + service_path = "." # root of the repo + service_name = "Path Router" + available_links = ["connect"] + available_actions = [] +} +``` + +### 2. Associate the agent with the service (service-level actions) + +Creates the notification channel so the agent's `entrypoint` gets invoked for this service's own `create`/`update`/`delete`/`link` actions: + +```hcl +module "service_definition_association_api_key" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/api_key?ref=v4.5.1" + type = "scope_notification" + nrn = var.nrn + specification_slug = "k8s" +} + +module "service_definition_channel_association_path_router" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1" + nrn = var.nrn + repository_service_spec_repo = "nullplatform/services-path-router" + service_path = "." + service_specification_slug = module.service_definition_path_router.service_specification_slug + api_key = module.service_definition_association_api_key.api_key + tags_selectors = var.tags_selectors_path_router # e.g. { owner = "api-private" } + agent_arguments = ["--service-path=/root/.np/nullplatform/services-path-router"] +} +``` + +### 3. Register the `container-scope-override` on the target scope specification + +This is the piece that powers [Blue/Green Deployment Sync](#bluegreen-deployment-sync). It does **not** hook into path-router's own service specification — it hooks into the **scope specification** of the applications path-router will route to, so their deploy workflow calls `container-scope-override/deployment/sync_router` on every blue/green step: + +```hcl +module "scope_channel_association" { + for_each = toset([ + "/container-scope-override", + # add one entry per service that ships a container-scope-override directory + ]) + + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_definition_agent_association?ref=v4.5.1" + nrn = var.nrn + tags_selectors = var.tags_selectors_path_router + api_key = module.service_definition_association_api_key.api_key + scope_specification_id = var.scope_specification_id # the scope spec apps deploy under + scope_specification_slug = var.scope_specification_slug + enabled_override = true + override_repo_path = "/root/.np/nullplatform/services-path-router" + overrides_service_path = each.value +} +``` + +> **This `for_each` list is the actual mechanism behind the blue-green sync — and the actual failure mode to watch for.** Every path in the set gets its `container-scope-override` invoked on **every** deployment event of the target scope, unconditionally. If an entry stays in this list after its corresponding service has been removed from nullplatform (e.g. a leftover `"/endpoint-exposer/container-scope-override"` from a decommissioned service), that override still fires on every blue/green deploy, fails when it can't resolve a service specification, and **rolls back the entire deployment** — even though it has nothing to do with path-router. Keep this list in sync with the services actually registered in the account, in addition to keeping the services repo checkout itself up to date (see the note under Blue/Green Deployment Sync below). + +## How Routing Works + +On `create`/`update` (`workflows/istio/{create,update}.yaml`): +1. `find k8s namespace` — locates the `nullplatform` namespace. +2. `build context` — resolves the target scope. +3. `check path conflict` (`scripts/istio/check_path_conflict`) — fails the action if another `HTTPRoute` already serves the same `base_domain` + `path_prefix`. +4. `delete existing httproute` — removes any prior route for this service (idempotent update). +5. `build httproute` (`scripts/istio/build_httproute` → `build_rule` → `templates/istio/httproute.yaml.tpl`) — resolves the scope's backend `Service`(s), infers the Gateway from scope visibility, and renders the `HTTPRoute` manifest. If the scope has an in-progress blue/green deployment, this step builds a weighted `blue_green_annotation` splitting traffic between the blue and green `Service`s instead of a single backend. +6. `apply` — applies the manifest with `kubectl`. + +On `delete` (`workflows/istio/delete.yaml`): deletes all `HTTPRoute`s created for the service. + +## Blue/Green Deployment Sync + +`container-scope-override/` hooks into the **target scope's own** deployment workflow (not path-router's own workflow) so the route stays correct as the scope deploys: + +| Scope deployment step | Override workflow | What it does | +|---|---|---| +| Initial deploy | `initial.yaml` | Rebuilds the route once the first version is live. | +| Blue/green start | `blue_green.yaml` | Rebuilds the route with blue=100%, green=0% once the new pods are ready. | +| Traffic switch | `switch_traffic.yaml` | Rebuilds the route with the deployment's current `desired_switched_traffic` weight. | +| Rollback | `rollback.yaml` | Rebuilds the route pointing back at the surviving (green) backend after the failed blue deployment is torn down. | +| Finalize | `finalize.yaml` | Rebuilds the route pointing at the single finalized backend once blue/green concludes. | +| Delete | `delete.yaml` | Cleans up when the scope itself is deleted. | + +All of these invoke `container-scope-override/deployment/sync_router`, which looks up the path-router service instance for the current application (`SERVICE_SPECIFICATION_SLUG=path-router`, set in `container-scope-override/values.yaml`) and triggers its `update-path-router` action, then polls until it completes. **If no path-router service exists for the application, this step exits cleanly (status 0) — it's a no-op, not an error.** + +> Which override directories actually get invoked is controlled by the `scope_channel_association` tofu resource's `for_each` set (see [Tofu Implementation](#3-register-the-container-scope-override-on-the-target-scope-specification) above) — it is **not** derived from scanning the filesystem. Two things both need to hold for this to work reliably: (1) the `for_each` list must only contain paths for services still registered in nullplatform — a leftover entry for a decommissioned service fails and **rolls back the entire deployment**, unrelated to path-router; and (2) the agent's checkout of this repo must actually contain the directory each listed path points to — an unmerged/stale feature branch missing a path that's still in the `for_each` list will fail the same way. Keep both the tofu `for_each` list and the branch in sync with what's actually deployed. + +## Known Limitations + +- `base_domain` is a static enum (see [Adding a New Domain](#adding-a-new-domain)) — there is no dynamic domain list today. +- DNS is entirely out of band — nothing in this service or in nullplatform provisions it automatically. +- Mixed-visibility routing on a single `base_domain` is not supported (see the DNS table note above). diff --git a/container-scope-override/deployment/sync_router b/container-scope-override/deployment/sync_router new file mode 100755 index 0000000..06d1058 --- /dev/null +++ b/container-scope-override/deployment/sync_router @@ -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/container-scope-override/deployment/workflows/blue_green.yaml b/container-scope-override/deployment/workflows/blue_green.yaml new file mode 100644 index 0000000..a310ea3 --- /dev/null +++ b/container-scope-override/deployment/workflows/blue_green.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply diff --git a/container-scope-override/deployment/workflows/delete.yaml b/container-scope-override/deployment/workflows/delete.yaml new file mode 100644 index 0000000..e8906ae --- /dev/null +++ b/container-scope-override/deployment/workflows/delete.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply traffic diff --git a/container-scope-override/deployment/workflows/finalize.yaml b/container-scope-override/deployment/workflows/finalize.yaml new file mode 100644 index 0000000..a310ea3 --- /dev/null +++ b/container-scope-override/deployment/workflows/finalize.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply diff --git a/container-scope-override/deployment/workflows/initial.yaml b/container-scope-override/deployment/workflows/initial.yaml new file mode 100644 index 0000000..a310ea3 --- /dev/null +++ b/container-scope-override/deployment/workflows/initial.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply diff --git a/container-scope-override/deployment/workflows/rollback.yaml b/container-scope-override/deployment/workflows/rollback.yaml new file mode 100644 index 0000000..a310ea3 --- /dev/null +++ b/container-scope-override/deployment/workflows/rollback.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply diff --git a/container-scope-override/deployment/workflows/switch_traffic.yaml b/container-scope-override/deployment/workflows/switch_traffic.yaml new file mode 100644 index 0000000..a310ea3 --- /dev/null +++ b/container-scope-override/deployment/workflows/switch_traffic.yaml @@ -0,0 +1,5 @@ +steps: + - name: sync_router + type: script + file: "$OVERRIDES_PATH/deployment/sync_router" + after: apply diff --git a/container-scope-override/values.yaml b/container-scope-override/values.yaml new file mode 100644 index 0000000..8ee4047 --- /dev/null +++ b/container-scope-override/values.yaml @@ -0,0 +1,2 @@ +configuration: + SERVICE_SPECIFICATION_SLUG: path-router diff --git a/entrypoint/entrypoint b/entrypoint/entrypoint new file mode 100755 index 0000000..45d50d7 --- /dev/null +++ b/entrypoint/entrypoint @@ -0,0 +1,66 @@ +#!/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 + +# Fall back to deriving SERVICE_PATH from the script's own location when the +# caller doesn't pass --service-path. WORKING_DIRECTORY points at the +# `entrypoint/` directory inside the service; the parent is the service root +# (e.g. `/root/.np/nullplatform/services/endpoint-exposer`). Without this +# fallback, every absolute path below becomes `/workflows/...` instead of +# `/workflows/...`, and the np CLI fails with +# "failed to read workflow file: open /workflows//.yaml". +SERVICE_PATH="${SERVICE_PATH:-$(cd "$WORKING_DIRECTORY/.." && pwd)}" + +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/entrypoint/service b/entrypoint/service new file mode 100755 index 0000000..cdf2292 --- /dev/null +++ b/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:-istio}" + +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/scripts/common/apply b/scripts/common/apply new file mode 100755 index 0000000..1b092ff --- /dev/null +++ b/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/scripts/istio/build_context b/scripts/istio/build_context new file mode 100755 index 0000000..95e0930 --- /dev/null +++ b/scripts/istio/build_context @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +SERVICE_SLUG=$(echo "$CONTEXT" | jq -r '.service.slug') +ACTION_ID=$(echo "$CONTEXT" | jq -r '.id') +ACTION_NAME=$(echo "$CONTEXT" | jq -r '.slug') +APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""') + +BASE_DOMAIN=$(echo "$CONTEXT" | jq -r '.service.attributes.base_domain // .parameters.base_domain // ""') +STRIP_PREFIX=$(echo "$CONTEXT" | jq -r '.service.attributes.strip_prefix // .parameters.strip_prefix // "true"') +PATH_PREFIX=$(echo "$CONTEXT" | jq -r '.service.attributes.path_prefix // .parameters.path_prefix // ""') +SCOPE_SLUG=$(echo "$CONTEXT" | jq -r '.service.attributes.scope // .parameters.scope // ""') + +[[ -z "$BASE_DOMAIN" ]] && { echo "ERROR: base_domain is required in service attributes"; exit 1; } +[[ -z "$PATH_PREFIX" ]] && { echo "ERROR: path_prefix is required in service attributes"; exit 1; } +[[ -z "$SCOPE_SLUG" ]] && { echo "ERROR: scope is required in service attributes"; exit 1; } + +export OUTPUT_DIR="${SERVICE_PATH}/output/${SERVICE_SLUG}-${SERVICE_ID}/${ACTION_NAME}-${ACTION_ID}" +mkdir -p "$OUTPUT_DIR" + +export SERVICE_ID SERVICE_SLUG ACTION_ID ACTION_NAME APPLICATION_ID +export BASE_DOMAIN STRIP_PREFIX PATH_PREFIX SCOPE_SLUG diff --git a/scripts/istio/build_delete_context b/scripts/istio/build_delete_context new file mode 100755 index 0000000..48143d1 --- /dev/null +++ b/scripts/istio/build_delete_context @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') +export SERVICE_ID +export SCOPE_ID="" diff --git a/scripts/istio/build_httproute b/scripts/istio/build_httproute new file mode 100755 index 0000000..4542096 --- /dev/null +++ b/scripts/istio/build_httproute @@ -0,0 +1,105 @@ +#!/bin/bash + +set -euo pipefail + +source "$SERVICE_PATH/scripts/istio/config" + +echo "=== Building path-router HTTPRoute ===" +echo "BASE_DOMAIN: $BASE_DOMAIN" +echo "PATH_PREFIX: $PATH_PREFIX" +echo "SCOPE_SLUG: $SCOPE_SLUG" +echo "STRIP_PREFIX: $STRIP_PREFIX" + +echo "Resolving scope '$SCOPE_SLUG' for application $APPLICATION_ID..." +SCOPES_JSON=$(np scope list --application_id "$APPLICATION_ID" --limit 200 --format json | jq '.results // []') +SCOPE_ID=$(echo "$SCOPES_JSON" | jq -r --arg s "$SCOPE_SLUG" '.[] | select(.slug == $s) | .id // empty') + +if [[ -z "$SCOPE_ID" ]]; then + echo "ERROR: scope '$SCOPE_SLUG' not found for application $APPLICATION_ID" + exit 1 +fi + +echo "Resolved: $SCOPE_SLUG → $SCOPE_ID" + +export SCOPE_ID +source "$SERVICE_PATH/scripts/istio/build_rule" + +if [[ "$SCOPE_VISIBILITY" == "private" ]]; then + GATEWAY_NAME="$PRIVATE_GATEWAY_NAME" +else + GATEWAY_NAME="$PUBLIC_GATEWAY_NAME" +fi + +echo "Gateway: $GATEWAY_NAME (namespace: $GATEWAY_NAMESPACE) — inferred from scope visibility: $SCOPE_VISIBILITY" + +SERVICE_NAME=$(echo "$SCOPE_RULE" | jq -r '.service.name') +SERVICE_PORT=$(echo "$SCOPE_RULE" | jq -r '.service.port.number') +BLUE_GREEN=$(echo "$SCOPE_RULE" | jq -r '.blue_green_annotation // "null"') + +if [[ "$BLUE_GREEN" != "null" && -n "$BLUE_GREEN" ]]; then + BLUE_SERVICE_NAME=$(echo "$BLUE_GREEN" | jq -r '.forward.targetGroups[0].serviceName') + GREEN_SERVICE_NAME=$(echo "$BLUE_GREEN" | jq -r '.forward.targetGroups[1].serviceName') + BLUE_WEIGHT=$(echo "$BLUE_GREEN" | jq -r '.forward.targetGroups[0].weight') + GREEN_WEIGHT=$(echo "$BLUE_GREEN" | jq -r '.forward.targetGroups[1].weight') + + CONTEXT_JSON=$(jq -n \ + --arg service_id "$SERVICE_ID" \ + --arg scope_id "$SCOPE_ID" \ + --arg k8s_namespace "$K8S_NAMESPACE" \ + --arg base_domain "$BASE_DOMAIN" \ + --arg path_prefix "$PATH_PREFIX" \ + --arg gateway_name "$GATEWAY_NAME" \ + --arg gateway_namespace "$GATEWAY_NAMESPACE" \ + --argjson strip_prefix "$STRIP_PREFIX" \ + --arg service_name "$SERVICE_NAME" \ + --argjson service_port "$SERVICE_PORT" \ + --argjson blue_green "true" \ + --arg blue_service_name "$BLUE_SERVICE_NAME" \ + --arg green_service_name "$GREEN_SERVICE_NAME" \ + --argjson blue_weight "$BLUE_WEIGHT" \ + --argjson green_weight "$GREEN_WEIGHT" \ + '{ + service_id: $service_id, scope_id: $scope_id, + k8s_namespace: $k8s_namespace, base_domain: $base_domain, + path_prefix: $path_prefix, gateway_name: $gateway_name, + gateway_namespace: $gateway_namespace, strip_prefix: $strip_prefix, + service_name: $service_name, service_port: $service_port, + blue_green: $blue_green, + blue_service_name: $blue_service_name, green_service_name: $green_service_name, + blue_weight: $blue_weight, green_weight: $green_weight + }') +else + CONTEXT_JSON=$(jq -n \ + --arg service_id "$SERVICE_ID" \ + --arg scope_id "$SCOPE_ID" \ + --arg k8s_namespace "$K8S_NAMESPACE" \ + --arg base_domain "$BASE_DOMAIN" \ + --arg path_prefix "$PATH_PREFIX" \ + --arg gateway_name "$GATEWAY_NAME" \ + --arg gateway_namespace "$GATEWAY_NAMESPACE" \ + --argjson strip_prefix "$STRIP_PREFIX" \ + --arg service_name "$SERVICE_NAME" \ + --argjson service_port "$SERVICE_PORT" \ + --argjson blue_green "false" \ + '{ + service_id: $service_id, scope_id: $scope_id, + k8s_namespace: $k8s_namespace, base_domain: $base_domain, + path_prefix: $path_prefix, gateway_name: $gateway_name, + gateway_namespace: $gateway_namespace, strip_prefix: $strip_prefix, + service_name: $service_name, service_port: $service_port, + blue_green: $blue_green + }') +fi + +CONTEXT_PATH="$OUTPUT_DIR/httproute-context-$SCOPE_ID.json" +echo "$CONTEXT_JSON" > "$CONTEXT_PATH" + +HTTPROUTE_FILE="$OUTPUT_DIR/httproute-path-router-$SERVICE_ID-$SCOPE_ID.yaml" +TEMPLATE="$SERVICE_PATH/templates/istio/httproute.yaml.tpl" + +echo "Rendering: $TEMPLATE → $HTTPROUTE_FILE" +gomplate -c .="$CONTEXT_PATH" --file "$TEMPLATE" --out "$HTTPROUTE_FILE" +rm "$CONTEXT_PATH" + +echo "Done: $HTTPROUTE_FILE" +export HTTPROUTE_FILE diff --git a/scripts/istio/build_ingress_with_rule b/scripts/istio/build_ingress_with_rule new file mode 100755 index 0000000..2518a4e --- /dev/null +++ b/scripts/istio/build_ingress_with_rule @@ -0,0 +1,406 @@ +#!/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 + +# Ensure a path starts with "/". The Kubernetes Gateway API rejects +# non-absolute values for the `Exact` and `PathPrefix` match types with: +# spec.rules[N].matches[M].path: Invalid value: ...: value must be an +# absolute path and start with '/' when type one of ['Exact', 'PathPrefix'] +# Developers often enter "health" in the UI expecting it to become "/health", +# so normalize here instead of surfacing a validator error per route. +ensure_absolute_path() { + local path="$1" + if [[ "$path" != /* ]]; then + echo "/$path" + else + echo "$path" + fi +} + +# 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 + prefix_path=$(ensure_absolute_path "$prefix_path") + 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') + # RegularExpression values are not required to be absolute by Gateway API, + # so we leave them untouched. + echo "RegularExpression:$regex_path" + return + fi + + # Default: Exact match + path=$(ensure_absolute_path "$path") + 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/scripts/istio/build_rule b/scripts/istio/build_rule new file mode 100755 index 0000000..ef97852 --- /dev/null +++ b/scripts/istio/build_rule @@ -0,0 +1,186 @@ +#!/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" + +SCOPE_VISIBILITY=$(echo "$SCOPE_JSON" | jq -r '.visibility // "public"') +echo "DEBUG: SCOPE_VISIBILITY=$SCOPE_VISIBILITY" + +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 +export SCOPE_VISIBILITY \ No newline at end of file diff --git a/scripts/istio/check_path_conflict b/scripts/istio/check_path_conflict new file mode 100755 index 0000000..48d58b7 --- /dev/null +++ b/scripts/istio/check_path_conflict @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +echo "Checking for path conflicts: $BASE_DOMAIN$PATH_PREFIX" + +CONFLICT=$(kubectl get httproute -n "$K8S_NAMESPACE" \ + -l "nullplatform.com/managed-by=path-router" \ + -o json 2>/dev/null | \ + jq -r --arg domain "$BASE_DOMAIN" --arg path "$PATH_PREFIX" --arg sid "$SERVICE_ID" ' + .items[] | + select( + (.spec.hostnames // [] | any(. == $domain)) and + (.spec.rules[].matches[].path.value == $path) and + (.metadata.labels["nullplatform.com/service-id"] != $sid) + ) | .metadata.name' 2>/dev/null || true) + +if [[ -n "$CONFLICT" ]]; then + echo "ERROR: path '$PATH_PREFIX' on '$BASE_DOMAIN' is already claimed by HTTPRoute '$CONFLICT'" + echo "Each path prefix must be unique per domain across all path-router service instances." + exit 1 +fi + +echo "No conflicts found — path '$PATH_PREFIX' on '$BASE_DOMAIN' is available." diff --git a/scripts/istio/config b/scripts/istio/config new file mode 100755 index 0000000..1f11985 --- /dev/null +++ b/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/scripts/istio/delete_httproutes b/scripts/istio/delete_httproutes new file mode 100755 index 0000000..4c0f5d9 --- /dev/null +++ b/scripts/istio/delete_httproutes @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +if [[ -n "${SCOPE_ID:-}" && "$SCOPE_ID" != "null" ]]; then + echo "Deleting HTTPRoute for service=$SERVICE_ID scope=$SCOPE_ID..." + kubectl delete httproute -n "$K8S_NAMESPACE" \ + -l "nullplatform.com/service-id=$SERVICE_ID,nullplatform.com/scope-id=$SCOPE_ID" \ + --ignore-not-found +else + echo "Deleting all HTTPRoutes for service=$SERVICE_ID..." + kubectl delete httproute -n "$K8S_NAMESPACE" \ + -l "nullplatform.com/service-id=$SERVICE_ID" \ + --ignore-not-found +fi + +echo "Delete complete" diff --git a/scripts/istio/fetch_provider_data b/scripts/istio/fetch_provider_data new file mode 100755 index 0000000..f3fcbbe --- /dev/null +++ b/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/specs/links/connect.json.tpl b/specs/links/connect.json.tpl new file mode 100644 index 0000000..89affd8 --- /dev/null +++ b/specs/links/connect.json.tpl @@ -0,0 +1,16 @@ +{ + "name": "Connect", + "slug": "connect", + "unique": true, + "assignable_to": "any", + "use_default_actions": false, + "attributes": { + "schema": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "required": [], + "properties": {} + }, + "values": {} + } +} diff --git a/specs/service-spec.json.tpl b/specs/service-spec.json.tpl new file mode 100644 index 0000000..5cbb445 --- /dev/null +++ b/specs/service-spec.json.tpl @@ -0,0 +1,82 @@ +{ + "name": "Path Router", + "slug": "path-router", + "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": ["base_domain", "path_prefix", "scope"], + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "label": "Base Domain", + "scope": "#/properties/base_domain" + }, + { + "type": "Control", + "label": "Strip path prefix before forwarding", + "scope": "#/properties/strip_prefix" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "label": "Path Prefix", + "scope": "#/properties/path_prefix" + }, + { + "type": "Control", + "label": "Scope", + "scope": "#/properties/scope" + } + ] + } + ] + }, + "properties": { + "base_domain": { + "type": "string", + "title": "Base Domain", + "description": "Shared domain for path-based routing.", + "enum": ["path-router.api-private.playground.nullapps.io"] + }, + "strip_prefix": { + "type": "boolean", + "title": "Strip path prefix", + "description": "Remove the path prefix before forwarding to the backend. When enabled, /APP1/health is forwarded as /health.", + "default": true + }, + "path_prefix": { + "type": "string", + "title": "Path Prefix", + "pattern": "^/[a-zA-Z0-9_\\-]+$", + "description": "Path prefix to route to this application. Example: /APP1, /api-gateway" + }, + "scope": { + "type": "string", + "title": "Scope", + "description": "Target scope to route traffic to.", + "additionalKeywords": { + "enum": "[.scopes[]?.slug] | if length == 0 then [\"No scopes available for selected environment\"] else . end" + } + } + } + }, + "values": {} + }, + "selectors": { + "category": "Networking", + "imported": false, + "provider": "Istio", + "sub_category": "Path Routing" + } +} diff --git a/templates/istio/httproute.yaml.tpl b/templates/istio/httproute.yaml.tpl new file mode 100644 index 0000000..1296151 --- /dev/null +++ b/templates/istio/httproute.yaml.tpl @@ -0,0 +1,38 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: path-router-{{ .service_id }}-{{ .scope_id }} + namespace: {{ .k8s_namespace }} + labels: + nullplatform.com/managed-by: path-router + nullplatform.com/service-id: "{{ .service_id }}" + nullplatform.com/scope-id: "{{ .scope_id }}" +spec: + parentRefs: + - name: {{ .gateway_name }} + namespace: {{ .gateway_namespace }} + group: gateway.networking.k8s.io + kind: Gateway + hostnames: + - {{ .base_domain }} + rules: + - matches: + - path: + type: PathPrefix + value: {{ .path_prefix }} +{{ if .strip_prefix }} filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: / +{{ end }} backendRefs: +{{ if .blue_green }} - name: {{ .blue_service_name }} + port: {{ .service_port }} + weight: {{ .blue_weight }} + - name: {{ .green_service_name }} + port: {{ .service_port }} + weight: {{ .green_weight }} +{{ else }} - name: {{ .service_name }} + port: {{ .service_port }} +{{ end }} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..6831afc --- /dev/null +++ b/values.yaml @@ -0,0 +1,2 @@ +configuration: + K8S_NAMESPACE: nullplatform \ No newline at end of file diff --git a/workflows/istio/create.yaml b/workflows/istio/create.yaml new file mode 100644 index 0000000..e0f26fd --- /dev/null +++ b/workflows/istio/create.yaml @@ -0,0 +1,41 @@ +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: APPLICATION_ID + type: environment + - name: BASE_DOMAIN + type: environment + - name: STRIP_PREFIX + type: environment + - name: PATH_PREFIX + type: environment + - name: SCOPE_SLUG + type: environment + - name: "check path conflict" + type: script + file: "$SERVICE_PATH/scripts/istio/check_path_conflict" + - name: "build httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + - name: apply + type: script + file: "$SERVICE_PATH/scripts/common/apply" + configuration: + ACTION: apply + DRY_RUN: false diff --git a/workflows/istio/delete.yaml b/workflows/istio/delete.yaml new file mode 100644 index 0000000..603eb26 --- /dev/null +++ b/workflows/istio/delete.yaml @@ -0,0 +1,18 @@ +steps: + - name: "find k8s namespace" + type: script + file: "$SERVICE_PATH/scripts/istio/fetch_provider_data" + output: + - name: K8S_NAMESPACE + type: environment + - name: "build delete context" + type: script + file: "$SERVICE_PATH/scripts/istio/build_delete_context" + output: + - name: SERVICE_ID + type: environment + - name: SCOPE_ID + type: environment + - name: "delete httproutes" + type: script + file: "$SERVICE_PATH/scripts/istio/delete_httproutes" diff --git a/workflows/istio/update.yaml b/workflows/istio/update.yaml new file mode 100644 index 0000000..6fe4e83 --- /dev/null +++ b/workflows/istio/update.yaml @@ -0,0 +1,44 @@ +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: APPLICATION_ID + type: environment + - name: BASE_DOMAIN + type: environment + - name: STRIP_PREFIX + type: environment + - name: PATH_PREFIX + type: environment + - name: SCOPE_SLUG + type: environment + - name: "check path conflict" + type: script + file: "$SERVICE_PATH/scripts/istio/check_path_conflict" + - name: "delete existing httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/delete_httproutes" + - name: "build httproute" + type: script + file: "$SERVICE_PATH/scripts/istio/build_httproute" + - name: apply + type: script + file: "$SERVICE_PATH/scripts/common/apply" + configuration: + ACTION: apply + DRY_RUN: false From 90803bdf788759ac257d24d25d127c99973e350c Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 12:31:14 -0300 Subject: [PATCH 2/7] fix(path-router): replace real playground domain with placeholder example The base_domain enum leaked a real testing domain (path-router.api-private.playground.nullapps.io). Replace it and the matching README examples with a generic example.com placeholder. --- README.md | 4 ++-- specs/service-spec.json.tpl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f358715..8506741 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ These all have working defaults (`scripts/istio/config`) — you only need to se "type": "string", "title": "Base Domain", "description": "Shared domain for path-based routing.", - "enum": ["path-router.api-private.playground.nullapps.io"] + "enum": ["path-router.example.com"] } ``` @@ -98,7 +98,7 @@ kubectl get ingress -n gateways gateway-alb-private -o jsonpath='{.status.loadBa Example Route53 alias record: ``` -path-router.api-private.playground.nullapps.io. A (Alias) → dualstack.k8s-nullplatform-internet-facing-xxxxxxxxxx.us-east-1.elb.amazonaws.com. +path-router.example.com. A (Alias) → dualstack.k8s-nullplatform-internet-facing-xxxxxxxxxx.us-east-1.elb.amazonaws.com. ``` Note: **which gateway/ALB to point at depends on the visibility of the scopes you intend to route to**, not on the domain name itself — path-router inspects the target scope's `visibility` at request-routing time (`scripts/istio/build_httproute`) and attaches the `HTTPRoute` to the matching Gateway automatically. If a single `base_domain` is ever used to route to scopes of mixed visibility, only one Gateway/ALB will be reachable at a time per route — keep one domain per visibility tier to avoid surprises. diff --git a/specs/service-spec.json.tpl b/specs/service-spec.json.tpl index 5cbb445..19f5222 100644 --- a/specs/service-spec.json.tpl +++ b/specs/service-spec.json.tpl @@ -47,7 +47,7 @@ "type": "string", "title": "Base Domain", "description": "Shared domain for path-based routing.", - "enum": ["path-router.api-private.playground.nullapps.io"] + "enum": ["path-router.example.com"] }, "strip_prefix": { "type": "boolean", From 23da1b7b95a028dc76967bfd250aadce5ceb7622 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 12:33:07 -0300 Subject: [PATCH 3/7] chore(path-router): remove unused scaffold files from initial template .gitignore (Node.js-specific), Dockerfile (http-echo placeholder), and ci.yml (docker build/push) came from the generic "Any Technology" template and aren't used by this shell-script/tofu-based service. --- .github/workflows/ci.yml | 28 -------- .gitignore | 134 --------------------------------------- Dockerfile | 5 -- 3 files changed, 167 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .gitignore delete mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ce7b173..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: ci-nullplatform -env: - NULLPLATFORM_API_KEY: ${{ secrets.NULLPLATFORM_API_KEY }} -on: - push: - branches: - - main -permissions: - id-token: write - contents: read - packages: read -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Install nullplatform cli - run: curl https://cli.nullplatform.com/install.sh | sh - - name: Checkout code - uses: actions/checkout@v4 - - name: Start nullplatform CI - run: np build start - - name: Build asset - run: docker build -t main . - - name: Push asset - run: np asset push --type docker-image --source main - - name: End nullplatform CI - if: ${{ always() }} - run: np build update --status ${{ contains(fromJSON('["failure", "cancelled"]'), job.status) && 'failed' || 'successful' }} \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c1ae460..0000000 --- a/.gitignore +++ /dev/null @@ -1,134 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# Intellij - -.idea diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2c788e0..0000000 --- a/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM hashicorp/http-echo:1.0.0 - -CMD ["-text={\"status\":\"ok\",\"msg\":\"Hola mundo\"}", "-listen=:8080", "-status-code=200"] - - From b2d7440da1bbbaf3e26fff9c04d48bfd68d8ee4a Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 12:36:28 -0300 Subject: [PATCH 4/7] chore(path-router): add .gitignore for output dir, IDEs, and OS files Covers the runtime output/ directory written by scripts/istio/build_context, plus common IDE folders (.idea, .vscode, .claude) and OS cruft (.DS_Store). --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49bce2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Script output (generated at runtime by the agent) +output/ + +# IDE +.idea/ +.vscode/ +.claude/ + +# OS +.DS_Store From 157cf78ca3cd8295f2ac17a54baeba1ab22ad155 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 12:58:07 -0300 Subject: [PATCH 5/7] feat(path-router): add specs/install/istio to register the service on nullplatform Mirrors the specs/install convention added for rds-postgres-server/db: a working, copy-pasteable OpenTofu example that registers the service specification, the service-level agent association, and the container-scope-override hook on the target scope specification via nullplatform/tofu-modules. Unlike RDS, path-router has no requirements/ step since it provisions no cloud infrastructure. --- .gitignore | 7 ++ README.md | 2 +- specs/install/README.md | 54 +++++++++++++++ specs/install/istio/.terraform.lock.hcl | 65 ++++++++++++++++++ specs/install/istio/main.tf | 71 ++++++++++++++++++++ specs/install/istio/outputs.tf | 9 +++ specs/install/istio/terraform.tfvars.example | 15 +++++ specs/install/istio/variables.tf | 62 +++++++++++++++++ 8 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 specs/install/README.md create mode 100644 specs/install/istio/.terraform.lock.hcl create mode 100644 specs/install/istio/main.tf create mode 100644 specs/install/istio/outputs.tf create mode 100644 specs/install/istio/terraform.tfvars.example create mode 100644 specs/install/istio/variables.tf diff --git a/.gitignore b/.gitignore index 49bce2f..68ab390 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ # Script output (generated at runtime by the agent) output/ +# Tofu / Terraform +**/.terraform/ +*.tfstate +*.tfstate.backup +*.tfstate.*.backup +specs/install/**/terraform.tfvars + # IDE .idea/ .vscode/ diff --git a/README.md b/README.md index 8506741..7454d5e 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Note: **which gateway/ALB to point at depends on the visibility of the scopes yo ## Tofu Implementation (Registering the Service) -path-router itself creates no AWS infrastructure, but it still needs to be **registered with nullplatform and wired to the agent** via `nullplatform/tofu-modules`. This is normally done in the account's platform-provisioning tofu project (e.g. `services-testing` / `ifr-platformups-*`), not in this repo. Three module calls are involved: +path-router itself creates no AWS infrastructure, but it still needs to be **registered with nullplatform and wired to the agent** via `nullplatform/tofu-modules`. This is normally done in the account's platform-provisioning tofu project (e.g. `services-testing` / `ifr-platformups-*`), not in this repo. Three module calls are involved — a ready-to-copy working example lives in [`specs/install/istio`](specs/install/istio): ### 1. Register the service specification diff --git a/specs/install/README.md b/specs/install/README.md new file mode 100644 index 0000000..20f5c30 --- /dev/null +++ b/specs/install/README.md @@ -0,0 +1,54 @@ +# Install — registering the path-router service + +This directory holds the reference OpenTofu/Terraform used to **install** +path-router on a nullplatform account: registering its service +specification, agent association (notification channel), and the +`container-scope-override` hook on the target scope specification, so +`np service create` starts routing actions to an agent and deployments of +the routed scopes keep their `HTTPRoute` in sync. + +Unlike `rds-postgres-server`/`rds-postgres-db`, path-router has no separate +`requirements/` step — it creates no cloud infrastructure, so there is no +AssumeRole (or equivalent) IAM setup to provision beforehand. + +## Layout + +``` +install/ +├── README.md (this file) +└── istio/ Working example (only ingress type implemented today) + ├── main.tf + ├── variables.tf + ├── outputs.tf + └── terraform.tfvars.example +``` + +## Using the example + +```bash +cp -r specs/install/istio /path/to/your/infra/path-router +cd /path/to/your/infra/path-router +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars + +tofu init +tofu apply +``` + +`tags_selectors` must match the tag selectors of the agent(s) that should +pick up path-router actions (the same selectors passed as `tags_selectors` +to the `nullplatform/agent` tofu-module). + +`scope_specification_id`/`scope_specification_slug` identify the scope spec +whose deploy workflow gets the `container-scope-override` hook — i.e. the +type of application that will be routed to via path-router. Run this once +per scope specification that needs blue/green sync; see the top-level +[`README.md`](../../README.md#3-register-the-container-scope-override-on-the-target-scope-specification) +for the failure mode to watch out for if a scope specification's `for_each` +list of override paths ever falls out of sync with the services actually +registered on the account. + +This only registers the service with the platform and wires the deployment +hook — it does not create the Gateway API resources or DNS records the +service needs to actually route traffic. See the top-level +[`README.md`](../../README.md) ("Requirements") for those steps. diff --git a/specs/install/istio/.terraform.lock.hcl b/specs/install/istio/.terraform.lock.hcl new file mode 100644 index 0000000..54792e4 --- /dev/null +++ b/specs/install/istio/.terraform.lock.hcl @@ -0,0 +1,65 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/external" { + version = "2.4.0" + hashes = [ + "h1:AmY6ZeIvqoTT5ZjzD+P49PeQH6Va1QLMkX+7MUQfYoA=", + "zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135", + "zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134", + "zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950", + "zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852", + "zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee", + "zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c", + "zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d", + "zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4", + "zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee", + "zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3", + "zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54", + ] +} + +provider "registry.terraform.io/hashicorp/http" { + version = "3.6.0" + constraints = "~> 3.0" + hashes = [ + "h1:ligOpkIiBTS1ZkLoFp3cwkRYCwYyaZj1Z7t3GdfNa+c=", + "zh:0996c7db5d7627bc6ab8c4d217f18fb122d60e99e454812b080ee5695cad1003", + "zh:25b7ee0ba9edc912a00365c776d062ae7c66d94050c6c13038447c8e8b95ddf2", + "zh:29c4ba54add6eee7f1d0034d331ba0f14f3046234b1f7520a537e6444e4521b7", + "zh:30a3aa3ef978f8142daf2ced3f9f1ecda8b0831cdc6911e7e930e95eab191b4f", + "zh:30bf0810acdfe96e799ed9b64cb70e96d6f7c033621e0373b9897513977c49c5", + "zh:4ecaaf3dfd20feaa2b92af521018501bc7d874b6a6642ef86ea4cc3c251d737a", + "zh:661760406e3d5372e6725d18ca80734996f21adabc02d82e36ed6d8db07cad7d", + "zh:70dd9556bd2633082efd9681421f89dd4abdde6fcd84834627fa4b8b8f9e7afe", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:973a72066866579ac6fa0e0c9e9eee8e77d238cf78f1b1f96ce6536052fa73ce", + "zh:b2ac9463a9499b478147186027fabee65c04b8f6d963a8df3d49241ce5784fe2", + "zh:c6acc05dc3456c0001b5fb99e1b57005f5a3d3d766f9bddfc21ffe8364fa7535", + "zh:f97f2d57ebcefe62f0644f58e8ba68593f8f05baf7856c99953396aedd14e415", + ] +} + +provider "registry.terraform.io/nullplatform/nullplatform" { + version = "0.0.96" + constraints = ">= 0.0.86" + hashes = [ + "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=", + "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960", + "zh:00c482219d99e53b6802f2f7a2b1581ddcae88911fcbf09a7a26b18e62cad846", + "zh:19114e9acae41bbb79f5308a754492ace94c2df3ee21e0c99de51c5dcab3f775", + "zh:2568f82d9e587f6253d5e8bd9b9cf20ad7cfb403488af0928ee6dfd116f6635f", + "zh:35c046dabe79ede321b6d5a4fd37564a2cfd00f8d49be01ff17dbdb86fd847b0", + "zh:46b4300356f11726fd6675b6a7fe9c7293094f38ec1be5d6d5d659863eee3e8a", + "zh:46d5029dcdb6febd2c10b19da7b565c14915909da0e7576e33b56c4697c5bfe3", + "zh:46ffdfa96722e184b64bd04198170c91bf59976a5171aad5371b655481da346a", + "zh:91bc64b2f7da208e1f0d5a059026b6fb78d284e2f03ab15d1e7eb16cf707eed0", + "zh:ad1b94910840d6662ef21dea503998618e6ad5b0a340c63044bb5e913e926eeb", + "zh:bf3ecf8a22fdfab30f7eaac8b56bd321e5fd4e3a0a57fda15a3078c84e769a71", + "zh:e9bd846753e194c8fc2f2f3f53f15fd33f9fd56467963fd2766d1f713865bcad", + "zh:ecf36a184c1a01f9297e331d0b1671a99e3fbc179ceb2653efe0a74c61ec97ea", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/specs/install/istio/main.tf b/specs/install/istio/main.tf new file mode 100644 index 0000000..1dd05ff --- /dev/null +++ b/specs/install/istio/main.tf @@ -0,0 +1,71 @@ +################################################################################ +# Install — registers the path-router service definition and its agent +# associations (notification channels) on a nullplatform account. +# +# path-router creates no AWS (or other cloud) infrastructure, so this is the +# only registration step needed. It covers: +# 1. the service specification (service_definition) +# 2. the service-level notification channel, so the agent's entrypoint gets +# invoked for path-router's own create/update/delete/link actions +# (service_definition_agent_association) +# 3. the scope-level container-scope-override hook that keeps HTTPRoutes in +# sync during blue/green deployments of the scopes path-router routes to +# (scope_definition_agent_association) +# +# See ../../../README.md ("Tofu Implementation") for the full picture, +# including the Gateway env vars, base_domain enum, and DNS steps this +# registration alone does not cover. +################################################################################ + +locals { + service_path = "." + available_links = ["connect"] + available_actions = [] +} + +module "service_definition" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1" + + nrn = var.nrn + repository_org = var.repository_org + repository_name = var.repository_name + repository_branch = var.repository_branch + repository_token = var.repository_token + service_path = local.service_path + service_name = var.service_name + available_links = local.available_links + available_actions = local.available_actions +} + +module "service_definition_association_api_key" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/api_key?ref=v4.5.1" + + type = "scope_notification" + nrn = var.nrn + specification_slug = "k8s" +} + +module "service_definition_agent_association" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1" + + nrn = var.nrn + repository_service_spec_repo = "${var.repository_org}/${var.repository_name}" + service_path = local.service_path + service_specification_slug = module.service_definition.service_specification_slug + api_key = module.service_definition_association_api_key.api_key + tags_selectors = var.tags_selectors + agent_arguments = ["--service-path=${var.agent_service_path}"] +} + +module "scope_definition_agent_association" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_definition_agent_association?ref=v4.5.1" + + nrn = var.nrn + tags_selectors = var.tags_selectors + api_key = module.service_definition_association_api_key.api_key + scope_specification_id = var.scope_specification_id + scope_specification_slug = var.scope_specification_slug + enabled_override = true + override_repo_path = var.override_repo_path + overrides_service_path = "/container-scope-override" +} diff --git a/specs/install/istio/outputs.tf b/specs/install/istio/outputs.tf new file mode 100644 index 0000000..e52a9ab --- /dev/null +++ b/specs/install/istio/outputs.tf @@ -0,0 +1,9 @@ +output "service_specification_id" { + description = "ID of the registered path-router service specification." + value = module.service_definition.service_specification_id +} + +output "service_specification_slug" { + description = "Slug of the registered path-router service specification." + value = module.service_definition.service_specification_slug +} diff --git a/specs/install/istio/terraform.tfvars.example b/specs/install/istio/terraform.tfvars.example new file mode 100644 index 0000000..25f5450 --- /dev/null +++ b/specs/install/istio/terraform.tfvars.example @@ -0,0 +1,15 @@ +nrn = "" # namespace-level NRN, e.g. organization=:account=:namespace= + +tags_selectors = { + "environment" = "" +} + +scope_specification_id = "" # the scope spec whose apps path-router will route to +scope_specification_slug = "" + +# override_repo_path = "/root/.np/nullplatform/services-path-router" +# agent_service_path = "/root/.np/nullplatform/services-path-router" + +# repository_org = "nullplatform" +# repository_name = "services-path-router" +# repository_branch = "main" diff --git a/specs/install/istio/variables.tf b/specs/install/istio/variables.tf new file mode 100644 index 0000000..6c106b1 --- /dev/null +++ b/specs/install/istio/variables.tf @@ -0,0 +1,62 @@ +variable "nrn" { + description = "NullPlatform Resource Name (namespace-level, e.g. organization=:account=:namespace=) where the service definition is registered." + type = string +} + +variable "tags_selectors" { + description = "Agent tag selectors for the notification channels (must match the tags the target agent registers with)." + type = map(string) +} + +variable "scope_specification_id" { + description = "ID of the scope specification whose deploy workflow should be hooked with the container-scope-override — i.e. the scope spec that the applications path-router routes to deploy under." + type = string +} + +variable "scope_specification_slug" { + description = "Slug of the scope specification identified by scope_specification_id." + type = string +} + +variable "override_repo_path" { + description = "Path on the agent's filesystem where this repository is checked out (used to resolve overrides_service_path)." + type = string + default = "/root/.np/nullplatform/services-path-router" +} + +variable "agent_service_path" { + description = "Path on the agent's filesystem passed as --service-path so the agent's entrypoint resolves this service's scripts and specs." + type = string + default = "/root/.np/nullplatform/services-path-router" +} + +variable "service_name" { + description = "Display name for the Path Router service in nullplatform." + type = string + default = "Path Router" +} + +variable "repository_org" { + description = "GitHub organization owning the services-path-router repository." + type = string + default = "nullplatform" +} + +variable "repository_name" { + description = "Repository name containing the path-router service spec templates." + type = string + default = "services-path-router" +} + +variable "repository_branch" { + description = "Branch of the services-path-router repository to register the service spec/links/entrypoint from." + type = string + default = "main" +} + +variable "repository_token" { + description = "Access token for private repositories. Unnecessary once services-path-router is public." + type = string + default = null + sensitive = true +} From 558f4a41425b702ce5e27a0eff5edd0caf9a1531 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 13:44:53 -0300 Subject: [PATCH 6/7] fix(path-router): keep PAE's live domain in the base_domain enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing it broke the live service_specification registered against PAE's account (which reads this file directly to render the spec) — the enum is the actual runtime source of truth, not just documentation. Keep the generic placeholder alongside the real domain instead of replacing it. --- README.md | 2 +- specs/service-spec.json.tpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7454d5e..bf774a0 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ These all have working defaults (`scripts/istio/config`) — you only need to se "type": "string", "title": "Base Domain", "description": "Shared domain for path-based routing.", - "enum": ["path-router.example.com"] + "enum": ["path-router.example.com", "path-router.api-private.playground.nullapps.io"] } ``` diff --git a/specs/service-spec.json.tpl b/specs/service-spec.json.tpl index 19f5222..7bee255 100644 --- a/specs/service-spec.json.tpl +++ b/specs/service-spec.json.tpl @@ -47,7 +47,7 @@ "type": "string", "title": "Base Domain", "description": "Shared domain for path-based routing.", - "enum": ["path-router.example.com"] + "enum": ["path-router.example.com", "path-router.api-private.playground.nullapps.io"] }, "strip_prefix": { "type": "boolean", From 6955e86b8a244feac0ec6f924d0e05ba3b04c076 Mon Sep 17 00:00:00 2001 From: sebas_correa Date: Wed, 8 Jul 2026 14:32:10 -0300 Subject: [PATCH 7/7] docs(path-router): document the agent_repos_extra requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering the service specification isn't enough for actions to work — the agent needs this repo in its agent_repos_extra to actually run entrypoint/entrypoint. Found this the hard way while wiring path-router into a real account: the service_definition looked correctly registered but had no effect until the agent's repo list was updated. --- specs/install/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/specs/install/README.md b/specs/install/README.md index 20f5c30..38b7a3e 100644 --- a/specs/install/README.md +++ b/specs/install/README.md @@ -52,3 +52,30 @@ This only registers the service with the platform and wires the deployment hook — it does not create the Gateway API resources or DNS records the service needs to actually route traffic. See the top-level [`README.md`](../../README.md) ("Requirements") for those steps. + +## Wiring the agent (`agent_repos_extra`) + +Registering the service specification is not enough on its own — the agent +also needs this repository checked out locally so it can actually run +`entrypoint/entrypoint` and `container-scope-override/deployment/sync_router` +when an action fires. Add it to the `agent_repos_extra` variable of the +`nullplatform/agent` tofu-module (the module that deploys the agent, usually +in a separate cluster-provisioning tofu project, not in this one): + +```hcl +agent_repos_extra = [ + # ...other repos the agent needs... + "https://github.com/nullplatform/services-path-router.git#main", # or the branch you're testing +] +``` + +Without this, `service_definition`/`service_definition_agent_association` +above will succeed and the service will look correctly registered, but every +action will fail once it reaches the agent — it has no local copy of +`entrypoint/entrypoint` to run. If the repository is still private, use an +authenticated URL instead (`https://@github.com/...`) and drop the +token once it's public. + +After updating `agent_repos_extra`, re-apply the `nullplatform/agent` module +— this triggers a Helm chart update that restarts the agent pod with the new +repository list.