diff --git a/Taskfile.yml b/Taskfile.yml
index ae9338c..c10cc34 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -227,15 +227,15 @@ tasks:
--set persistence.size=1Gi
--set resources.requests.memory=128Mi
--set resources.limits.memory=256Mi
- platforms: [linux, darwin]
- echo "Waiting for Gitea deployment..."
- kubectl rollout status deployment/gitea -n gitea --timeout=300s
setup:gitea-token:
desc: Create Gitea API token, default org, and write to .env
cmds:
- - echo "Port-forwarding Gitea for setup..."
+ - echo "Configuring Gitea (Cross-platform)..."
- cmd: |
+ echo "Port-forwarding Gitea for setup..."
kubectl port-forward -n gitea svc/gitea-http {{.GITEA_PORT}}:3000 &
PF_PID=$!
sleep 3
@@ -294,6 +294,8 @@ tasks:
kill $PF_PID 2>/dev/null || true
platforms: [linux, darwin]
+ - cmd: powershell -ExecutionPolicy Bypass -File scripts/config-gitea.ps1 -GiteaPort {{.GITEA_PORT}}
+ platforms: [windows]
setup:argocd-gitea:
desc: Register Gitea repository credentials with ArgoCD
@@ -318,6 +320,8 @@ tasks:
password: "$GITEA_ADMIN_PASS"
EOF
platforms: [linux, darwin]
+ - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-argocd-creds.ps1 -GiteaInternalHost {{.GITEA_INTERNAL_HOST}}
+ platforms: [windows]
- echo "ArgoCD can now access Gitea repositories."
setup:gitea-gitops-secret:
@@ -353,9 +357,10 @@ tasks:
for f in "$ENV_FILE" "$PORTAL_ENV"; do
update_env_var "$f" "GITOPS_SECRET_REF" "$GITOPS_SECRET_NAME"
done
-
- echo "Created secret '$GITOPS_SECRET_NAME' and wrote GITOPS_SECRET_REF to .env files."
platforms: [linux, darwin]
+ - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-gitops-creds.ps1 -GiteaInternalHost {{.GITEA_INTERNAL_HOST}} -SecretName {{.GITOPS_SECRET_NAME}}
+ platforms: [windows]
+ - echo "Created secret '{{.GITOPS_SECRET_NAME}}' and wrote GITOPS_SECRET_REF to .env files."
setup:crds:
desc: Install Helios CRDs into the cluster
@@ -395,30 +400,25 @@ tasks:
echo "DOCKER_USERNAME and DOCKER_PASSWORD must be set in .env" >&2
exit 1
fi
- platforms: [linux, darwin]
- - cmd: |
- powershell -Command "if ([string]::IsNullOrEmpty($env:DOCKER_USERNAME) -or [string]::IsNullOrEmpty($env:DOCKER_PASSWORD)) { Write-Error 'DOCKER_USERNAME and DOCKER_PASSWORD must be set in .env'; exit 1 }"
- platforms: [windows]
- - cmd: |
+
+ echo "Creating Docker registry secret..."
+ DOCKER_SERVER="${DOCKER_SERVER:-https://index.docker.io/v1/}"
+ DOCKER_EMAIL="${DOCKER_EMAIL:-dev@helios.io}"
+
kubectl create secret docker-registry docker-credentials \
- --docker-server=${DOCKER_SERVER:-https://index.docker.io/v1/} \
- --docker-username=$DOCKER_USERNAME \
- --docker-password=$DOCKER_PASSWORD \
- --docker-email=${DOCKER_EMAIL:-dev@helios.io} \
+ --docker-server="$DOCKER_SERVER" \
+ --docker-username="$DOCKER_USERNAME" \
+ --docker-password="$DOCKER_PASSWORD" \
+ --docker-email="$DOCKER_EMAIL" \
--dry-run=client -o yaml | kubectl apply -f -
- platforms: [linux, darwin]
- - cmd: |
- powershell -Command "$server = if ([string]::IsNullOrEmpty($env:DOCKER_SERVER)) { 'https://index.docker.io/v1/' } else { $env:DOCKER_SERVER }; $email = if ([string]::IsNullOrEmpty($env:DOCKER_EMAIL)) { 'dev@helios.io' } else { $env:DOCKER_EMAIL }; kubectl create secret docker-registry docker-credentials --docker-server=$server --docker-username=$env:DOCKER_USERNAME --docker-password=$env:DOCKER_PASSWORD --docker-email=$email --dry-run=client -o yaml | kubectl apply -f -"
- platforms: [windows]
- - cmd: |
+
if kubectl get sa pipeline >/dev/null 2>&1; then
kubectl patch sa pipeline -p '{"secrets": [{"name": "docker-credentials"}]}'
else
echo "pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)"
fi
platforms: [linux, darwin]
- - cmd: |
- powershell -Command "kubectl get sa pipeline *> $null; if ($LASTEXITCODE -eq 0) { kubectl patch sa pipeline -p '{\"secrets\": [{\"name\": \"docker-credentials\"}]}' } else { Write-Host 'pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)' }"
+ - cmd: powershell -ExecutionPolicy Bypass -File scripts/setup-credentials.ps1
platforms: [windows]
setup:portal-deps:
@@ -457,8 +457,9 @@ tasks:
dev:portal:
desc: Run the Backstage portal with ArgoCD + kubectl proxy
+ dir: apps/portal
cmds:
- - cmd: cd apps/portal && ./start-dev.sh
+ - cmd: ./start-dev.sh
platforms: [linux, darwin]
- cmd: powershell -ExecutionPolicy Bypass -File ../../scripts/start-portal.ps1 -ArgocdPort {{.ARGOCD_PORT}}
platforms: [windows]
diff --git a/apps/portal/app-config.yaml b/apps/portal/app-config.yaml
index 65be732..9e6ea62 100644
--- a/apps/portal/app-config.yaml
+++ b/apps/portal/app-config.yaml
@@ -132,9 +132,14 @@ catalog:
rules:
- allow: [Template]
+ # Spring Boot Template
+ - type: file
+ target: ../../examples/spring-boot-template/template.yaml
+
# PostgREST Template - Instant REST API over PostgreSQL
- type: file
target: ../../examples/postgrest-template/template.yaml
+
rules:
- allow: [Template]
diff --git a/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml b/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml
new file mode 100644
index 0000000..b2e83ce
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/gitops/helios-app.yaml
@@ -0,0 +1,34 @@
+apiVersion: app.helios.io/v1alpha1
+kind: HeliosApp
+metadata:
+ name: ${{ values.name }}
+ namespace: default
+spec:
+ owner: ${{ values.owner }}
+ description: "Spring Boot service: ${{ values.name }}"
+ gitRepo: ${{ values.sourceRepo }}
+ gitBranch: main
+ imageRepo: ${{ values.image }}
+ gitopsRepo: ${{ values.gitopsRepo }}
+ gitopsPath: ${{ values.name }}
+ pipelineName: from-code-to-cluster
+ webhookSecret: git-credentials-${{ values.name }}
+ port: ${{ values.port }}
+ testCommand: "gradle test"
+ components:
+ - name: ${{ values.name }}
+ type: web-service
+ properties:
+ image: ${{ values.image }}:latest
+ port: ${{ values.port }}
+ replicas: 1
+ traits:
+ - type: service
+ properties:
+ port: ${{ values.port }}
+ - type: database
+ properties:
+ dbType: ${{ values.databaseType | default("postgres") }}
+ dbName: ${{ values.name | replace('-', '_') }}_db
+ version: "16"
+ storage: "1Gi"
diff --git a/apps/portal/examples/spring-boot-template/content/source/.env.example b/apps/portal/examples/spring-boot-template/content/source/.env.example
new file mode 100644
index 0000000..ecf85c5
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/.env.example
@@ -0,0 +1,9 @@
+# Database credentials (injected by Helios Operator in Kubernetes)
+DB_HOST=localhost
+DB_PORT=5432
+DB_NAME=${{ values.name | replace('-', '_') }}_db
+DB_USER=postgres
+DB_PASS=postgres
+
+# Application
+SERVER_PORT=${{ values.port }}
diff --git a/apps/portal/examples/spring-boot-template/content/source/.gitignore b/apps/portal/examples/spring-boot-template/content/source/.gitignore
new file mode 100644
index 0000000..b52d75f
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/.gitignore
@@ -0,0 +1,27 @@
+# ──────────────────────────────────────────────────────
+# .gitignore — Spring Boot + Gradle
+# ──────────────────────────────────────────────────────
+
+# Gradle
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!gradle/wrapper/gradle-wrapper.properties
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.vscode/
+.project
+.classpath
+.settings/
+out/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Environment
+.env
diff --git a/apps/portal/examples/spring-boot-template/content/source/Dockerfile b/apps/portal/examples/spring-boot-template/content/source/Dockerfile
new file mode 100644
index 0000000..9c9218e
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/Dockerfile
@@ -0,0 +1,59 @@
+# ──────────────────────────────────────────────────────────
+# Multi-stage Dockerfile for Spring Boot (Gradle)
+# ──────────────────────────────────────────────────────────
+# Stage 1 — Build
+# Uses the official Gradle image with JDK 21. We copy sources
+# into the container and run a full Gradle build. The resulting
+# fat JAR is extracted for the next stage.
+#
+# Stage 2 — Runtime
+# Uses a minimal Eclipse Temurin JRE-only Alpine image to keep
+# the final image small (~200 MB vs ~800 MB with a full JDK).
+#
+# Kaniko Compatibility
+# This Dockerfile avoids features that are problematic for
+# Kaniko (e.g. mounting secrets, multi-platform builds).
+# The build is fully self-contained.
+# ──────────────────────────────────────────────────────────
+
+# ---- Build Stage ----
+FROM gradle:8.13-jdk21 AS builder
+
+WORKDIR /workspace
+
+# Copy Gradle wrapper & build scripts first to leverage Docker
+# layer caching — dependencies are only re-downloaded when these
+# files change.
+COPY build.gradle settings.gradle ./
+COPY gradle ./gradle/
+
+# Download dependencies in a separate layer for caching
+RUN gradle dependencies --no-daemon || true
+
+# Copy source code and build
+COPY src ./src/
+RUN gradle bootJar --no-daemon -x test
+
+# ---- Production Stage ----
+FROM eclipse-temurin:21-jre-alpine AS production
+
+# Create a non-root user for security
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+WORKDIR /app
+
+# Copy the fat JAR from the builder stage
+COPY --from=builder /workspace/build/libs/app.jar ./app.jar
+
+# Switch to non-root user
+USER appuser
+
+EXPOSE ${{ values.port }}
+
+# Use exec form so the JVM receives signals (SIGTERM) correctly
+# for graceful shutdown in Kubernetes.
+ENTRYPOINT ["java", \
+ "-XX:+UseContainerSupport", \
+ "-XX:MaxRAMPercentage=75.0", \
+ "-Djava.security.egd=file:/dev/./urandom", \
+ "-jar", "app.jar"]
diff --git a/apps/portal/examples/spring-boot-template/content/source/build.gradle b/apps/portal/examples/spring-boot-template/content/source/build.gradle
new file mode 100644
index 0000000..cc8b32c
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/build.gradle
@@ -0,0 +1,43 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.4.5'
+ id 'io.spring.dependency-management' version '1.1.7'
+}
+
+group = 'com.helios'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // Spring Boot starters
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ // PostgreSQL driver
+ runtimeOnly 'org.postgresql:postgresql'
+
+ // Testing
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ testRuntimeOnly 'com.h2database:h2'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+// Produce a reproducible, plain JAR name for the Dockerfile
+bootJar {
+ archiveFileName = 'app.jar'
+}
diff --git a/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml b/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml
new file mode 100644
index 0000000..d45d6f9
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/catalog-info.yaml
@@ -0,0 +1,18 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: ${{ values.name }}
+ description: ${{ values.description | dump }}
+ annotations:
+ gitea.org/repo-url: http://localhost:3030/${{ values.owner }}/${{ values.name }}
+ backstage.io/techdocs-ref: dir:.
+ backstage.io/kubernetes-id: ${{ values.name }}
+ backstage.io/kubernetes-label-selector: app.kubernetes.io/name=${{ values.name }}
+ backstage.io/kubernetes-namespace: default
+ janus-idp.io/tekton: ${{ values.name }}
+ tekton.dev/ci-cd: "true"
+ argocd/app-name: ${{ values.name }}-argocd
+spec:
+ type: service
+ lifecycle: production
+ owner: ${{ values.owner }}
diff --git a/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml b/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml
new file mode 100644
index 0000000..352aa8b
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/docker-compose.yml
@@ -0,0 +1,35 @@
+# ──────────────────────────────────────────────────────────
+# docker-compose.yml — Local Development Environment
+# ──────────────────────────────────────────────────────────
+# Usage:
+# docker compose up -d # Start PostgreSQL
+# ./gradlew bootRun # Start the Spring Boot app
+# docker compose down -v # Tear down (remove volumes)
+#
+# This file starts ONLY the database. The Spring Boot app is
+# expected to run on the host (via IDE or Gradle) so that hot
+# reload and debugging work seamlessly.
+# ──────────────────────────────────────────────────────────
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: ${{ values.name }}-postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${{ values.name | replace('-', '_') }}_db
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ pgdata:
+ driver: local
diff --git a/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties b/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/apps/portal/examples/spring-boot-template/content/source/settings.gradle b/apps/portal/examples/spring-boot-template/content/source/settings.gradle
new file mode 100644
index 0000000..c0474ca
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = '${{ values.name }}'
diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java
new file mode 100644
index 0000000..81cacc0
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/Application.java
@@ -0,0 +1,22 @@
+package com.helios.app;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Entry point for the ${{ values.name }} Spring Boot application.
+ *
+ *
The {@code @SpringBootApplication} annotation enables:
+ *
+ * - Component scanning within the {@code com.helios.app} package
+ * - Auto-configuration of Spring Data JPA, Actuator, etc.
+ * - Property source resolution from {@code application.yml}
+ *
+ */
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+}
diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java
new file mode 100644
index 0000000..fd75ff6
--- /dev/null
+++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/controller/AppController.java
@@ -0,0 +1,35 @@
+package com.helios.app.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Root controller providing basic health-check and greeting endpoints.
+ *
+ * These endpoints serve two purposes:
+ *
+ * - Give developers immediate visual feedback that the app is running.
+ * - Provide a lightweight HTTP check independent of Spring Actuator
+ * for quick smoke-testing during CI/CD.
+ *
+ */
+@RestController
+public class AppController {
+
+ @GetMapping("/")
+ public ResponseEntity