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: + *

+ */ +@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: + *

    + *
  1. Give developers immediate visual feedback that the app is running.
  2. + *
  3. Provide a lightweight HTTP check independent of Spring Actuator + * for quick smoke-testing during CI/CD.
  4. + *
+ */ +@RestController +public class AppController { + + @GetMapping("/") + public ResponseEntity> hello() { + return ResponseEntity.ok(Map.of( + "message", "Hello from ${{ values.name }}!", + "timestamp", Instant.now().toString() + )); + } + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "ok")); + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java new file mode 100644 index 0000000..2cdff73 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/entity/SampleItem.java @@ -0,0 +1,71 @@ +package com.helios.app.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * Sample JPA entity demonstrating database connectivity. + * + *

This entity exists so the scaffolded project proves end-to-end + * database integration out of the box. Developers should replace or + * extend it with their domain models.

+ */ +@Entity +@Table(name = "sample_items") +public class SampleItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + // ── Constructors ─────────────────────────────────── + + protected SampleItem() { + // JPA requires a no-arg constructor + } + + public SampleItem(String name, String description) { + this.name = name; + this.description = description; + } + + // ── Getters & Setters ────────────────────────────── + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java new file mode 100644 index 0000000..abd2aa0 --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/java/com/helios/app/repository/SampleItemRepository.java @@ -0,0 +1,15 @@ +package com.helios.app.repository; + +import com.helios.app.entity.SampleItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link SampleItem}. + * + *

Provides CRUD operations and query derivation. Developers can add + * custom query methods following Spring Data naming conventions.

