diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml
index 9f53c5eb..8c7a3733 100644
--- a/.github/workflows/gradle-build.yml
+++ b/.github/workflows/gradle-build.yml
@@ -9,17 +9,27 @@ on:
- 'helm/**'
- 'docker-compose/**'
- 'structures-js/**'
- - 'webdocs/**'
+ - 'webdocs/**'
- 'website/**'
+ pull_request:
+ branches:
+ - develop
+ paths-ignore:
+ - 'helm/**'
+ - 'docker-compose/**'
+ - 'structures-js/**'
+ - 'webdocs/**'
+ - 'website/**'
+
+concurrency:
+ group: gradle-build-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
jobs:
gradle_build_and_publish:
name: Build and Publish
runs-on: ubuntu-latest
steps:
- - name: Cancel Previous Runs
- uses: styfle/cancel-workflow-action@0.12.1
-
- name: Checkout code
uses: actions/checkout@v4
@@ -34,16 +44,39 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
+ # gradle.properties holds the plain version. Effective version by event:
+ # PR -> -pr. (pullable image to test the PR)
+ # develop push -> -SNAPSHOT (development build)
+ # main push -> (release, published as-is)
+ - name: Determine build version
+ id: ver
+ run: |
+ BASE=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2)
+ BASE=${BASE%-SNAPSHOT}
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
+ VERSION="${BASE}-pr${{ github.event.pull_request.number }}.${SHORT_SHA}"
+ echo "gradle_args=-PstructuresVersion=${VERSION}" >> "$GITHUB_OUTPUT"
+ elif [[ "${{ github.ref_name }}" == "main" ]]; then
+ VERSION="${BASE}"
+ echo "gradle_args=-Prelease" >> "$GITHUB_OUTPUT"
+ else
+ VERSION="${BASE}-SNAPSHOT"
+ echo "gradle_args=" >> "$GITHUB_OUTPUT"
+ fi
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "Building version ${VERSION}"
+
- name: Build with Gradle
id: gradle_build
continue-on-error: true
env:
TESTCONTAINERS_RYUK_DISABLED: true
- run: ./gradlew build
+ run: ./gradlew build ${{ steps.ver.outputs.gradle_args }}
- name: Publish to Maven Central with JReleaser
- if: ${{ steps.gradle_build.outcome == 'success' }}
- run: ./gradlew publish && ./gradlew jreleaserDeploy --info
+ if: ${{ github.event_name == 'push' && steps.gradle_build.outcome == 'success' }}
+ run: ./gradlew publish ${{ steps.ver.outputs.gradle_args }} && ./gradlew jreleaserDeploy --info ${{ steps.ver.outputs.gradle_args }}
env:
JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.MAVEN_TOKEN_USERNAME }}
JRELEASER_MAVENCENTRAL_TOKEN: ${{ secrets.MAVEN_TOKEN_PASSWORD }}
@@ -52,9 +85,11 @@ jobs:
JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_KEY_NEW }}
JRELEASER_GPG_PASSPHRASE: ${{ secrets.GPG_KEY_PASS_NEW }}
+ # Fork and dependabot PRs do not receive the Docker Hub secrets, so only
+ # publish images for pushes and same-repo PRs.
- name: Publish to Docker Hub
- if: ${{ steps.gradle_build.outcome == 'success' }}
- run: ./gradlew bootBuildImage --publishImage
+ if: ${{ steps.gradle_build.outcome == 'success' && (github.event_name == 'push' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) }}
+ run: ./gradlew bootBuildImage --publishImage ${{ steps.ver.outputs.gradle_args }}
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
@@ -92,6 +127,10 @@ jobs:
- name: Run E2E Tests
id: run_e2e_tests
continue-on-error: true
+ env:
+ # Shell env overrides the gradle.properties env-file in compose
+ # interpolation, so e2e pulls the image this run just published
+ structuresVersion: ${{ steps.ver.outputs.version }}
run: |
cd structures-js/structures-e2e
gradle pnpmInstall
@@ -111,7 +150,7 @@ jobs:
path: gh-pages
- name: Generate Allure report
- uses: simple-elf/allure-report-action@v1.13
+ uses: simple-elf/allure-report-action@v1.15
id: allure-report
with:
allure_results: allure-results
@@ -135,7 +174,7 @@ jobs:
context: 'Test Report'
description: 'Passed'
state: 'success'
- sha: ${{ github.sha }}
+ sha: ${{ github.event.pull_request.head.sha || github.sha }}
target_url: https://mindsignited.github.io/structures/allure/${{ github.run_number }}
- name: Check If Failure
@@ -151,5 +190,3 @@ jobs:
# Optionally mark the job as failed
exit 1
-
- https://mindsignited.github.io/structures/webdocs/guide/overview.html
diff --git a/.github/workflows/structures-js-publish.yml b/.github/workflows/structures-js-publish.yml
new file mode 100644
index 00000000..28ba9600
--- /dev/null
+++ b/.github/workflows/structures-js-publish.yml
@@ -0,0 +1,96 @@
+name: Publish structures-js packages
+
+# Not wired up to automatic triggers yet — run manually from the Actions tab.
+# To enable automatic publishing, replace `on:` with:
+# on:
+# push:
+# branches:
+# - develop
+# tags:
+# - 'v*'
+on:
+ workflow_dispatch:
+
+concurrency:
+ group: structures-js-publish-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ publish:
+ name: Publish @kinotic/structures-api and @kinotic/structures-cli
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: 'https://registry.npmjs.org'
+ scope: '@kinotic'
+ cache: pnpm
+ cache-dependency-path: |
+ structures-js/structures-api/pnpm-lock.yaml
+ structures-js/structures-cli/pnpm-lock.yaml
+
+ - name: Determine version and dist-tag
+ id: ver
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ VERSION="${GITHUB_REF#refs/tags/v}"
+ DIST_TAG="latest"
+ else
+ BASE=$(node -p "require('./structures-js/structures-api/package.json').version")
+ BASE=${BASE%%-*}
+ SHORT_SHA=$(git rev-parse --short=7 HEAD)
+ VERSION="${BASE}-dev.${SHORT_SHA}"
+ DIST_TAG="dev"
+ fi
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "dist_tag=${DIST_TAG}" >> "$GITHUB_OUTPUT"
+ echo "Publishing version ${VERSION} with dist-tag ${DIST_TAG}"
+
+ - name: Set version in structures-api
+ working-directory: structures-js/structures-api
+ run: npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}"
+
+ - name: Install structures-api deps
+ working-directory: structures-js/structures-api
+ run: pnpm install --frozen-lockfile
+
+ - name: Build structures-api
+ working-directory: structures-js/structures-api
+ run: pnpm build
+
+ - name: Publish structures-api to npm
+ working-directory: structures-js/structures-api
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}"
+
+ - name: Set version and pin api dep in structures-cli
+ working-directory: structures-js/structures-cli
+ run: |
+ npm version --no-git-tag-version --allow-same-version "${{ steps.ver.outputs.version }}"
+ node -e "
+ const fs = require('fs');
+ const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ p.dependencies['@kinotic/structures-api'] = '${{ steps.ver.outputs.version }}';
+ fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
+ "
+
+ - name: Install structures-cli deps
+ working-directory: structures-js/structures-cli
+ run: pnpm install --no-frozen-lockfile
+
+ - name: Publish structures-cli to npm
+ working-directory: structures-js/structures-cli
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: pnpm publish --access public --no-git-checks --tag "${{ steps.ver.outputs.dist_tag }}"
\ No newline at end of file
diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml
new file mode 100644
index 00000000..47b3216d
--- /dev/null
+++ b/.github/workflows/version-check.yml
@@ -0,0 +1,54 @@
+name: Version Check
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ require_version_bump:
+ name: Require version bump
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Compare version with base branch
+ run: |
+ HEAD_VERSION=$(grep '^structuresVersion=' gradle.properties | cut -d= -f2)
+
+ git fetch origin "${{ github.base_ref }}" --depth=1
+ BASE_VERSION=$(git show "origin/${{ github.base_ref }}:gradle.properties" | grep '^structuresVersion=' | cut -d= -f2)
+
+ # The numeric core ignores any pre-release suffix (-SNAPSHOT, -rc1, ...)
+ HEAD_CORE=${HEAD_VERSION%%-*}
+ BASE_CORE=${BASE_VERSION%%-*}
+
+ echo "Base (${{ github.base_ref }}): ${BASE_VERSION}"
+ echo "Head: ${HEAD_VERSION}"
+
+ fail() { echo "::error::$1"; exit 1; }
+
+ if [[ "${HEAD_CORE}" == "${BASE_CORE}" ]]; then
+ if [[ "${HEAD_VERSION}" == "${BASE_VERSION}" ]]; then
+ fail "structuresVersion is still ${HEAD_VERSION}; bump it in gradle.properties before merging to ${{ github.base_ref }} (releases are immutable in Maven Central)"
+ elif [[ "${HEAD_VERSION}" == "${HEAD_CORE}" ]]; then
+ # pre-release -> release promotion of the same version (e.g. 3.6.0-rc1 -> 3.6.0)
+ echo "Version promotion OK: ${BASE_VERSION} -> ${HEAD_VERSION}"
+ elif [[ "${BASE_VERSION}" == "${BASE_CORE}" ]]; then
+ fail "structuresVersion ${HEAD_VERSION} is a pre-release of ${BASE_VERSION}, which is already released"
+ else
+ # both pre-releases of the same version: head must sort after base
+ HIGHEST=$(printf '%s\n%s\n' "${BASE_VERSION}" "${HEAD_VERSION}" | sort -V | tail -1)
+ if [[ "${HIGHEST}" != "${HEAD_VERSION}" ]]; then
+ fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}"
+ fi
+ echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}"
+ fi
+ else
+ HIGHEST=$(printf '%s\n%s\n' "${BASE_CORE}" "${HEAD_CORE}" | sort -V | tail -1)
+ if [[ "${HIGHEST}" != "${HEAD_CORE}" ]]; then
+ fail "structuresVersion ${HEAD_VERSION} is lower than ${BASE_VERSION} on ${{ github.base_ref }}"
+ fi
+ echo "Version bump OK: ${BASE_VERSION} -> ${HEAD_VERSION}"
+ fi
diff --git a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle
index c4a79e69..dede9ec5 100644
--- a/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle
+++ b/buildSrc/src/main/groovy/org.kinotic.java-application-conventions.gradle
@@ -34,18 +34,51 @@ dependencies {
// bootBuildImage task source https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/tasks/bundling/BootBuildImage.java
// Paketo options here https://paketo.io/docs/howto/java/#configure-the-jvm-at-runtime
// https://paketo.io/docs/reference/java-reference/
+
+// Full --add-opens set recommended by Apache Ignite for JDK 11+
+// https://ignite.apache.org/docs/latest/quick-start/java#running-ignite-with-java-11-or-later
+// A missing entry surfaces at runtime as InaccessibleObjectException during Ignite marshalling
+def igniteAddOpens = [
+ 'java.base/jdk.internal.access',
+ 'java.base/jdk.internal.misc',
+ 'java.base/sun.nio.ch',
+ 'java.base/sun.util.calendar',
+ 'java.management/com.sun.jmx.mbeanserver',
+ 'jdk.internal.jvmstat/sun.jvmstat.monitor',
+ 'java.base/sun.reflect.generics.reflectiveObjects',
+ 'jdk.management/com.sun.management.internal',
+ 'java.base/java.io',
+ 'java.base/java.nio',
+ 'java.base/java.net',
+ 'java.base/java.util',
+ 'java.base/java.util.concurrent',
+ 'java.base/java.util.concurrent.locks',
+ 'java.base/java.util.concurrent.atomic',
+ 'java.base/java.lang',
+ 'java.base/java.lang.invoke',
+ 'java.base/java.lang.reflect',
+ 'java.base/java.math',
+ 'java.sql/java.sql',
+ 'java.base/java.time',
+ 'java.base/java.text',
+ 'java.management/sun.management',
+ 'java.desktop/java.awt.font'
+].collect { "--add-opens=${it}=ALL-UNNAMED" }.join(' ')
+
bootBuildImage {
network = "host"
- publish = true
+ // Local builds only produce the image; CI publishes by passing --publishImage
+ publish = false
imageName = "mindsignited/${project.name}:${project.version}"
- tags = (!version.endsWith('SNAPSHOT') ?
+ // Only release versions (no -SNAPSHOT / -prN.sha suffix) may claim the latest tag
+ tags = (!version.contains('-') ?
[
"mindsignited/${project.name}:latest"
]
: [])
environment = [
"BPE_DELIM_JAVA_TOOL_OPTIONS" : " ",
- "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED"
+ "BPE_APPEND_JAVA_TOOL_OPTIONS": "-XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC ${igniteAddOpens}".toString()
]
docker {
publishRegistry {
diff --git a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle
index 7a3b3000..7e0a4a2a 100644
--- a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle
+++ b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle
@@ -5,7 +5,18 @@ plugins {
}
group 'org.kinotic'
-version "${structuresVersion}"
+
+// gradle.properties holds the plain version (e.g. 3.5.8). Development builds get
+// -SNAPSHOT appended automatically; CI passes -Prelease on main to publish the
+// version as-is (stripping a leftover -SNAPSHOT so the guard and the build agree),
+// and PR builds pass an explicit -PstructuresVersion override.
+def effectiveVersion = "${structuresVersion}"
+if (hasProperty('release')) {
+ effectiveVersion = effectiveVersion - '-SNAPSHOT'
+} else if (!effectiveVersion.contains('-')) {
+ effectiveVersion += '-SNAPSHOT'
+}
+version effectiveVersion
sourceCompatibility = '21'
repositories {
diff --git a/dev-tools/kind/KIND_IMAGE_LOADING.md b/dev-tools/kind/KIND_IMAGE_LOADING.md
index a591095b..7202e18e 100644
--- a/dev-tools/kind/KIND_IMAGE_LOADING.md
+++ b/dev-tools/kind/KIND_IMAGE_LOADING.md
@@ -1,199 +1,61 @@
# KinD Image Loading Strategy
-**Issue**: KinD clusters cannot pull images directly from Docker registries (docker.io, gcr.io, etc.) because they're isolated from the internet.
+KinD cluster nodes run containerd with normal outbound network access, so they
+**pull public registry images directly** (docker.elastic.co, docker.io, ghcr.io,
+etc.) exactly like any other Kubernetes cluster. No manual loading is needed for
+published images — the Helm charts reference the image/tag in their values files
+and the nodes pull with `imagePullPolicy: IfNotPresent`.
-**Additional Issue**: Multi-platform images (like Elasticsearch) can fail with `kind load docker-image` due to manifest format issues.
+`kind load` is only for images that exist **solely in the host Docker daemon**
+— i.e. locally built images that no registry serves:
-**Solution**:
-1. Pre-pull images on the host machine with explicit platform
-2. Use `docker save` to create a tarball
-3. Load tarball into KinD cluster using `kind load image-archive`
+| Image | Source | How it reaches the cluster |
+|-------|--------|---------------------------|
+| `mindsignited/structures-server` (local build) | `./gradlew :structures-server:bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` |
+| `mindsignited/structures-migration` (local build) | `bootBuildImage` | `kind load docker-image` + `pullPolicy: Never` |
+| Elasticsearch, Keycloak, PostgreSQL, ingress-nginx, cert-manager | Public registries | Pulled by the nodes directly |
-## Image Loading Methods
+## Loading a locally built image
-### Method 1: Direct Load (Simple Images)
-For simple single-platform images:
```bash
-docker pull docker.io/bitnami/postgresql:15.5.0
-kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster
+./gradlew :structures-server:bootBuildImage
+kind load docker-image mindsignited/structures-server: --name structures-cluster
```
-**Use for**: PostgreSQL, Keycloak, structures-server
+The structures-server values file sets `pullPolicy: Never` so the node uses the
+loaded image and never tries to pull a tag that only exists locally.
-### Method 2: Tarball Load (Multi-Platform Images)
-For complex multi-platform images:
-```bash
-# Pull for specific platform
-docker pull --platform linux/amd64 docker.elastic.co/elasticsearch/elasticsearch:8.18.1
-
-# Save to tarball
-docker save docker.elastic.co/elasticsearch/elasticsearch:8.18.1 -o /tmp/elasticsearch.tar
-
-# Load tarball into cluster
-kind load image-archive /tmp/elasticsearch.tar --name structures-cluster
-
-# Clean up
-rm /tmp/elasticsearch.tar
-```
-
-**Use for**: Elasticsearch (official Elastic images)
-
-## Why Two Methods?
-
-### Multi-Platform Image Problem
-
-Official Elasticsearch images are **multi-platform** (support amd64, arm64, etc.). When you run:
-```bash
-kind load docker-image docker.elastic.co/elasticsearch/elasticsearch:8.18.1
-```
-
-KinD tries to load the image but encounters:
-```
-ERROR: failed to load image: ctr: content digest sha256:xxx: not found
-```
-
-This happens because:
-1. Docker stores multi-platform images with **manifests** pointing to platform-specific layers
-2. `kind load docker-image` doesn't handle these manifests correctly
-3. The containerd runtime in KinD nodes can't find the referenced content
-
-### Tarball Method Fixes This
-
-Using `docker save` + `kind load image-archive`:
-1. ✅ `docker save` flattens the image into a single-platform tarball
-2. ✅ `kind load image-archive` imports the complete image with all layers
-3. ✅ No manifest issues - everything is in the tarball
-4. ✅ Works reliably for all image types
-
-## Updated Functions
-
-All deployment functions now follow this pattern:
-
-### 1. **Elasticsearch** (v8.11.1)
-```bash
-# 1. Check if image exists locally
-# 2. Pull if needed: docker pull docker.io/bitnami/elasticsearch:8.11.1
-# 3. Load into cluster: kind load docker-image --name structures-cluster
-# 4. Deploy with Helm, specifying --set image.tag=8.11.1
-```
-
-### 2. **PostgreSQL** (v15.5.0)
-```bash
-# Same pattern:
-# docker.io/bitnami/postgresql:15.5.0
-```
-
-### 3. **Keycloak** (v26.0.2)
-```bash
-# Same pattern:
-# docker.io/bitnami/keycloak:26.0.2
-```
-
-## How It Works
+## History: why Elasticsearch used to be pre-loaded
-```bash
-# Example: Loading Elasticsearch
-
-# Step 1: Pull image from Docker Hub to local Docker
-docker pull docker.io/bitnami/elasticsearch:8.11.1
-
-# Step 2: Load image into KinD cluster nodes
-kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster
-
-# Step 3: Verify image is available in cluster
-docker exec structures-cluster-control-plane crictl images | grep elasticsearch
-
-# Step 4: Deploy with Helm (image.pullPolicy defaults to IfNotPresent)
-helm upgrade --install elasticsearch bitnami/elasticsearch --set image.tag=8.11.1
-```
-
-## Benefits
-
-✅ **Predictable versions** - Pin to known working versions
-✅ **No network issues** - Images loaded from local Docker
-✅ **Faster deployments** - No waiting for image pulls
-✅ **Offline capable** - Works without internet once images are cached
-✅ **Consistent behavior** - Same images used across deployments
-
-## Version Selection Rationale
-
-| Component | Version | Reason |
-|-----------|---------|--------|
-| Elasticsearch | 8.11.1 | Stable LTS version, compatible with Structures |
-| PostgreSQL | 15.5.0 | Latest stable PG 15.x, matches production usage |
-| Keycloak | 26.0.2 | Matches docker-compose setup exactly |
+Earlier versions of these scripts pre-pulled the Elasticsearch image on the
+host, re-tagged it as `localhost/elasticsearch:`, and pushed it into
+the nodes with `kind load docker-image`. That existed to work around
+multi-platform manifest issues (`ctr: content digest ... not found`) in the
+`kind load` path — but `kind load` itself was the only reason the image needed
+to touch the host Docker daemon at all. Deploying the chart and letting the
+nodes pull the image avoids the entire problem, and also avoids version skew
+between a hardcoded pre-load tag in the script and the tag in
+`config/elasticsearch/values.yaml` (they had already drifted: 8.18.1 vs
+8.19.13).
## Troubleshooting
-### Image Pull Fails
+### Pod stuck in `ImagePullBackOff` (public image)
```bash
-# Check Docker Hub rate limits
-docker pull docker.io/bitnami/elasticsearch:8.11.1
+# Check events for the actual pull error
+kubectl get events --sort-by='.lastTimestamp' | tail -20
-# If rate limited, authenticate:
-docker login
+# Docker Hub rate limiting? Authenticate the nodes or pre-load as a workaround:
+docker pull && kind load docker-image --name structures-cluster
```
-### Image Load Fails
+### Pod stuck in `ErrImageNeverPull` (local image)
+The image was not loaded into the nodes. Re-run:
```bash
-# Check KinD cluster exists
-kind get clusters
-
-# Check cluster is running
-kubectl cluster-info --context kind-structures-cluster
-
-# Manual load test
-kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster
+kind load docker-image --name structures-cluster
```
-### Pod Still Shows ImagePullBackOff
+### Verify what images a node has
```bash
-# Check image is in cluster
docker exec structures-cluster-control-plane crictl images
-
-# Check pod image specification
-kubectl get pod -o yaml | grep image:
-
-# Make sure image.pullPolicy is not Always
-kubectl get deployment -o yaml | grep pullPolicy
```
-
-## Manual Image Loading
-
-If you need to pre-load images before deployment:
-
-```bash
-# Pre-load all dependency images
-docker pull docker.io/bitnami/elasticsearch:8.11.1
-docker pull docker.io/bitnami/postgresql:15.5.0
-docker pull docker.io/bitnami/keycloak:26.0.2
-
-kind load docker-image docker.io/bitnami/elasticsearch:8.11.1 --name structures-cluster
-kind load docker-image docker.io/bitnami/postgresql:15.5.0 --name structures-cluster
-kind load docker-image docker.io/bitnami/keycloak:26.0.2 --name structures-cluster
-
-# Verify
-docker exec structures-cluster-control-plane crictl images | grep bitnami
-```
-
-## Automated in Deploy Script
-
-The `deploy.sh` functions now automatically:
-1. Check if image exists locally (`docker image inspect`)
-2. Pull if missing (`docker pull`)
-3. Load into KinD (`kind load docker-image`)
-4. Deploy with Helm (with pinned version)
-
-No manual intervention needed! Just run:
-```bash
-./dev-tools/kind/kind-cluster.sh deploy
-```
-
-## Future Improvements
-
-Consider adding:
-- Image pre-caching script (`load-images.sh`)
-- Version configuration in `config/versions.yaml`
-- Multi-architecture support (arm64/amd64)
-- Local registry for faster loading (optional)
-
diff --git a/dev-tools/kind/config/elasticsearch/values.yaml b/dev-tools/kind/config/elasticsearch/values.yaml
index 6b1224da..d51d0c2c 100644
--- a/dev-tools/kind/config/elasticsearch/values.yaml
+++ b/dev-tools/kind/config/elasticsearch/values.yaml
@@ -9,7 +9,7 @@ replicas: 2
minimumMasterNodes: 1
image: "docker.elastic.co/elasticsearch/elasticsearch"
-imageTag: "8.18.1"
+imageTag: "8.19.13"
imagePullPolicy: IfNotPresent
# Disable all security for local dev (matching docker-compose)
diff --git a/dev-tools/kind/config/structures-server/values.yaml b/dev-tools/kind/config/structures-server/values.yaml
index f6da89e2..813303ad 100644
--- a/dev-tools/kind/config/structures-server/values.yaml
+++ b/dev-tools/kind/config/structures-server/values.yaml
@@ -32,10 +32,11 @@ nameOverride: ""
image:
# Repository matches bootBuildImage output
repository: mindsignited/structures-server
- # Tag from gradle.properties version (structuresVersion=3.5.3-SNAPSHOT)
- tag: 3.5.7
- # Never pull - use images loaded into KinD cluster
- pullPolicy: Never
+ # Published tag pulled from Docker Hub. Overridden by deploy --tag, or by
+ # deploy --build-local which uses the locally built gradle.properties version.
+ tag: 3.5.8-pr7.023aa91
+ # Pull from Docker Hub by default; deploy --build-local overrides this to Never
+ pullPolicy: IfNotPresent
sha: ""
# Migration configuration
@@ -46,9 +47,10 @@ migration:
activeDeadlineSeconds: 300
image:
repository: mindsignited/structures-migration
- tag: 3.5.7
- # Never pull - use images loaded into KinD cluster
- pullPolicy: Never
+ # Keep in sync with image.tag above; deploy --tag sets both
+ tag: 3.5.8-pr7.023aa91
+ # Pull from Docker Hub by default; deploy --build-local overrides this to Never
+ pullPolicy: IfNotPresent
sha: ""
## eviction-tracking uses the evictionTracking logic and paths for tracking eviction events
diff --git a/dev-tools/kind/kind-cluster.sh b/dev-tools/kind/kind-cluster.sh
index 0e697905..210ad546 100755
--- a/dev-tools/kind/kind-cluster.sh
+++ b/dev-tools/kind/kind-cluster.sh
@@ -294,6 +294,8 @@ cmd_deploy() {
local helm_values_override=""
local helm_sets=()
local wait_timeout_override=""
+ local build_local="0"
+ local image_tag=""
# Parse subcommand options
while [[ $# -gt 0 ]]; do
@@ -334,6 +336,14 @@ cmd_deploy() {
DEPLOY_LOAD_GENERATOR="1"
shift
;;
+ --build-local)
+ build_local="1"
+ shift
+ ;;
+ --tag)
+ image_tag="$2"
+ shift 2
+ ;;
--wait-timeout)
wait_timeout_override="$2"
shift 2
@@ -354,6 +364,10 @@ Options:
--with-keycloak, -k Deploy Keycloak + PostgreSQL and enable OIDC authentication
--with-observability Deploy observability stack (OTEL, Prometheus, Grafana)
--with-load-generator Run load generator after deployment (generates schemas/test data)
+ --build-local Build structures-server/migration images from source and load
+ them into the cluster (default: pull published images from Docker Hub)
+ --tag Image tag to pull from Docker Hub (default: tag from values file;
+ ignored with --build-local, which uses the gradle.properties version)
--wait-timeout Deployment timeout (default: 5m)
--help, -h Show this help message
@@ -383,6 +397,12 @@ Examples:
# Deploy with inline override
$(basename "$0") deploy --set replicaCount=3
+ # Deploy a specific published tag (e.g. a PR image)
+ $(basename "$0") deploy --tag 3.5.8-pr7.023aa91
+
+ # Build from source and load into the cluster instead of pulling
+ $(basename "$0") deploy --build-local
+
EOF
return "${EXIT_SUCCESS}"
;;
@@ -498,54 +518,77 @@ EOF
# Deploy structures-server
section "Deploying structures-server"
-
- # Build additional sets string
- local additional_sets=""
-
+
# Add OIDC configuration if Keycloak is deployed
if [[ "${DEPLOY_KEYCLOAK}" == "1" ]]; then
progress "Enabling OIDC authentication (oidc.enabled=true)"
helm_sets+=("--set" "oidc.enabled=true")
fi
-
- if [[ ${#helm_sets[@]} -gt 0 ]]; then
- additional_sets="${helm_sets[*]}"
- fi
- # Build and load structures-server
- cmd_build "--load"
-
- # Build and load structures-migration (used by Helm pre-upgrade hook)
- section "Building structures-migration"
- progress "Running: ./gradlew :structures-migration:bootBuildImage"
- progress "This may take a few minutes..."
- blank_line
-
- export RUNNING_KIND_CLUSTER="true"
- if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do
- if [[ "${line}" == *"BUILD"* ]] || \
- [[ "${line}" == *"Successfully built"* ]] || \
- [[ "${line}" == *"Paketo"* ]] || \
- [[ "${line}" == *"Error"* ]] || \
- [[ "${line}" == *"FAIL"* ]] || \
- [[ "${VERBOSE}" == "1" ]]; then
- echo " ${line}"
+ if [[ "${build_local}" == "1" ]]; then
+ # Opt-in: build the images from source and load them into the cluster
+ local built_version
+ built_version=$(get_structures_version) || return "${EXIT_DEPLOYMENT_FAILED}"
+ progress "Building images locally (version ${built_version})"
+ helm_sets+=("--set" "image.tag=${built_version}")
+ helm_sets+=("--set" "image.pullPolicy=Never")
+ helm_sets+=("--set" "migration.image.tag=${built_version}")
+ helm_sets+=("--set" "migration.image.pullPolicy=Never")
+
+ # Build and load structures-server
+ if ! cmd_build "--load"; then
+ return "${EXIT_DEPLOYMENT_FAILED}"
+ fi
+
+ # Build and load structures-migration (used by Helm pre-upgrade hook)
+ section "Building structures-migration"
+ progress "Running: ./gradlew :structures-migration:bootBuildImage"
+ progress "This may take a few minutes..."
+ blank_line
+
+ export RUNNING_KIND_CLUSTER="true"
+ if ! execute ./gradlew ":structures-migration:bootBuildImage" 2>&1 | while IFS= read -r line; do
+ if [[ "${line}" == *"BUILD"* ]] || \
+ [[ "${line}" == *"Successfully built"* ]] || \
+ [[ "${line}" == *"Paketo"* ]] || \
+ [[ "${line}" == *"Error"* ]] || \
+ [[ "${line}" == *"FAIL"* ]] || \
+ [[ "${VERBOSE}" == "1" ]]; then
+ echo " ${line}"
+ fi
+ done; then
+ error "Failed to build structures-migration image"
+ return "${EXIT_DEPLOYMENT_FAILED}"
+ fi
+
+ local migration_image
+ migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}"
+
+ section "Loading Migration Image into Cluster"
+ if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then
+ error "Failed to load migration image into cluster"
+ return "${EXIT_DEPLOYMENT_FAILED}"
+ fi
+ blank_line
+ else
+ # Default: cluster nodes pull the published images from Docker Hub
+ progress "Using published images from Docker Hub (use --build-local to build from source)"
+ helm_sets+=("--set" "image.pullPolicy=IfNotPresent")
+ helm_sets+=("--set" "migration.image.pullPolicy=IfNotPresent")
+ if [[ -n "${image_tag}" ]]; then
+ progress "Image tag: ${image_tag}"
+ helm_sets+=("--set" "image.tag=${image_tag}")
+ helm_sets+=("--set" "migration.image.tag=${image_tag}")
fi
- done; then
- error "Failed to build structures-migration image"
- return "${EXIT_DEPLOYMENT_FAILED}"
fi
-
- local migration_image
- migration_image=$(get_migration_image_name) || return "${EXIT_DEPLOYMENT_FAILED}"
-
- section "Loading Migration Image into Cluster"
- if ! load_image_into_cluster "${CLUSTER_NAME}" "${migration_image}"; then
- error "Failed to load migration image into cluster"
- return "${EXIT_DEPLOYMENT_FAILED}"
+
+ # Build additional sets string
+ local additional_sets=""
+ if [[ ${#helm_sets[@]} -gt 0 ]]; then
+ additional_sets="${helm_sets[*]}"
fi
- blank_line
-
+
+
if ! deploy_structures_server "${CLUSTER_NAME}" "${additional_sets}"; then
error "Deployment failed"
echo ""
diff --git a/dev-tools/kind/lib/config.sh b/dev-tools/kind/lib/config.sh
index 6d94edf6..94258c96 100755
--- a/dev-tools/kind/lib/config.sh
+++ b/dev-tools/kind/lib/config.sh
@@ -246,21 +246,35 @@ get_coredns_template_path() {
# version=$(get_structures_version)
#
get_structures_version() {
+ # Explicit override wins, e.g. deploying the released image from a release
+ # checkout: structuresVersion=3.5.8 ./kind-cluster.sh deploy
+ if [[ -n "${structuresVersion:-}" ]]; then
+ echo "${structuresVersion}"
+ return 0
+ fi
+
local gradle_props="./gradle.properties"
-
+
if [[ ! -f "${gradle_props}" ]]; then
error "gradle.properties not found"
return 1
fi
-
+
local version
version=$(grep '^structuresVersion=' "${gradle_props}" | cut -d'=' -f2)
-
+
if [[ -z "${version}" ]]; then
error "structuresVersion not found in gradle.properties"
return 1
fi
-
+
+ # Mirror the build convention: plain versions are development builds and get
+ # -SNAPSHOT appended (see org.kinotic.java-common-conventions.gradle); this
+ # matches what a local ./gradlew build on the same checkout produces
+ if [[ "${version}" != *-* ]]; then
+ version="${version}-SNAPSHOT"
+ fi
+
echo "${version}"
}
diff --git a/dev-tools/kind/lib/deploy.sh b/dev-tools/kind/lib/deploy.sh
index ebc907b9..f1f1ed51 100755
--- a/dev-tools/kind/lib/deploy.sh
+++ b/dev-tools/kind/lib/deploy.sh
@@ -720,63 +720,23 @@ deploy_elasticsearch() {
local context="kind-${cluster_name}"
progress "Deploying Elasticsearch..."
-
- # Elasticsearch version (matching docker-compose compose.ek-stack.yml)
- local es_version="8.18.1"
- local es_image="docker.elastic.co/elasticsearch/elasticsearch:${es_version}"
- local local_tag="localhost/elasticsearch:${es_version}"
-
- # Detect host platform for image pulling
- local platform="linux/amd64"
- if [[ "$(uname -m)" == "arm64" ]] || [[ "$(uname -m)" == "aarch64" ]]; then
- platform="linux/arm64"
- fi
-
- # Pre-pull image for current platform and re-tag to avoid multi-platform issues
- progress "Pre-loading Elasticsearch image into cluster..."
- if ! docker image inspect "${local_tag}" &>/dev/null; then
- progress "Pulling ${es_image} for ${platform}..."
- # Pull for specific platform
- if ! docker pull --platform "${platform}" "${es_image}"; then
- error "Failed to pull Elasticsearch image"
- return 1
- fi
-
- # Re-tag to local name to create clean single-platform reference
- progress "Re-tagging image to local reference..."
- if ! docker tag "${es_image}" "${local_tag}"; then
- error "Failed to tag Elasticsearch image"
- return 1
- fi
- fi
-
- # Load the locally-tagged image into KinD (avoids multi-platform issues)
- progress "Loading image into KinD cluster..."
- if ! kind load docker-image "${local_tag}" --name "${cluster_name}"; then
- error "Failed to load Elasticsearch image into cluster"
- return 1
- fi
-
- # Also tag in the cluster as the original name so pods can find it
- progress "Tagging image in cluster nodes..."
- for node in $(kind get nodes --name "${cluster_name}"); do
- docker exec "${node}" ctr -n k8s.io images tag "${local_tag}" "${es_image}" || true
- done
-
+
# Get Elasticsearch values file from config directory
local values_flags
values_flags=$(get_service_helm_flags "elasticsearch") || return 1
-
+
progress "Using Elasticsearch configuration from: $(get_service_values_path elasticsearch)"
-
- # Deploy using external values file
+
+ # Deploy using external values file. The image/tag come from the values file
+ # and cluster nodes pull directly from the registry; manual kind-load is only
+ # needed for locally built images. Timeout allows for the initial image pull.
local helm_output
# shellcheck disable=SC2086
helm_output=$(helm upgrade --install elasticsearch elastic/elasticsearch \
--kube-context "${context}" \
--version 8.5.1 \
${values_flags} \
- --wait --timeout 5m 2>&1)
+ --wait --timeout 10m 2>&1)
local exit_code=$?
diff --git a/gradle.properties b/gradle.properties
index ef2f06f3..2ccc3b6a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
-structuresVersion=3.5.7
+structuresVersion=3.5.8
allureVersion=2.32.0
antlrVersion=4.13.1
diff --git a/structures-frontend/src/plugins/StructuresUI.ts b/structures-frontend/src/plugins/StructuresUI.ts
index 7bf65517..71ba56f6 100644
--- a/structures-frontend/src/plugins/StructuresUI.ts
+++ b/structures-frontend/src/plugins/StructuresUI.ts
@@ -5,15 +5,18 @@ import {StructuresStates} from '@/states/index'
export function createStructuresUI(): Plugin {
return {
install(_: App, options: {router: Router}) {
- options.router.beforeEach((to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => {
+ options.router.beforeEach(async (to: RouteLocationNormalized, _: RouteLocationNormalized, next: NavigationGuardNext) => {
const { authenticationRequired } = to.meta
- if ((authenticationRequired === undefined || authenticationRequired)
- && !StructuresStates.getUserState().isAuthenticated()){
- next({ path: '/login', query: { referer: to.fullPath } })
- } else {
- next()
+ if (authenticationRequired === undefined || authenticationRequired) {
+ const userState = StructuresStates.getUserState()
+ if (!userState.isAuthenticated()
+ && !(await userState.restoreSession())) {
+ next({ path: '/login', query: { referer: to.fullPath } })
+ return
+ }
}
+ next()
})
StructuresStates.getApplicationState().initialize(options.router)
diff --git a/structures-frontend/src/states/IUserState.ts b/structures-frontend/src/states/IUserState.ts
index 8117e4a3..62ffae0e 100644
--- a/structures-frontend/src/states/IUserState.ts
+++ b/structures-frontend/src/states/IUserState.ts
@@ -1,13 +1,29 @@
import { ConnectedInfo, ConnectionInfo, Continuum } from '@kinotic/continuum-client'
import { reactive } from 'vue'
import Cookies from 'js-cookie'
-import { User } from 'oidc-client-ts'
+import { User, UserManager } from 'oidc-client-ts'
import { createDebug } from '@/util/debug'
const debug = createDebug('user-state');
import { oidcSessionManager } from '@/util/OidcSessionManager'
import { configService } from '@/util/config'
import { createConnectionInfo } from '../util/helpers'
+import { selectToken, writeTokenCookie } from '@/util/tokenCookie'
+import { createUserManagerSettings } from '@/pages/login/OidcConfiguration'
+
+// Deliberately outside the 'oidc.' prefix so AuthenticationManager.clearOidcState()
+// does not wipe it during error recovery. The provider name is not a secret; it is
+// needed to rebuild the UserManager when restoring a session after a page refresh.
+const AUTH_PROVIDER_KEY = 'auth.provider'
+
+// Prefix used by the oidc-client-ts WebStorageStateStore for the persisted user
+const OIDC_USER_KEY_PREFIX = 'oidc.user:'
+
+/**
+ * Thrown when the frontend role gate rejects an otherwise valid token. Lets the
+ * restore path tell a definitive denial apart from a transient failure.
+ */
+class FrontendRoleDeniedError extends Error {}
export interface IUserState {
connectedInfo: ConnectedInfo | null
@@ -17,6 +33,7 @@ export interface IUserState {
isAuthenticated(): boolean
authenticate(login: string, passcode: string): Promise
handleOidcLogin(user: User, provider: string): Promise
+ restoreSession(): Promise
logout(): Promise
}
@@ -25,6 +42,7 @@ export class UserState implements IUserState {
public oidcUser: User | null = null
private authenticated: boolean = false
private accessDenied: boolean = false
+ private restorePromise: Promise | null = null
public async authenticate(login: string, passcode: string): Promise {
try {
@@ -62,22 +80,111 @@ export class UserState implements IUserState {
debug('No existing connection to disconnect')
}
- const connectionInfo: ConnectionInfo = createConnectionInfo()
+ try {
+ await this.establishConnection(user, provider)
+ } catch (reason: any) {
+ this.accessDenied = true
+ if (reason instanceof Error) {
+ throw reason
+ } else if (reason) {
+ throw new Error(reason)
+ } else {
+ throw new Error('OIDC authentication failed')
+ }
+ }
+ }
- let tokenToUse = user.access_token;
+ /**
+ * Attempt to restore an OIDC session persisted by oidc-client-ts in localStorage.
+ * Called by the router guard on page refresh before redirecting to login.
+ * Resolves true if a session was restored and the Continuum connection
+ * re-established; never rejects, so the guard always reaches a decision.
+ */
+ public restoreSession(): Promise {
+ if (!this.restorePromise) {
+ this.restorePromise = this.doRestoreSession()
+ .catch(error => {
+ debug('Session restore failed unexpectedly: %O', error)
+ return false
+ })
+ .finally(() => {
+ this.restorePromise = null
+ })
+ }
+ return this.restorePromise
+ }
- if (user.access_token && !this.isValidJWT(user.access_token)) {
- debug('Access token is not a valid JWT, using ID token for Microsoft social login');
- tokenToUse = user.id_token || user.access_token;
+ private async doRestoreSession(): Promise {
+ if (this.isAuthenticated()) {
+ return true
}
- connectionInfo.connectHeaders = {
- Authorization: `Bearer ${tokenToUse}`
+ const provider = localStorage.getItem(AUTH_PROVIDER_KEY)
+ if (!provider) {
+ return false
}
try {
- this.connectedInfo = await Continuum.connect(connectionInfo)
+ // Temporary manager just to read/renew the persisted user. Renewal and
+ // session monitoring stay off so its timers never race the long-lived
+ // oidcSessionManager that takes over once the session is established.
+ const settings = await createUserManagerSettings(provider)
+ const userManager = new UserManager({
+ ...settings,
+ automaticSilentRenew: false,
+ monitorSession: false
+ })
+
+ let user = await userManager.getUser()
+ if (!user) {
+ this.clearPersistedSession()
+ return false
+ }
+
+ if (user.expired) {
+ if (!user.refresh_token) {
+ debug('Persisted OIDC session expired with no refresh token')
+ this.clearPersistedSession()
+ return false
+ }
+ debug('Persisted OIDC session expired, attempting silent renew')
+ user = await userManager.signinSilent()
+ if (!user) {
+ return false
+ }
+ }
+
+ // The backend is the source of truth: only treat the session as
+ // restored once Continuum.connect accepts the token.
+ await this.establishConnection(user, provider)
+ debug('OIDC session restored for provider %s', provider)
+ return true
+ } catch (error) {
+ debug('Session restore failed: %O', error)
+ // Only discard the persisted session on a definitive denial. Transient
+ // failures (backend restarting, network blip) keep it so the next
+ // navigation can retry the restore.
+ if (error instanceof FrontendRoleDeniedError) {
+ this.clearPersistedSession()
+ }
+ return false
+ }
+ }
+
+ /**
+ * Connect to Continuum with the user's token, enforce the frontend role gate,
+ * and persist what is needed to survive a page refresh.
+ * Shared by the initial OIDC login and session restore paths.
+ */
+ private async establishConnection(user: User, provider: string): Promise {
+ const connectionInfo: ConnectionInfo = createConnectionInfo()
+ connectionInfo.connectHeaders = {
+ Authorization: `Bearer ${selectToken(user)}`
+ }
+
+ this.connectedInfo = await Continuum.connect(connectionInfo)
+ try {
// Frontend role gate. The backend has already validated the token; this is a
// UI-only admission check using frontEndRoles configured on the OIDC provider.
const providerConfig = await configService.getOidcProviderByName(provider)
@@ -85,60 +192,57 @@ export class UserState implements IUserState {
const userRoles = this.connectedInfo.participant.roles ?? []
const hasRequiredRole = providerConfig.frontEndRoles.some(r => userRoles.includes(r))
if (!hasRequiredRole) {
- try { await Continuum.disconnect() } catch { /* best effort */ }
- this.connectedInfo = null
this.accessDenied = true
- throw new Error(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`)
+ throw new FrontendRoleDeniedError(`User does not have any required frontend role. Required one of: ${providerConfig.frontEndRoles.join(', ')}`)
}
}
- this.authenticated = true
- this.accessDenied = false
- this.oidcUser = user
-
- const useSecureCookies = window.location.protocol === 'https:'
-
- Cookies.set('token', tokenToUse, {
- sameSite: 'strict',
- secure: useSecureCookies,
- expires: new Date(user.expires_at! * 1000)
- })
-
- if (user.refresh_token) {
- const refreshExpiry = this.parseJwtExpiry(user.refresh_token)
- Cookies.set('oidc_refresh_token', user.refresh_token, {
- sameSite: 'strict',
- secure: true,
- expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
- })
- }
-
- // Initialize session manager for automatic token refresh
+ // Initialize automatic token refresh before marking the session
+ // authenticated so a failure here leaves no half-authenticated state
await oidcSessionManager.initialize(provider, async () => {
console.warn('Token refresh failed, logging out')
await this.logout()
})
- } catch (reason: any) {
- this.accessDenied = true
- if (reason) {
- throw new Error(reason)
- } else {
- throw new Error('OIDC authentication failed')
- }
+ } catch (error) {
+ try { await Continuum.disconnect() } catch { /* best effort */ }
+ this.connectedInfo = null
+ throw error
+ }
+
+ this.authenticated = true
+ this.accessDenied = false
+ this.oidcUser = user
+
+ // Best-effort persistence: a storage failure must not tear down the
+ // established session, it only costs restore-on-refresh.
+ try {
+ localStorage.setItem(AUTH_PROVIDER_KEY, provider)
+ // The token cookie is only for the GraphQL/OpenAPI playgrounds; the session
+ // itself is restored from the oidc-client-ts user store in localStorage.
+ writeTokenCookie(user)
+ } catch (error) {
+ debug('Failed to persist session state: %O', error)
}
}
/**
- * Parse the expiry date from a JWT token
+ * Remove the persisted OIDC user and provider so a page refresh cannot restore
+ * the session. Deletes the oidc-client-ts store entries by key prefix so no
+ * async config load is needed and a config failure cannot strand tokens.
*/
- private parseJwtExpiry(token: string): Date | null {
+ private clearPersistedSession(): void {
try {
- const parts = token.split('.')
- if (parts.length !== 3) return null
- const payload = JSON.parse(atob(parts[1]))
- return payload.exp ? new Date(payload.exp * 1000) : null
- } catch {
- return null
+ const keysToRemove: string[] = []
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith(OIDC_USER_KEY_PREFIX)) {
+ keysToRemove.push(key)
+ }
+ }
+ keysToRemove.forEach(key => localStorage.removeItem(key))
+ localStorage.removeItem(AUTH_PROVIDER_KEY)
+ } catch (error) {
+ debug('Failed to clear persisted session: %O', error)
}
}
@@ -154,7 +258,10 @@ export class UserState implements IUserState {
}
}
+ this.clearPersistedSession()
+
Cookies.remove('token')
+ // Legacy cleanup: refresh tokens are no longer written to cookies
Cookies.remove('oidc_refresh_token')
this.connectedInfo = null
@@ -171,22 +278,6 @@ export class UserState implements IUserState {
// Check if we have an active Continuum connection
return this.authenticated && this.connectedInfo !== null
}
-
- private isValidJWT(token: string): boolean {
- try {
- const parts = token.split('.');
- if (parts.length !== 3) {
- return false;
- }
-
- const header = JSON.parse(atob(parts[0]));
- const payload = JSON.parse(atob(parts[1]));
-
- return !!(header.alg && payload.iss && payload.aud);
- } catch (error) {
- return false;
- }
- }
}
export const USER_STATE: IUserState = reactive(new UserState())
diff --git a/structures-frontend/src/util/OidcSessionManager.ts b/structures-frontend/src/util/OidcSessionManager.ts
index d8c43d9c..b5ff65f1 100644
--- a/structures-frontend/src/util/OidcSessionManager.ts
+++ b/structures-frontend/src/util/OidcSessionManager.ts
@@ -1,6 +1,6 @@
import { User, UserManager } from 'oidc-client-ts'
import { createUserManagerSettings } from '@/pages/login/OidcConfiguration'
-import Cookies from 'js-cookie'
+import { writeTokenCookie } from '@/util/tokenCookie'
/**
* OidcSessionManager maintains a persistent UserManager instance with event listeners
@@ -40,7 +40,7 @@ class OidcSessionManager {
// Create bound handlers so we can properly remove them later
this.boundHandlers.userLoaded = (user: User) => {
console.log('Token refreshed automatically')
- this.updateCookies(user)
+ writeTokenCookie(user)
}
this.boundHandlers.silentRenewError = (error: Error) => {
@@ -96,60 +96,6 @@ class OidcSessionManager {
return this.userManager
}
- /**
- * Update cookies with fresh tokens from the refreshed user
- */
- private updateCookies(user: User): void {
- let tokenToUse = user.access_token
-
- // Some providers (like Microsoft) return opaque access tokens
- // In that case, use the ID token instead
- if (!this.isValidJWT(user.access_token) && user.id_token) {
- console.log('Access token is not a valid JWT, using ID token')
- tokenToUse = user.id_token
- }
-
- Cookies.set('token', tokenToUse, {
- sameSite: 'strict',
- secure: true,
- expires: new Date(user.expires_at! * 1000)
- })
-
- if (user.refresh_token) {
- const refreshExpiry = this.parseJwtExpiry(user.refresh_token)
- Cookies.set('oidc_refresh_token', user.refresh_token, {
- sameSite: 'strict',
- secure: true,
- expires: refreshExpiry ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
- })
- }
- }
-
- /**
- * Check if a token is a valid JWT (has 3 parts separated by dots)
- */
- private isValidJWT(token: string): boolean {
- try {
- const parts = token.split('.')
- return parts.length === 3
- } catch {
- return false
- }
- }
-
- /**
- * Parse the expiry date from a JWT token
- */
- private parseJwtExpiry(token: string): Date | null {
- try {
- const parts = token.split('.')
- if (parts.length !== 3) return null
- const payload = JSON.parse(atob(parts[1]))
- return payload.exp ? new Date(payload.exp * 1000) : null
- } catch {
- return null
- }
- }
}
export const oidcSessionManager = new OidcSessionManager()
diff --git a/structures-frontend/src/util/tokenCookie.ts b/structures-frontend/src/util/tokenCookie.ts
new file mode 100644
index 00000000..c0d794cf
--- /dev/null
+++ b/structures-frontend/src/util/tokenCookie.ts
@@ -0,0 +1,48 @@
+import Cookies from 'js-cookie'
+import { User } from 'oidc-client-ts'
+import { createDebug } from '@/util/debug'
+
+const debug = createDebug('token-cookie')
+
+/**
+ * Check if a token is a structurally valid JWT
+ */
+function isValidJWT(token: string): boolean {
+ try {
+ const parts = token.split('.')
+ if (parts.length !== 3) {
+ return false
+ }
+
+ const header = JSON.parse(atob(parts[0]))
+ const payload = JSON.parse(atob(parts[1]))
+
+ return !!(header.alg && payload.iss && payload.aud)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Pick the token to present to the backend. Some providers (like Microsoft social
+ * login) return opaque access tokens; in that case fall back to the ID token.
+ */
+export function selectToken(user: User): string {
+ if (user.access_token && !isValidJWT(user.access_token)) {
+ debug('Access token is not a valid JWT, using ID token')
+ return user.id_token || user.access_token
+ }
+ return user.access_token
+}
+
+/**
+ * Write the token cookie used by the GraphQL/OpenAPI playgrounds.
+ * The session itself is persisted by the oidc-client-ts user store, not this cookie.
+ */
+export function writeTokenCookie(user: User): void {
+ Cookies.set('token', selectToken(user), {
+ sameSite: 'strict',
+ secure: window.location.protocol === 'https:',
+ expires: new Date(user.expires_at! * 1000)
+ })
+}
diff --git a/structures-js/structures-e2e/test/setup.ts b/structures-js/structures-e2e/test/setup.ts
index ac50bc13..c4ca575f 100644
--- a/structures-js/structures-e2e/test/setup.ts
+++ b/structures-js/structures-e2e/test/setup.ts
@@ -2,9 +2,40 @@
import path from 'node:path'
// @ts-ignore
import os from 'node:os'
+// @ts-ignore
+import fs from 'node:fs'
import {StartedDockerComposeEnvironment, DockerComposeEnvironment, Wait} from 'testcontainers'
import {TestProject} from 'vitest/node.js'
+/**
+ * Load gradle.properties as the docker-compose environment, with
+ * structuresVersion resolved to the effective image tag: an explicit
+ * env override wins (CI exports a PR tag); otherwise a plain version is a
+ * development build and gets -SNAPSHOT appended to mirror
+ * org.kinotic.java-common-conventions.gradle. Passing everything through a
+ * single withEnvironment call avoids depending on compose env-file precedence.
+ */
+function composeEnvironment(): Record {
+ const env: Record = {}
+ try {
+ const content = fs.readFileSync(path.resolve('../../', 'gradle.properties'), 'utf8')
+ for (const line of content.split('\n')) {
+ const match = line.match(/^([\w.]+)=(.*)$/)
+ if (match) {
+ env[match[1]] = match[2].trim()
+ }
+ }
+ } catch {
+ // fall through to compose defaults
+ }
+ if (process.env.structuresVersion) {
+ env.structuresVersion = process.env.structuresVersion
+ } else if (env.structuresVersion && !env.structuresVersion.includes('-')) {
+ env.structuresVersion += '-SNAPSHOT'
+ }
+ return env
+}
+
let environment: StartedDockerComposeEnvironment
@@ -28,7 +59,7 @@ export async function setup(project: TestProject) {
environment = await new DockerComposeEnvironment(resolvedPath, files)
.withWaitStrategy('structures-elasticsearch', Wait.forHttp('/_cluster/health', 9200))
.withWaitStrategy('structures-server', Wait.forHttp('/health', 9090))
- .withEnvironmentFile(path.resolve('../../', 'gradle.properties'))
+ .withEnvironment(composeEnvironment())
.up(['structures-elasticsearch', 'structures-server'])
const container = environment.getContainer('structures-server')