This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git

commit 00d8725b486e4b450e093b419d498252b6de49b6
Author: yasithdev <[email protected]>
AuthorDate: Thu Jun 11 04:17:07 2026 -0400

    added quickstart and readme
---
 CLAUDE.md                          |   5 ++
 README.md                          |  32 +++++++++
 Tiltfile                           |  55 +++++++++++++++
 airavata-django-portal/.gitignore  |   2 +
 airavata-django-portal/Dockerfile  |  44 ++++++------
 airavata-django-portal/README.md   |   7 ++
 airavata-django-portal/compose.yml |  34 ++++++++++
 devstack/README.md                 | 136 +++++++++++++++++++++++++++++++++++++
 devstack/devstack                  |  22 ++++++
 devstack/lib/certs.sh              |  13 ++++
 devstack/lib/colima.sh             |  24 +++++++
 devstack/lib/commands.sh           |  43 ++++++++++++
 devstack/lib/config.sh             |  25 +++++++
 devstack/lib/dns.sh                |  52 ++++++++++++++
 devstack/lib/ingress.sh            |  38 +++++++++++
 devstack/lib/verify.sh             |  13 ++++
 devstack/traefik/compose.yml       |  29 ++++++++
 devstack/traefik/traefik.yml       |  18 +++++
 18 files changed, 573 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8ddfc606a..f118ef7ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,6 +50,11 @@ uv run ty check                                   # Python 