+ */ +@Repository +public interface SampleItemRepository extends JpaRepository { +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml b/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml new file mode 100644 index 0000000..b46790c --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/main/resources/application.yml @@ -0,0 +1,64 @@ +# ────────────────────────────────────────────────────────── +# Application Configuration +# ────────────────────────────────────────────────────────── +spring: + application: + name: ${{ values.name }} + + # ── JPA / Hibernate ─────────────────────────────────── + jpa: + # Validate schema against entities on startup; migrations + # should be handled by Flyway / Liquibase in production. + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + properties: + hibernate: + format_sql: true + jdbc: + time_zone: UTC + + # ── DataSource ──────────────────────────────────────── + # Environment variables are injected by the Helios Operator + # when deployed on Kubernetes. For local development the + # values fall back to sensible defaults that match the + # docker-compose.yml shipped with this project. + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:${{ values.name | replace('-', '_') }}_db} + username: ${DB_USER:postgres} + password: ${DB_PASS:postgres} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + idle-timeout: 30000 + connection-timeout: 20000 + +# ── Server ────────────────────────────────────────────── +server: + port: ${SERVER_PORT:${{ values.port }}} + +# ── Actuator (health/readiness probes for Kubernetes) ── +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + +# ── Logging ───────────────────────────────────────────── +logging: + level: + root: INFO + com.helios: DEBUG + org.hibernate.SQL: WARN diff --git a/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java b/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java new file mode 100644 index 0000000..226c9ed --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/test/java/com/helios/app/ApplicationTests.java @@ -0,0 +1,22 @@ +package com.helios.app; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Smoke test ensuring the Spring application context loads successfully. + * + *

Uses an in-memory H2 database (via the {@code test} profile) so + * the test suite runs without an external PostgreSQL instance — critical + * for CI pipelines (Tekton) where a DB may not yet be provisioned.

+ */ +@SpringBootTest +@ActiveProfiles("test") +class ApplicationTests { + + @Test + void contextLoads() { + // If the context fails to load, this test will fail. + } +} diff --git a/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml b/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml new file mode 100644 index 0000000..7a0ed2a --- /dev/null +++ b/apps/portal/examples/spring-boot-template/content/source/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +# ────────────────────────────────────────────────────────── +# Test Profile — used by CI/CD pipelines (Tekton) and local +# test runs where a real PostgreSQL instance is not available. +# ────────────────────────────────────────────────────────── +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect diff --git a/apps/portal/examples/spring-boot-template/template.yaml b/apps/portal/examples/spring-boot-template/template.yaml new file mode 100644 index 0000000..93324be --- /dev/null +++ b/apps/portal/examples/spring-boot-template/template.yaml @@ -0,0 +1,182 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: spring-boot-template + title: Java Spring Boot Template (Database-backed) + description: >- + Scaffolds a standard Java Spring Boot application (Gradle) with a PostgreSQL database. + The Helios Operator automatically provisions the database and injects + credentials (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS) into the backend pod. +spec: + owner: user:guest + type: service + + parameters: + - title: Component Information + required: + - name + - port + - dockerOrg + - repoName + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + port: + title: Port + type: number + description: The port the Spring Boot app listens on + default: 8080 + dockerOrg: + title: Docker Registry Org/User + type: string + description: Your Docker Hub username or Organization + repoName: + title: Docker Repository Name + type: string + description: The name of the Docker repository (e.g. my-service) + + - title: Database Configuration + properties: + databaseConfig: + title: Database Settings + type: object + ui:field: DatabasePicker + + - title: Repository & Webhook + required: + - repoUrl + properties: + repoUrl: + title: Source Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - localhost:3030 + - title: Optional Extras (Local Convenience) + properties: + registerToCatalog: + title: Register component in catalog + type: boolean + default: false + sendNotification: + title: Send Backstage notification + type: boolean + default: false + + steps: + # 1. Source Code + - id: fetch-source + name: Fetch Source Code + action: fetch:template + input: + url: ./content/source + targetPath: ./source + values: + # Use repoName as the app identifier to keep it Kubernetes/URL safe. + name: ${{ parameters.repoName }} + owner: ${{ user.entity.metadata.name or 'guest' }} + port: ${{ parameters.port }} + description: "Spring Boot service: ${{ parameters.name }}" + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + + - id: publish-source + name: Publish Source Code + action: publish:gitea + input: + description: Source Code for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} + sourcePath: ./source + repoVisibility: public + + - id: create-webhook + name: Create Webhook + action: gitea:create-webhook + input: + repoUrl: ${{ parameters.repoUrl }} + # Tekton EventListener address is the service root. + webhookUrl: http://el-${{ parameters.repoName }}-listener.default.svc.cluster.local:8080 + # Deterministic secret so users don't have to provide one. + # Must match the Kubernetes secret value created below. + webhookSecret: ${{ parameters.repoName }} + events: + - push + + # 2. GitOps + - id: fetch-gitops + name: Fetch GitOps Manifests + action: fetch:template + input: + url: ./content/gitops + targetPath: ./gitops + values: + name: ${{ parameters.repoName }} + image: index.docker.io/${{ parameters.dockerOrg }}/${{ parameters.repoName }} + dockerOrg: ${{ parameters.dockerOrg }} + repoName: ${{ parameters.repoName }} + port: ${{ parameters.port }} + databaseType: ${{ parameters.databaseConfig.dbType }} + databaseName: ${{ parameters.databaseConfig.dbName }} + owner: ${{ user.entity.metadata.name or 'guest' }} + sourceRepo: ${{ steps['publish-source'].output.remoteUrl }} + gitopsRepo: ${{ steps['publish-source'].output.remoteUrl | replace(".git", "") }}-gitops + testCommand: "gradle test" + + - id: publish-gitops + name: Publish GitOps Manifests + action: publish:gitea + input: + description: GitOps Manifests for ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }}-gitops + sourcePath: ./gitops + repoVisibility: public + + # 3. Secret + Deploy + - id: create-secret + name: Create Git Credentials Secret + action: kubernetes:create-git-credentials-secret + input: + name: ${{ parameters.repoName }} + namespace: default + username: ${{ (parameters.repoUrl | parseRepoUrl).owner }} + webhookSecret: ${{ parameters.repoName }} + + - id: apply-helios + name: Deploy to Kubernetes + action: kubernetes:apply + input: + manifestPath: ./gitops/helios-app.yaml + namespaced: true + + # 4. Registration + - id: register + name: Register Component + action: catalog:register + if: '{{ parameters.registerToCatalog }}' + input: + repoContentsUrl: ${{ steps['publish-source'].output.repoContentsUrl }} + catalogInfoPath: 'catalog-info.yaml' + + - id: notify + name: Notify User + action: notification:send + if: '{{ parameters.sendNotification }}' + input: + recipients: entity + entityRefs: + - user:default/guest + title: 'Spring Boot Template Executed' + info: 'Your Spring Boot application has been scaffolded with a PostgreSQL database!' + + output: + links: + - title: Source Repository + url: ${{ steps['publish-source'].output.remoteUrl }} + - title: GitOps Repository + url: ${{ steps['publish-gitops'].output.remoteUrl }} + - title: Open in Catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} diff --git a/scripts/check-prereqs.bat b/scripts/check-prereqs.bat index ac95be0..fce2b9b 100644 --- a/scripts/check-prereqs.bat +++ b/scripts/check-prereqs.bat @@ -35,6 +35,8 @@ call :check_tool "docker" "docker --version" call :check_tool "kubectl" "kubectl version --client" call :check_tool "k3d" "k3d version" call :check_tool "cue" "cue version" +call :check_optional_tool "helm" "helm version" +call :check_optional_tool "jq" "jq --version" echo. echo [Node.js / Frontend] @@ -88,11 +90,9 @@ if %CHECK_ENV% equ 1 ( ) ) - REM Check required variables - call :check_env_var "GITHUB_TOKEN" - call :check_env_var "GITHUB_USER" - call :check_env_var "AUTH_GITHUB_CLIENT_ID" - call :check_env_var "AUTH_GITHUB_CLIENT_SECRET" + REM Check required variables (Gitea-based workflow) + call :check_env_var "DOCKER_USERNAME" + call :check_env_var "DOCKER_PASSWORD" ) ) @@ -153,3 +153,14 @@ if "!val!"=="" ( echo [OK] %~1 is configured ) goto :eof + +:check_optional_tool +REM %~1 = tool name, %~2 = version command +where %~1 >nul 2>&1 +if %errorlevel% equ 0 ( + echo [OK] %~1 found ^(Optional^) +) else ( + echo [WARN] %~1 not found. ^(Optional, but recommended for Gitea setup^) + set /a WARNINGS+=1 +) +goto :eof diff --git a/scripts/check-prereqs.sh b/scripts/check-prereqs.sh index 0381d86..a715211 100755 --- a/scripts/check-prereqs.sh +++ b/scripts/check-prereqs.sh @@ -64,6 +64,18 @@ check_tool() { fi } +# --------------------------------------------------------------------------- +# Check an optional tool (only warns if missing) +# --------------------------------------------------------------------------- +check_optional_tool() { + local name="$1" hint="$2" + if ! command -v "$name" &>/dev/null; then + warn "$name not found. (Optional, but recommended for Gitea setup). Install: $hint" + else + pass "$name $(command -v "$name")" + fi +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -91,8 +103,7 @@ check_tool "cue" "" \ "cue version | head -1 | sed -n -E 's/.*v([0-9]+\.[0-9]+\.[0-9]+).*/\\1/p'" \ "go install cuelang.org/go/cmd/cue@latest" -check_tool "helm" "" \ - "helm version --short | sed -n -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\\1/p'" \ +check_optional_tool "helm" \ "https://helm.sh/docs/intro/install/" echo -e "\n${BOLD}Node.js / Frontend${NC}" @@ -105,8 +116,7 @@ check_tool "yarn" "" \ "corepack enable && corepack prepare yarn@4 --activate" echo -e "\n${BOLD}CLI Helpers${NC}" -check_tool "jq" "" \ - "jq --version | awk '{print \$1}'" \ +check_optional_tool "jq" \ "https://stedolan.github.io/jq/download/" echo -e "\n${BOLD}Runtime Checks${NC}" diff --git a/scripts/config-gitea.ps1 b/scripts/config-gitea.ps1 new file mode 100644 index 0000000..d0dfbf1 --- /dev/null +++ b/scripts/config-gitea.ps1 @@ -0,0 +1,134 @@ +param( + [string]$GiteaPort = "3030", + [string]$AdminUser = $env:GITEA_ADMIN_USER, + [string]$AdminPass = $env:GITEA_ADMIN_PASS +) + +if ([string]::IsNullOrEmpty($AdminUser)) { $AdminUser = "helios" } +if ([string]::IsNullOrEmpty($AdminPass)) { $AdminPass = "helios123" } + +$GiteaBase = "http://localhost:$GiteaPort" + +Write-Host "Cleaning up port $GiteaPort..." +Get-NetTCPConnection -LocalPort $GiteaPort -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue +} + +Write-Host "Port-forwarding Gitea for setup..." +$Job = Start-Job -ScriptBlock { + param($port) + kubectl port-forward -n gitea svc/gitea-http "${port}:3000" +} -ArgumentList $GiteaPort + +# Wait for Gitea to be available +$maxRetries = 10 +$retryCount = 0 +$connected = $false + +Write-Host "Waiting for Gitea at $GiteaBase..." +while ($retryCount -lt $maxRetries -and -not $connected) { + try { + $response = Invoke-RestMethod -Uri "$GiteaBase/api/v1/version" -Method Get -ErrorAction Stop + $connected = $true + Write-Host "Connected to Gitea version: $($response.version)" + } catch { + $retryCount++ + Write-Host "Retry ${retryCount}/${maxRetries}: Gitea not ready yet..." + Start-Sleep -Seconds 3 + } +} + +if (-not $connected) { + Stop-Job $Job + Remove-Job $Job + Write-Error "Failed to connect to Gitea via port-forward." + exit 1 +} + +# Create Auth Header +$Pair = "$($AdminUser):$($AdminPass)" +$Encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($Pair)) +$Headers = @{ + Authorization = "Basic $Encoded" + "Content-Type" = "application/json" +} + +Write-Host "Creating Gitea organization 'helios-platform'..." +try { + $body = @{ + username = "helios-platform" + full_name = "Helios Platform" + visibility = "public" + } | ConvertTo-Json + Invoke-RestMethod -Uri "$GiteaBase/api/v1/orgs" -Method Post -Headers $Headers -Body $body +} catch { + Write-Host "Organization might already exist or request failed: $_" +} + +Write-Host "Creating Gitea API token..." +$timestamp = [DateTimeOffset]::Now.ToUnixTimeSeconds() +$tokenName = "helios-platform-$timestamp" +$body = @{ + name = $tokenName + scopes = @("all") +} | ConvertTo-Json + +try { + $tokenResp = Invoke-RestMethod -Uri "$GiteaBase/api/v1/users/$AdminUser/tokens" -Method Post -Headers $Headers -Body $body + $token = $tokenResp.sha1 + if ([string]::IsNullOrEmpty($token)) { $token = $tokenResp.token } +} catch { + Write-Error "Failed to create Gitea API token: $_" + Stop-Job $Job + Remove-Job $Job + exit 1 +} + +if (-not $token) { + Write-Error "Could not extract token from response." + Stop-Job $Job + Remove-Job $Job + exit 1 +} + +Write-Host "Token created successfully." + +# Update .env files +$envFiles = @(".env", "apps/portal/.env") + +function Update-EnvVar { + param($filePath, $key, $value) + if (Test-Path $filePath) { + $lines = Get-Content $filePath + $updated = $false + $newLines = @() + foreach ($line in $lines) { + if ($line -match "^$key=") { + $newLines += "$key=$value" + $updated = $true + } else { + $newLines += $line + } + } + if (-not $updated) { + $newLines += "$key=$value" + } + $newLines | Set-Content $filePath + Write-Host "Updated $key in $filePath" + } +} + +foreach ($file in $envFiles) { + Update-EnvVar -filePath $file -key "GITEA_TOKEN" -value $token + Update-EnvVar -filePath $file -key "GITEA_USER" -value $AdminUser + Update-EnvVar -filePath $file -key "GITEA_URL" -value $GiteaBase + Update-EnvVar -filePath $file -key "GITEA_INTERNAL_URL" -value "http://gitea-http.gitea.svc.cluster.local:3000" +} + +Write-Host "=============================================" +Write-Host " Gitea configuration complete on Windows!" +Write-Host "=============================================" + +# Cleanup +Stop-Job $Job +Remove-Job $Job diff --git a/scripts/setup-argocd-creds.ps1 b/scripts/setup-argocd-creds.ps1 new file mode 100644 index 0000000..f3d2f5c --- /dev/null +++ b/scripts/setup-argocd-creds.ps1 @@ -0,0 +1,23 @@ +param( + [string]$GiteaInternalHost = "gitea-http.gitea.svc.cluster.local:3000" +) + +$user = if ($env:GITEA_ADMIN_USER) { $env:GITEA_ADMIN_USER } else { "helios" } +$pass = if ($env:GITEA_ADMIN_PASS) { $env:GITEA_ADMIN_PASS } else { "helios123" } + +$secret = @" +apiVersion: v1 +kind: Secret +metadata: + name: gitea-repo-creds + namespace: argocd + labels: + argocd.argoproj.io/secret-type: repo-creds +stringData: + type: git + url: http://$GiteaInternalHost + username: "$user" + password: "$pass" +"@ + +$secret | kubectl apply -f - diff --git a/scripts/setup-credentials.ps1 b/scripts/setup-credentials.ps1 new file mode 100644 index 0000000..c95b4e5 --- /dev/null +++ b/scripts/setup-credentials.ps1 @@ -0,0 +1,29 @@ +$dockerUser = $env:DOCKER_USERNAME +$dockerPass = $env:DOCKER_PASSWORD +$dockerServer = $env:DOCKER_SERVER +$dockerEmail = $env:DOCKER_EMAIL + +if ([string]::IsNullOrEmpty($dockerUser) -or [string]::IsNullOrEmpty($dockerPass)) { + Write-Error "DOCKER_USERNAME and DOCKER_PASSWORD must be set in .env" + exit 1 +} + +if ([string]::IsNullOrEmpty($dockerServer)) { $dockerServer = "https://index.docker.io/v1/" } +if ([string]::IsNullOrEmpty($dockerEmail)) { $dockerEmail = "dev@helios.io" } + +Write-Host "Creating docker-registry secret..." +kubectl create secret docker-registry docker-credentials ` + --docker-server=$dockerServer ` + --docker-username=$dockerUser ` + --docker-password=$dockerPass ` + --docker-email=$dockerEmail ` + --dry-run=client -o yaml | kubectl apply -f - + +Write-Host "Patching pipeline service account..." +$sa = kubectl get sa pipeline -n default -o name 2>$null +if ($LASTEXITCODE -eq 0) { + kubectl patch sa pipeline -p '{"secrets": [{"name": "docker-credentials"}]}' + Write-Host "Patched pipeline service account with docker-credentials" +} else { + Write-Host "pipeline ServiceAccount not found yet; skipping patch (will be created by Tekton)" +} diff --git a/scripts/setup-gitops-creds.ps1 b/scripts/setup-gitops-creds.ps1 new file mode 100644 index 0000000..da2c7f9 --- /dev/null +++ b/scripts/setup-gitops-creds.ps1 @@ -0,0 +1,32 @@ +param( + [string]$GiteaInternalHost = "gitea-http.gitea.svc.cluster.local:3000", + [string]$SecretName = "helios-gitops-bot" +) + +$user = if ($env:GITOPS_GIT_USER) { $env:GITOPS_GIT_USER } elseif ($env:GITEA_BOT_USER) { $env:GITEA_BOT_USER } elseif ($env:GITEA_ADMIN_USER) { $env:GITEA_ADMIN_USER } else { "helios" } +$pass = if ($env:GITOPS_GIT_PASSWORD) { $env:GITOPS_GIT_PASSWORD } elseif ($env:GITEA_BOT_PASSWORD) { $env:GITEA_BOT_PASSWORD } elseif ($env:GITEA_ADMIN_PASS) { $env:GITEA_ADMIN_PASS } else { "helios123" } + +kubectl create secret generic "$SecretName" --type=kubernetes.io/basic-auth --from-literal=username="$user" --from-literal=password="$pass" --dry-run=client -o yaml | kubectl apply -f - +kubectl annotate secret "$SecretName" "tekton.dev/git-0=http://$GiteaInternalHost" --overwrite + +# PowerShell env update logic +$envFiles = @(".env", "apps/portal/.env") +foreach ($file in $envFiles) { + if (Test-Path $file) { + $content = Get-Content $file + $updated = $false + $newLines = @() + foreach ($line in $content) { + if ($line -match "^GITOPS_SECRET_REF=") { + $newLines += "GITOPS_SECRET_REF=$SecretName" + $updated = $true + } else { + $newLines += $line + } + } + if (-not $updated) { + $newLines += "GITOPS_SECRET_REF=$SecretName" + } + $newLines | Set-Content $file + } +} diff --git a/scripts/start-portal.ps1 b/scripts/start-portal.ps1 index aebf1f8..5d8cc21 100644 --- a/scripts/start-portal.ps1 +++ b/scripts/start-portal.ps1 @@ -1,38 +1,89 @@ param( - [string]$ArgocdPort = "8080" + [string]$ArgocdPort = "8080", + [string]$GiteaLocalPort = "3030", + [string]$ProxyPort = "8001" ) -# Generate ArgoCD auth token -try { - $passB64 = kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' +$ErrorActionPreference = "Stop" +$Jobs = @() + +function Cleanup { + Write-Host "[Stop] Stopping background processes..." -ForegroundColor Yellow + foreach ($job in $Jobs) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -ErrorAction SilentlyContinue + } + # Also kill any lingering kubectl port-forwards we might have started + Get-Process -Name "kubectl" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "port-forward" -or $_.CommandLine -match "proxy" } | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Host "[Done] Cleanup complete." -ForegroundColor Green +} + +# Ensure cleanup on exit +trap { Cleanup; exit } + +Write-Host "[Gitea] Starting Gitea Port-Forward (localhost:$GiteaLocalPort)..." -ForegroundColor Yellow +$Jobs += Start-Job -ScriptBlock { kubectl port-forward -n gitea svc/gitea-http "${using:GiteaLocalPort}:3000" } + +Write-Host "[Proxy] Starting Kubectl Proxy (localhost:$ProxyPort)..." -ForegroundColor Yellow +$Jobs += Start-Job -ScriptBlock { kubectl proxy --port="${using:ProxyPort}" } + +# ArgoCD Token Automation +$argocdToken = "" +$adminSecret = kubectl -n argocd get secret argocd-initial-admin-secret -o json 2>$null | ConvertFrom-Json +if ($null -ne $adminSecret) { + Write-Host "[ArgoCD] Fetching Admin Password..." -ForegroundColor Yellow + $passB64 = $adminSecret.data.password $pass = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($passB64)).Trim() - $body = @{ username = "admin"; password = $pass } | ConvertTo-Json -Compress + Write-Host "[ArgoCD] Starting Port-Forward (localhost:$ArgocdPort)..." -ForegroundColor Yellow + $Jobs += Start-Job -ScriptBlock { kubectl port-forward -n argocd svc/argocd-server "${using:ArgocdPort}:443" } + + # Wait for PF to be ready + Start-Sleep -Seconds 3 - # Limit TLS bypass scope to the local ArgoCD login request. - $previousValidationCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback try { + $previousValidationCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } - - $parsed = Invoke-RestMethod -Uri "https://127.0.0.1:${ArgocdPort}/api/v1/session" ` - -Method Post ` - -ContentType "application/json" ` - -Body $body - } - finally { + + $body = @{ username = "admin"; password = $pass } | ConvertTo-Json -Compress + $parsed = Invoke-RestMethod -Uri "https://127.0.0.1:${ArgocdPort}/api/v1/session" -Method Post -ContentType "application/json" -Body $body -ErrorAction Stop + + if ($parsed.token) { + $env:ARGOCD_AUTH_TOKEN = $parsed.token + Write-Host "[ArgoCD] Token Generated!" -ForegroundColor Green + } + } catch { + Write-Host "[ArgoCD] Warning: Could not generate token: $_" -ForegroundColor Yellow + } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $previousValidationCallback } +} else { + Write-Host "[ArgoCD] Info: Admin secret not found. Skipping token generation." -ForegroundColor Yellow +} - if ($parsed.token) { - $env:ARGOCD_AUTH_TOKEN = $parsed.token - Write-Output "ArgoCD token generated." - } else { - Write-Warning "Could not generate ArgoCD token. ArgoCD features may not work." +# Load Environment Variables from .env +$envFile = "../../.env" +if (Test-Path $envFile) { + Write-Host "[Env] Loading variables from $envFile" -ForegroundColor Yellow + Get-Content $envFile | Where-Object { $_ -match "=" -and $_ -notmatch "^#" } | ForEach-Object { + $parts = $_.Split('=', 2) + if ($parts.Count -eq 2) { + $key = $parts[0].Trim() + $value = $parts[1].Trim() + + # Use ASCII codes for quotes to avoid encoding issues: [char]34 is ", [char]39 is ' + $value = $value.Trim([char]34).Trim([char]39) + + if ($key) { + [System.Environment]::SetEnvironmentVariable($key, $value) + } + } } -} catch { - Write-Warning "Could not generate ArgoCD token. ArgoCD features may not work." - Write-Error "ArgoCD token request failed: $_" } -# Start Backstage -yarn start +Write-Host "[Portal] Starting Backstage Portal..." -ForegroundColor Green +try { + yarn start +} finally { + Cleanup +}