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/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/.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/.gitignore b/.gitignore
index c1ae460..68ab390 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,134 +1,17 @@
-# 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
+# Script output (generated at runtime by the agent)
+output/
+
+# Tofu / Terraform
+**/.terraform/
+*.tfstate
+*.tfstate.backup
+*.tfstate.*.backup
+specs/install/**/terraform.tfvars
+
+# IDE
+.idea/
+.vscode/
+.claude/
+
+# OS
+.DS_Store
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"]
-
-
diff --git a/README.md b/README.md
index 4e481b6..bf774a0 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,212 @@
-
-
-
-
-
-
- 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.example.com", "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.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.
+
+## 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 — a ready-to-copy working example lives in [`specs/install/istio`](specs/install/istio):
+
+### 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/install/README.md b/specs/install/README.md
new file mode 100644
index 0000000..38b7a3e
--- /dev/null
+++ b/specs/install/README.md
@@ -0,0 +1,81 @@
+# 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.
+
+## 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.
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
+}
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..7bee255
--- /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.example.com", "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