type check (ty)
 ./lint_js.sh                                      # JS lint (ESLint + Prettier)
 ```
 
+Recommended: run the whole stack with Tilt — `tilt up` in the `airavata` repo,
+then `tilt up --port 10351` here (serves the portal at
+https://gateway.airavata.host). Prerequisite: `./devstack/devstack setup` 
(once)
+before first use. See the `Tiltfile` at the repo root and `devstack/README.md`.
+
 ### Django Apps
 
 Each app under `django_airavata/apps/` is self-contained with its own frontend:
diff --git a/README.md b/README.md
index a0809e3a8..aaa0947e8 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,38 @@
 
 The `airavata-portals` repository is a consolidated home for all web-based 
user interfaces built on top of the [Apache 
Airavata](https://airavata.apache.org/) middleware platform. This collection of 
frontend components and frameworks enables seamless interaction with Airavata's 
powerful orchestration, identity, data, and compute services.
 
+## Running locally with Tilt
+
+The Django portal runs as a container tenant on the shared `airavata-devstack`
+substrate (one colima VM, one Traefik ingress serving `*.airavata.host`), 
managed
+with [Tilt](https://tilt.dev). The two stacks run as separate Tilt instances.
+
+### One-time setup
+
+```bash
+# From either repo — both carry the identical devstack kit
+./devstack/devstack setup
+```
+
+This installs colima, mkcert, dnsmasq, creates the shared VM and Traefik 
ingress,
+and configures wildcard DNS for `*.airavata.host` → `127.0.0.1` (trusted cert, 
no `-k`).
+
+### Daily startup
+
+```bash
+# 1. Start the Airavata backend stack (in the apache/airavata repo)
+cd ../airavata && tilt up
+
+# 2. Start the portals (this repo) on a distinct Tilt port
+tilt up --port 10351
+```
+
+The Django portal is then served at **https://gateway.airavata.host** (trusted
+HTTPS via the shared Traefik ingress). The portal runs inside the shared 
colima VM
+as a container; `settings_local.py` is generated automatically on first `tilt 
up`
+if the file does not exist. Only the Django portal is wired into the Tiltfile 
today;
+other portals can be added as additional resources later.
+
 ## Repository Structure
 
 This repository contains the following sub-projects and templates:
diff --git a/Tiltfile b/Tiltfile
new file mode 100644
index 000000000..5e599be8d
--- /dev/null
+++ b/Tiltfile
@@ -0,0 +1,55 @@
+# -*- mode: Python -*-
+# airavata-portals tenant. Prereq: ./devstack/devstack setup (shared with 
airavata).
+# Run on a distinct Tilt port:  tilt up --port 10351
+# Tiltfiles are Starlark, not Python: no `import`; `os` is built in 
(getenv/putenv only).
+PROFILE = os.getenv('DEVSTACK_PROFILE', 'airavata')
+os.putenv('DOCKER_HOST', 'unix://%s/.colima/%s/docker.sock' % 
(os.getenv('HOME'), PROFILE))
+PORTAL = 'airavata-django-portal'
+SETTINGS = PORTAL + '/django_airavata/settings_local.py'
+SDK_SRC = '../airavata/airavata-python-sdk'
+
+# Stage the sibling airavata-python-sdk into the portal build context. It's an 
editable
+# path dependency that lives in the sibling airavata repo, outside this build 
context, so
+# the Dockerfile can't COPY it directly. This rsync mirrors it into 
.devstack-sdk/ (gitignored)
+# and re-runs when the SDK changes, which retriggers the image build.
+local_resource('stage-sdk',
+    cmd='mkdir -p %s/.devstack-sdk && rsync -a --delete --exclude .venv 
--exclude __pycache__ --exclude .git %s/ %s/.devstack-sdk/airavata-python-sdk/' 
% (PORTAL, SDK_SRC, PORTAL),
+    deps=[SDK_SRC], labels=['django-portal'])
+
+local_resource('portal-settings', cmd='''
+set -e
+f="%s"
+if [ ! -f "$f" ]; then cat > "$f" <<'EOF'
+DEBUG = True
+ALLOWED_HOSTS = ['.airavata.host', 'localhost', '127.0.0.1']
+USE_X_FORWARDED_HOST = True
+SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+CSRF_TRUSTED_ORIGINS = ['https://gateway.airavata.host']
+KEYCLOAK_CLIENT_ID = 'pga'
+KEYCLOAK_CLIENT_SECRET = 'm36BXQIxX3j3VILadeHMK5IvbOeRlCCc'
+KEYCLOAK_AUTHORIZE_URL = 
'https://auth.airavata.host/realms/default/protocol/openid-connect/auth'
+KEYCLOAK_TOKEN_URL = 
'https://auth.airavata.host/realms/default/protocol/openid-connect/token'
+KEYCLOAK_USERINFO_URL = 
'https://auth.airavata.host/realms/default/protocol/openid-connect/userinfo'
+KEYCLOAK_LOGOUT_URL = 
'https://auth.airavata.host/realms/default/protocol/openid-connect/logout'
+KEYCLOAK_VERIFY_SSL = False
+GATEWAY_ID = 'default'
+GRPC_API_HOST = 'airavata-server'
+GRPC_API_PORT = 9090
+GRPC_API_SECURE = False
+PORTAL_TITLE = 'Airavata Django Portal (devstack)'
+EOF
+fi
+''' % SETTINGS, labels=['django-portal'])
+
+local_resource('devstack-ensure', cmd='./devstack/devstack ensure',
+               resource_deps=['portal-settings'], labels=['platform'])
+
+# NOTE: no `only=` — in Tilt that restricts the build CONTEXT (not just 
rebuild triggers),
+# and the Dockerfile needs the whole portal source (django_airavata/, 
scripts/, .devstack-sdk/).
+# .dockerignore already trims node_modules/.venv/dist. Runtime source edits 
flow through the
+# bind-mount + polling reload; dep changes (uv.lock) rebuild the image.
+docker_build('airavata-django-portal:dev', PORTAL, dockerfile=PORTAL + 
'/Dockerfile')
+
+docker_compose(PORTAL + '/compose.yml')
+dc_resource('airavata-django-portal', resource_deps=['devstack-ensure', 
'stage-sdk'], labels=['django-portal'],
+            links=[link('https://gateway.airavata.host', 'Gateway Portal')])
diff --git a/airavata-django-portal/.gitignore 
b/airavata-django-portal/.gitignore
index 4824918ba..56090b860 100644
--- a/airavata-django-portal/.gitignore
+++ b/airavata-django-portal/.gitignore
@@ -21,3 +21,5 @@ yarn-error.log
 .pytest_cache/
 .ruff_cache/
 __pycache__/
+# devstack: staged copy of the sibling airavata-python-sdk (built into the 
portal image)
+.devstack-sdk/
diff --git a/airavata-django-portal/Dockerfile 
b/airavata-django-portal/Dockerfile
index e7ef0ba4a..efd548b4a 100644
--- a/airavata-django-portal/Dockerfile
+++ b/airavata-django-portal/Dockerfile
@@ -1,5 +1,6 @@
 # node image is based on Debian and includes necessary build tools
-FROM node:19.9.0 as build-stage
+# node 20 LTS — vite 6 requires ^18 || ^20 || >=22 (excludes the old 19.9.0).
+FROM node:20 as build-stage
 
 # build api javascript
 # api must come first, then common, since the others depend on these
@@ -64,26 +65,32 @@ RUN yarn run build
 
 FROM python:3.12-slim as server-stage
 
+# The portal's only Airavata dependency is the editable airavata-python-sdk, 
pinned in
+# pyproject.toml as `[tool.uv.sources] path = 
"../../airavata/airavata-python-sdk"`. uv
+# REFUSES a relative source path that escapes above the project root, so the 
app cannot
+# live at /code (depth 1). We mirror the host's repo layout instead: the 
portal at
+# /workspace/airavata-portals/airavata-django-portal and the SDK at
+# /workspace/airavata/airavata-python-sdk, so `../../airavata/...` resolves 
in-tree —
+# identically at build time, in the running container, and on the host (remote 
mode).
 ENV PYTHONUNBUFFERED=1 \
     UV_COMPILE_BYTECODE=1 \
     UV_LINK_MODE=copy \
-    PATH="/code/.venv/bin:$PATH"
+    PATH="/workspace/airavata-portals/airavata-django-portal/.venv/bin:$PATH"
 
 # uv for dependency management (https://docs.astral.sh/uv/).
 COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
 
 EXPOSE 8000
 
-WORKDIR /code
+WORKDIR /workspace/airavata-portals/airavata-django-portal
 RUN apt-get update && apt-get install -y --no-install-recommends \
         git gcc g++ zlib1g-dev libjpeg-dev && \
     rm -rf /var/lib/apt/lists/*
 
-# NOTE: the portal's only Airavata dependency is the airavata-python-sdk, 
resolved
-# from a sibling apache/airavata checkout via [tool.uv.sources] in 
pyproject.toml.
-# It is not yet on PyPI, so the build context must make it available at
-# ../../airavata/airavata-python-sdk (e.g. build from the workspace root with 
an
-# appropriate context) or the pin must point at a published release.
+# Sibling airavata-python-sdk, staged into the portal context by the 
`stage-sdk` Tilt step
+# (it lives in the sibling airavata repo, outside this build context). Placed 
two levels up
+# so the pyproject `../../airavata/airavata-python-sdk` path resolves to it.
+COPY .devstack-sdk/airavata-python-sdk /workspace/airavata/airavata-python-sdk
 COPY pyproject.toml uv.lock README.md ./
 COPY ./django_airavata ./django_airavata
 RUN uv sync --frozen --no-dev
@@ -93,23 +100,22 @@ COPY ./django_airavata/settings_local.py.sample 
./django_airavata/settings_local
 
 COPY ./ .
 
-# Copy javascript builds from build-stage
-WORKDIR /code/django_airavata/apps/api/static/django_airavata_api
+# Copy javascript builds from build-stage (sources stay at the build-stage's 
/code)
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/api/static/django_airavata_api
 COPY --from=build-stage 
/code/django_airavata/apps/api/static/django_airavata_api .
-WORKDIR /code/django_airavata/static/common/dist
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/static/common/dist
 COPY --from=build-stage /code/django_airavata/static/common/dist .
-WORKDIR /code/django_airavata/apps/admin/static/django_airavata_admin
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/admin/static/django_airavata_admin
 COPY --from=build-stage 
/code/django_airavata/apps/admin/static/django_airavata_admin .
-WORKDIR /code/django_airavata/apps/groups/static/django_airavata_groups
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/groups/static/django_airavata_groups
 COPY --from=build-stage 
/code/django_airavata/apps/groups/static/django_airavata_groups .
-WORKDIR /code/django_airavata/apps/auth/static/django_airavata_auth
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth
 COPY --from=build-stage 
/code/django_airavata/apps/auth/static/django_airavata_auth .
-WORKDIR /code/django_airavata/apps/workspace/static/django_airavata_workspace
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace
 COPY --from=build-stage 
/code/django_airavata/apps/workspace/static/django_airavata_workspace .
-WORKDIR 
/code/django_airavata/apps/dataparsers/static/django_airavata_dataparsers
+WORKDIR 
/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/dataparsers/static/django_airavata_dataparsers
 COPY --from=build-stage 
/code/django_airavata/apps/dataparsers/static/django_airavata_dataparsers .
 
-WORKDIR /code
-
-ENTRYPOINT ["/code/scripts/start-server.sh"]
+WORKDIR /workspace/airavata-portals/airavata-django-portal
 
+ENTRYPOINT 
["/workspace/airavata-portals/airavata-django-portal/scripts/start-server.sh"]
diff --git a/airavata-django-portal/README.md b/airavata-django-portal/README.md
index 62c154c46..a25abf7a9 100644
--- a/airavata-django-portal/README.md
+++ b/airavata-django-portal/README.md
@@ -27,6 +27,13 @@ any yarn commands. See
 [the Yarn package manager](https://classic.yarnpkg.com/lang/en/) for 
information
 on how to install Yarn 1 (Classic).
 
+> **Recommended (Tilt + devstack):** run the portal as a container against a
+> local Apache Airavata stack, both managed with [Tilt](https://tilt.dev).
+> One-time setup: `./devstack/devstack setup` (from the `airavata-portals` 
root).
+> Then `cd ../airavata && tilt up`, and from the `airavata-portals` repo
+> `tilt up --port 10351`. The portal is served at 
<https://gateway.airavata.host>
+> (trusted HTTPS, no `-k`). The manual steps below are the equivalent without 
Tilt.
+
 This project uses [uv](https://docs.astral.sh/uv/) for Python dependency
 management. The portal has **no database** — there is nothing to migrate, and
 all persistence goes through the Airavata gRPC API and the cache.
diff --git a/airavata-django-portal/compose.yml 
b/airavata-django-portal/compose.yml
new file mode 100644
index 000000000..6f36efd88
--- /dev/null
+++ b/airavata-django-portal/compose.yml
@@ -0,0 +1,34 @@
+name: airavata-portal
+services:
+  airavata-django-portal:
+    image: airavata-django-portal:dev
+    container_name: airavata-django-portal
+    working_dir: /workspace/airavata-portals/airavata-django-portal
+    entrypoint: []                      # override image ENTRYPOINT so 
`command` runs via uv
+    command: uv run python manage.py runserver 0.0.0.0:8000
+    environment:
+      CHOKIDAR_USEPOLLING: "true"
+      WATCHPACK_POLLING: "true"
+    volumes:
+      - ".:/workspace/airavata-portals/airavata-django-portal"   # writable 
source mount (autoreload via polling)
+      # anonymous volumes preserve image-built artifacts the host source mount 
would shadow:
+      - "/workspace/airavata-portals/airavata-django-portal/.venv"   # uv venv 
(incl. the editable SDK)
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/api/static/django_airavata_api"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/static/common/dist"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/admin/static/django_airavata_admin"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/groups/static/django_airavata_groups"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/auth/static/django_airavata_auth"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace"
+      - 
"/workspace/airavata-portals/airavata-django-portal/django_airavata/apps/dataparsers/static/django_airavata_dataparsers"
+    networks:
+      airavata-devstack: {}
+    labels:
+      - traefik.enable=true
+      - 
traefik.http.routers.airavata-gateway.rule=Host(`gateway.airavata.host`)
+      - traefik.http.routers.airavata-gateway.entrypoints=web,websecure
+      - traefik.http.routers.airavata-gateway.tls=true
+      - traefik.http.services.airavata-gateway.loadbalancer.server.port=8000
+      - traefik.docker.network=airavata-devstack
+networks:
+  airavata-devstack:
+    external: true
diff --git a/devstack/README.md b/devstack/README.md
new file mode 100644
index 000000000..e3049bf19
--- /dev/null
+++ b/devstack/README.md
@@ -0,0 +1,136 @@
+# devstack
+
+Shared dev-substrate kit. One colima VM runs all containers; a single shared 
Traefik on
+`127.0.0.1:80/443` serves `https://*.airavata.host` (mkcert-trusted) across 
per-project
+docker networks. dnsmasq maps the dev TLD to `127.0.0.1`.
+
+---
+
+## One-time prerequisites
+
+### macOS
+
+```bash
+# Install deps (idempotent via Homebrew)
+brew install colima docker dnsmasq mkcert
+
+# Trust the mkcert CA in system and NSS stores
+mkcert -install
+
+# Then run setup (writes dnsmasq drop-in, /etc/resolver, starts the colima VM 
and Traefik)
+./devstack/devstack setup   # run as your normal user — it elevates via sudo 
internally only for DNS
+```
+
+### Ubuntu / Debian Linux
+
+```bash
+sudo apt-get update && sudo apt-get install -y dnsmasq mkcert libnss3-tools
+# colima + docker for Linux must be installed separately:
+#   https://github.com/abiosoft/colima/blob/main/docs/INSTALL.md
+
+mkcert -install
+
+./devstack/devstack setup   # run as your normal user — it elevates via sudo 
internally only for DNS
+```
+
+After `setup` completes, run `tilt up` from the repo root to start the project 
services.
+
+---
+
+## Daily workflow
+
+```bash
+# First terminal — start/resume the shared substrate (idempotent, no sudo)
+./devstack/devstack ensure
+
+# Second terminal — start the project
+tilt up
+```
+
+`ensure` is also the first resource in the `Tiltfile`; `tilt up` runs it 
automatically.
+
+---
+
+## Status
+
+```bash
+./devstack/devstack status
+# profile=airavata project=airavata tld=airavata.host
+#   INFO colima [profile=airavata] is running
+#   traefik: Up 3 hours
+```
+
+---
+
+## Restart matrix
+
+| Scenario | Command | Effect |
+|----------|---------|--------|
+| Reboot (colima auto-starts) | `./devstack/devstack ensure` | Idempotent 
bring-up |
+| colima stopped manually | `colima start -p airavata` then `ensure` | 
Restores VM + ingress |
+| Stop everything | `./devstack/devstack down` | Stops ingress + colima VM; 
**global** (all projects) |
+| Full reset | `./devstack/devstack reset` | Deletes the VM; **global + 
destructive** — prompts for profile name |
+
+`down` and `reset` are both global — they affect every project sharing the 
same colima VM.
+After `reset`, re-run `./devstack/devstack setup` to recreate everything from 
scratch.
+
+---
+
+## Traefik provider decision
+
+Phase 1 uses the **docker-socket-proxy** variant (Task 1 empirically verified):
+
+- `tecnativa/docker-socket-proxy:0.3.0` advertises a modern Docker API version 
to Traefik,
+  bypassing the colima socket version mismatch (`client version 1.24 is too 
old, min 1.44`).
+- Labels on project containers are the routing source of truth (no per-service 
file fragments).
+- The file provider is also active (`traefik/dynamic/`) for the TLS store 
default cert and
+  per-project cert entries written by `ingress_register_project`.
+
+---
+
+## Config
+
+Defaults live in `~/.airavata-devstack/config.env`:
+
+```env
+DEVSTACK_PROFILE=airavata
+DEVSTACK_PROJECT=airavata
+DEVSTACK_TLD=airavata.host
+```
+
+Override any key before running `setup` or `ensure`; values are persisted on 
first `setup`.
+
+---
+
+## State directory layout
+
+```
+~/.airavata-devstack/
+  config.env                       # profile/project/tld
+  certs/
+    rootCA.pem                     # mkcert CA (copied from CAROOT)
+    airavata.host.pem              # wildcard+apex cert
+    airavata.host-key.pem
+  traefik/
+    traefik.yml                    # static config (copied from 
devstack/traefik/)
+    dynamic/
+      _tls-default.yml             # default TLS store cert
+      airavata.yml                 # per-project cert entry
+```
+
+All state is under `$HOME` — colima requires host mounts to be under the home 
directory
+(not `/tmp`). `tilt down -v` removes containers but leaves this state intact; 
only
+`devstack reset` destroys it.
+
+---
+
+## Hostnames
+
+| URL | Service |
+|-----|---------|
+| `https://api.airavata.host` | Airavata server (gRPC + REST) |
+| `https://auth.airavata.host` | Keycloak |
+| `https://rabbitmq.airavata.host` | RabbitMQ management UI |
+| `https://adminer.airavata.host` | Adminer (`--profile tools`) |
+| `https://gateway.airavata.host` | Django portal (portals repo) |
+| `db.airavata.host:3306` | MariaDB — raw TCP on host `127.0.0.1:3306` 
(connect with a DB client, not a browser) |
diff --git a/devstack/devstack b/devstack/devstack
new file mode 100755
index 000000000..41c3254dc
--- /dev/null
+++ b/devstack/devstack
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# devstack — shared dev-substrate CLI. Usage: devstack 
{setup|ensure|status|down|reset}
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+. "$HERE/lib/config.sh"
+. "$HERE/lib/colima.sh"
+. "$HERE/lib/certs.sh"
+. "$HERE/lib/dns.sh"
+. "$HERE/lib/ingress.sh"
+. "$HERE/lib/verify.sh"
+. "$HERE/lib/commands.sh"
+devstack_load_config
+
+cmd="${1:-help}"
+case "$cmd" in
+  setup)  devstack_setup ;;
+  ensure) devstack_ensure ;;
+  status) devstack_status ;;
+  down)   devstack_down ;;
+  reset)  devstack_reset ;;
+  *) echo "usage: devstack {setup|ensure|status|down|reset}"; exit 2 ;;
+esac
diff --git a/devstack/lib/certs.sh b/devstack/lib/certs.sh
new file mode 100644
index 000000000..82e47be49
--- /dev/null
+++ b/devstack/lib/certs.sh
@@ -0,0 +1,13 @@
+# devstack/lib/certs.sh — mkcert CA trust + per-project wildcard+apex cert.
+devstack_certs() {
+  command -v mkcert >/dev/null || { echo "ERROR: mkcert not installed" >&2; 
exit 1; }
+  mkcert -install   # idempotent; trusts the CA in system + NSS stores
+  cp "$(mkcert -CAROOT)/rootCA.pem" "$DEVSTACK_HOME/certs/rootCA.pem"
+  local cert="$DEVSTACK_HOME/certs/$DEVSTACK_TLD.pem"
+  local key="$DEVSTACK_HOME/certs/$DEVSTACK_TLD-key.pem"
+  if [ ! -f "$cert" ]; then
+    # wildcard does NOT cover the apex — list both SANs.
+    mkcert -cert-file "$cert" -key-file "$key" "*.$DEVSTACK_TLD" 
"$DEVSTACK_TLD"
+    echo "generated cert for *.$DEVSTACK_TLD + $DEVSTACK_TLD"
+  fi
+}
diff --git a/devstack/lib/colima.sh b/devstack/lib/colima.sh
new file mode 100644
index 000000000..d9023324b
--- /dev/null
+++ b/devstack/lib/colima.sh
@@ -0,0 +1,24 @@
+# devstack/lib/colima.sh — colima VM lifecycle for the shared substrate.
+# NOTE: no --network-address (macOS-only + root-gated); ingress is via 
127.0.0.1 forwarding.
+DEVSTACK_CPU="${DEVSTACK_CPU:-4}"
+DEVSTACK_MEM="${DEVSTACK_MEM:-8}"
+DEVSTACK_DISK="${DEVSTACK_DISK:-60}"
+
+colima_running() { colima status -p "$DEVSTACK_PROFILE" >/dev/null 2>&1; }
+
+colima_create() {
+  echo "creating colima VM '$DEVSTACK_PROFILE' (cpu=$DEVSTACK_CPU 
mem=${DEVSTACK_MEM}g disk=${DEVSTACK_DISK}g)"
+  colima start -p "$DEVSTACK_PROFILE" \
+    --cpu "$DEVSTACK_CPU" --memory "$DEVSTACK_MEM" --disk "$DEVSTACK_DISK" \
+    --mount-inotify
+}
+
+colima_start_if_stopped() {
+  if colima_running; then echo "colima '$DEVSTACK_PROFILE' already running"; 
else
+    echo "starting colima '$DEVSTACK_PROFILE'"; colima start -p 
"$DEVSTACK_PROFILE"; fi
+}
+
+colima_require() {
+  colima_running || { echo "ERROR: colima '$DEVSTACK_PROFILE' is not running. 
Run: ./devstack/devstack setup" >&2; exit 1; }
+  [ -S "$DEVSTACK_SOCK" ] || { echo "ERROR: docker socket $DEVSTACK_SOCK 
missing" >&2; exit 1; }
+}
diff --git a/devstack/lib/commands.sh b/devstack/lib/commands.sh
new file mode 100644
index 000000000..9314e3c92
--- /dev/null
+++ b/devstack/lib/commands.sh
@@ -0,0 +1,43 @@
+# devstack/lib/commands.sh — top-level verbs.
+devstack_setup() {
+  [ "$(devstack_os)" = unknown ] && { echo "unsupported OS"; exit 1; }
+  devstack_save_config
+  if [ "$(devstack_os)" = macos ]; then
+    command -v brew >/dev/null || { echo "install Homebrew first"; exit 1; }
+    brew list colima >/dev/null 2>&1 || brew install colima docker dnsmasq 
mkcert
+  else
+    sudo apt-get update -y && sudo apt-get install -y dnsmasq mkcert 
libnss3-tools
+    command -v colima >/dev/null || { echo "install colima + docker for Linux 
first"; exit 1; }
+  fi
+  colima_running || colima_create
+  devstack_certs
+  devstack_dns          # sudo steps live here
+  ingress_up
+  ingress_register_project
+  devstack_verify
+  echo "devstack setup complete — now run: tilt up"
+}
+
+devstack_ensure() {
+  colima_require
+  ingress_up
+  ingress_register_project
+  devstack_verify
+}
+
+devstack_status() {
+  echo "profile=$DEVSTACK_PROFILE project=$DEVSTACK_PROJECT tld=$DEVSTACK_TLD"
+  colima status -p "$DEVSTACK_PROFILE" 2>&1 | sed 's/^/  /' || true
+  docker ps --filter name=airavata-devstack-traefik --format '  traefik: 
{{.Status}}' 2>/dev/null || true
+}
+
+devstack_down() {  # stop the shared ingress + the VM (GLOBAL — affects all 
projects)
+  DEVSTACK_HOME="$DEVSTACK_HOME" docker compose -f "$(dirname 
"${BASH_SOURCE[0]}")/../traefik/compose.yml" down 2>/dev/null || true
+  colima stop -p "$DEVSTACK_PROFILE" || true
+}
+
+devstack_reset() {  # GLOBAL destructive — wipes ALL projects' state
+  echo "WARNING: deletes the shared VM and ALL projects' data on it."
+  read -r -p "type the profile name to confirm: " c
+  [ "$c" = "$DEVSTACK_PROFILE" ] && colima delete -p "$DEVSTACK_PROFILE" -f || 
echo "aborted"
+}
diff --git a/devstack/lib/config.sh b/devstack/lib/config.sh
new file mode 100644
index 000000000..ff9cdf8be
--- /dev/null
+++ b/devstack/lib/config.sh
@@ -0,0 +1,25 @@
+# devstack/lib/config.sh — load/persist devstack config and derive paths.
+DEVSTACK_HOME="${DEVSTACK_HOME:-$HOME/.airavata-devstack}"
+DEVSTACK_CONF="$DEVSTACK_HOME/config.env"
+
+devstack_load_config() {
+  mkdir -p "$DEVSTACK_HOME/certs" "$DEVSTACK_HOME/traefik/dynamic"
+  [ -f "$DEVSTACK_CONF" ] && . "$DEVSTACK_CONF"
+  : "${DEVSTACK_PROFILE:=airavata}"
+  : "${DEVSTACK_PROJECT:=airavata}"
+  : "${DEVSTACK_TLD:=airavata.host}"
+  DEVSTACK_SOCK="$HOME/.colima/$DEVSTACK_PROFILE/docker.sock"
+  export DOCKER_HOST="unix://$DEVSTACK_SOCK"
+  export DEVSTACK_PROFILE DEVSTACK_PROJECT DEVSTACK_TLD DEVSTACK_SOCK 
DEVSTACK_HOME
+}
+
+devstack_save_config() {
+  cat > "$DEVSTACK_CONF" <<EOF
+DEVSTACK_PROFILE=$DEVSTACK_PROFILE
+DEVSTACK_PROJECT=$DEVSTACK_PROJECT
+DEVSTACK_TLD=$DEVSTACK_TLD
+EOF
+  echo "wrote $DEVSTACK_CONF"
+}
+
+devstack_os() { case "$(uname -s)" in Darwin) echo macos;; Linux) echo linux;; 
*) echo unknown;; esac; }
diff --git a/devstack/lib/dns.sh b/devstack/lib/dns.sh
new file mode 100644
index 000000000..17561bbc2
--- /dev/null
+++ b/devstack/lib/dns.sh
@@ -0,0 +1,52 @@
+# devstack/lib/dns.sh — map *.<tld> -> 127.0.0.1 (host-aware). Run from 
`setup` (sudo).
+_dnsmasq_conf_macos() {
+  local prefix; prefix="$(brew --prefix)"
+  local d="$prefix/etc/dnsmasq.d"; mkdir -p "$d"
+  # per-project drop-in (never edits the shared main conf)
+  printf 'address=/%s/127.0.0.1\n' "$DEVSTACK_TLD" > "$d/$DEVSTACK_TLD.conf"
+  grep -q "conf-dir=$d" "$prefix/etc/dnsmasq.conf" 2>/dev/null || \
+    echo "conf-dir=$d,*.conf" >> "$prefix/etc/dnsmasq.conf"
+  sudo brew services restart dnsmasq
+  sudo mkdir -p /etc/resolver
+  printf 'nameserver 127.0.0.1\n' | sudo tee "/etc/resolver/$DEVSTACK_TLD" 
>/dev/null
+  # scrub any stale apex line in /etc/hosts so nsswitch 'files' can't shadow it
+  if grep -qE "[[:space:]]$DEVSTACK_TLD($|[[:space:]])" /etc/hosts; then
+    sudo sed -i '' "/[[:space:]]$DEVSTACK_TLD\$/d" /etc/hosts || true
+  fi
+  sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder 2>/dev/null || 
true
+}
+
+# Linux: detect the DNS manager and apply the matching recipe (-> 127.0.0.1).
+_dnsmasq_conf_linux() {
+  local d=/etc/dnsmasq.d; sudo mkdir -p "$d"
+  printf 'address=/%s/127.0.0.1\n' "$DEVSTACK_TLD" | sudo tee 
"$d/$DEVSTACK_TLD.conf" >/dev/null
+  if grep -q '^dns=dnsmasq' /etc/NetworkManager/NetworkManager.conf 
2>/dev/null; then
+    # NetworkManager's built-in dnsmasq plugin owns resolution.
+    sudo mkdir -p /etc/NetworkManager/dnsmasq.d
+    printf 'address=/%s/127.0.0.1\n' "$DEVSTACK_TLD" | \
+      sudo tee "/etc/NetworkManager/dnsmasq.d/$DEVSTACK_TLD.conf" >/dev/null
+    sudo systemctl reload NetworkManager
+  elif systemctl is-active --quiet systemd-resolved; then
+    # split-DNS: dnsmasq on a dedicated loopback; route only this TLD to it.
+    sudo sed -i 's/^#\?listen-address=.*/listen-address=127.0.0.2/' 
/etc/dnsmasq.conf 2>/dev/null || \
+      echo 'listen-address=127.0.0.2' | sudo tee -a /etc/dnsmasq.conf 
>/dev/null
+    grep -q '^bind-interfaces' /etc/dnsmasq.conf || echo 'bind-interfaces' | 
sudo tee -a /etc/dnsmasq.conf >/dev/null
+    sudo systemctl restart dnsmasq
+    sudo mkdir -p /etc/systemd/resolved.conf.d
+    printf '[Resolve]\nDNS=127.0.0.2\nDomains=~%s\n' "$DEVSTACK_TLD" | \
+      sudo tee "/etc/systemd/resolved.conf.d/$DEVSTACK_TLD.conf" >/dev/null
+    sudo systemctl restart systemd-resolved
+  else
+    echo "WARN: unknown Linux DNS manager; add 
'address=/$DEVSTACK_TLD/127.0.0.1' to dnsmasq and restart it manually" >&2
+  fi
+  # scrub stale apex /etc/hosts line
+  sudo sed -i "/[[:space:]]$DEVSTACK_TLD\$/d" /etc/hosts 2>/dev/null || true
+}
+
+devstack_dns() {
+  case "$(devstack_os)" in
+    macos) _dnsmasq_conf_macos ;;
+    linux) _dnsmasq_conf_linux ;;
+    *) echo "unsupported OS" >&2; exit 1 ;;
+  esac
+}
diff --git a/devstack/lib/ingress.sh b/devstack/lib/ingress.sh
new file mode 100644
index 000000000..bffeaa1eb
--- /dev/null
+++ b/devstack/lib/ingress.sh
@@ -0,0 +1,38 @@
+# devstack/lib/ingress.sh — shared Traefik bring-up + per-project registration.
+INGRESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../traefik" && pwd)"
+
+_dc() { DEVSTACK_HOME="$DEVSTACK_HOME" docker compose -f 
"$INGRESS_DIR/compose.yml" "$@"; }
+
+ingress_up() {
+  cp "$INGRESS_DIR/traefik.yml" "$DEVSTACK_HOME/traefik/traefik.yml"
+  # default cert for no-SNI / unknown-SNI clients
+  cat > "$DEVSTACK_HOME/traefik/dynamic/_tls-default.yml" <<EOF
+tls:
+  stores:
+    default:
+      defaultCertificate:
+        certFile: /certs/$DEVSTACK_TLD.pem
+        keyFile: /certs/$DEVSTACK_TLD-key.pem
+EOF
+  _dc up -d
+}
+
+ingress_register_project() {
+  # 1) per-project cert entry (additive; never edits another project's file)
+  cat > "$DEVSTACK_HOME/traefik/dynamic/$DEVSTACK_PROJECT.yml" <<EOF
+tls:
+  certificates:
+    - certFile: /certs/$DEVSTACK_TLD.pem
+      keyFile: /certs/$DEVSTACK_TLD-key.pem
+EOF
+  # 2) project network (idempotent) + connect Traefik to it WITH the dev 
hostname
+  #    aliases, so in-VM containers resolve <name>.$DEVSTACK_TLD to Traefik via
+  #    Docker's embedded DNS (server -> auth.airavata.host, portal -> 
auth/api, ...).
+  docker network inspect "$DEVSTACK_PROJECT-devstack" >/dev/null 2>&1 || \
+    docker network create --attachable "$DEVSTACK_PROJECT-devstack"
+  docker network connect \
+    --alias "api.$DEVSTACK_TLD"      --alias "auth.$DEVSTACK_TLD" \
+    --alias "gateway.$DEVSTACK_TLD"  --alias "rabbitmq.$DEVSTACK_TLD" \
+    --alias "adminer.$DEVSTACK_TLD" \
+    "$DEVSTACK_PROJECT-devstack" airavata-devstack-traefik 2>/dev/null || true
+}
diff --git a/devstack/lib/verify.sh b/devstack/lib/verify.sh
new file mode 100644
index 000000000..9b7e4d36b
--- /dev/null
+++ b/devstack/lib/verify.sh
@@ -0,0 +1,13 @@
+# devstack/lib/verify.sh — hard-fail gate run at the end of ensure.
+devstack_verify() {
+  local host="api.$DEVSTACK_TLD" ok=1
+  case "$(devstack_os)" in
+    macos)
+      dscacheutil -q host -a name "$host" | grep -q '127.0.0.1' || { echo "DNS 
FAIL: $host !-> 127.0.0.1 (use dscacheutil, not dig)"; ok=0; } ;;
+    linux)
+      getent hosts "$host" | grep -q '127.0.0.1' || { echo "DNS FAIL: $host 
!-> 127.0.0.1"; ok=0; } ;;
+  esac
+  nc -z -w2 127.0.0.1 443 || { echo "INGRESS FAIL: nothing on 127.0.0.1:443"; 
ok=0; }
+  [ "$ok" = 1 ] && echo "devstack verify: OK ($host -> 127.0.0.1, Traefik :443 
up)"
+  [ "$ok" = 1 ]
+}
diff --git a/devstack/traefik/compose.yml b/devstack/traefik/compose.yml
new file mode 100644
index 000000000..ef1c549b7
--- /dev/null
+++ b/devstack/traefik/compose.yml
@@ -0,0 +1,29 @@
+# Shared substrate ingress — ONE instance for all projects. Operates on 
~/.airavata-devstack.
+# Bound to 127.0.0.1 only. Cert + router fragments are dropped into 
traefik/dynamic by ensure.
+name: airavata-devstack-ingress
+services:
+  proxy:
+    image: tecnativa/docker-socket-proxy:0.3.0
+    container_name: airavata-devstack-proxy
+    environment: { CONTAINERS: 1, NETWORKS: 1, SERVICES: 1, TASKS: 1, 
ENDPOINTS: 1, INFO: 1 }
+    volumes: [ "/var/run/docker.sock:/var/run/docker.sock:ro" ]
+    networks: [ ingress ]
+    restart: unless-stopped
+  traefik:
+    # v3.7+ negotiates the Docker API version. v3.3 was stuck at the ancient 
default 1.24,
+    # which colima's daemon (min API 1.40) rejects, breaking the docker 
provider entirely.
+    # Do NOT downgrade below v3.7.
+    image: traefik:v3.7
+    container_name: airavata-devstack-traefik
+    depends_on: [ proxy ]
+    ports: [ "127.0.0.1:80:80", "127.0.0.1:443:443" ]
+    volumes:
+      - "${DEVSTACK_HOME}/traefik/traefik.yml:/etc/traefik/traefik.yml:ro"
+      - "${DEVSTACK_HOME}/traefik/dynamic:/etc/traefik/dynamic:ro"
+      - "${DEVSTACK_HOME}/certs:/certs:ro"
+    networks: [ ingress ]
+    restart: unless-stopped
+networks:
+  ingress:
+    name: airavata-devstack-ingress
+    attachable: true
diff --git a/devstack/traefik/traefik.yml b/devstack/traefik/traefik.yml
new file mode 100644
index 000000000..e35cb9f11
--- /dev/null
+++ b/devstack/traefik/traefik.yml
@@ -0,0 +1,18 @@
+# Shared Traefik static config (staged to 
~/.airavata-devstack/traefik/traefik.yml).
+# Entrypoints listen on ALL container interfaces (:80/:443); the host-side
+# 127.0.0.1 restriction lives in compose's `ports:` mapping, not here.
+entryPoints:
+  web:
+    address: ":80"
+  websecure:
+    address: ":443"
+    transport:
+      respondingTimeouts:
+        readTimeout: 0      # keep long-lived gRPC (agent bidi) streams alive 
— spec R8
+providers:
+  file:
+    directory: /etc/traefik/dynamic
+    watch: true
+  docker:
+    endpoint: "tcp://airavata-devstack-proxy:2375"
+    exposedByDefault: false

Reply via email to