feat(platform): add production CI and containers

This commit is contained in:
sechmachine
2026-08-15 22:45:35 +07:00
parent b13c547068
commit cea4378f56
17 changed files with 567 additions and 14 deletions
@@ -12,8 +12,9 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
/**
* Defines form authentication, role-based Admin routes, CSRF protection, and response security headers.
* Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization completes.
* Defines form authentication, role-based Admin routes, public health probes, CSRF protection, and response
* security headers. Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization
* completes.
*/
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@@ -34,7 +35,7 @@ class SecurityConfiguration {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
"/actuator/health")
"/actuator/health", "/actuator/health/**")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
@@ -44,7 +44,8 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/assets/")
|| path.equals("/actuator/health") || path.startsWith("/actuator/health/")
|| path.startsWith("/assets/")
|| path.equals("/error");
}
}
+53
View File
@@ -0,0 +1,53 @@
# Production profile. Supply every LAB_* value from the deployment environment.
spring:
datasource:
url: "${LAB_DB_URL}"
username: "${LAB_DB_USERNAME}"
password: "${LAB_DB_PASSWORD}"
flyway:
enabled: true
locations: classpath:db/migration
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
docker:
compose:
enabled: false
lifecycle:
timeout-per-shutdown-phase: 30s
server:
port: 8080
shutdown: graceful
forward-headers-strategy: "${LAB_FORWARD_HEADERS_STRATEGY}"
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict
error:
include-message: never
include-stacktrace: never
management:
endpoints:
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: never
probes:
enabled: true
group:
liveness:
include: "livenessState"
readiness:
include: "readinessState,db"
lab:
public-origin: "${LAB_PUBLIC_ORIGIN}"
security:
master-key: "${LAB_SECURITY_MASTER_KEY}"
+6
View File
@@ -10,3 +10,9 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
management:
endpoint:
health:
probes:
enabled: true
@@ -61,6 +61,8 @@ class BootstrapIntegrationTest {
void rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk());
mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/bootstrap"));
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const read = (path) => readFileSync(resolve(root, path), "utf8");
test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => {
const dockerfile = read("Dockerfile");
const compose = read("compose.yaml");
const environment = read(".env.compose.example");
const production = read("src/main/resources/application-prod.yaml");
assert.match(dockerfile, /FROM node:24-alpine@sha256:/);
assert.match(dockerfile, /FROM eclipse-temurin:25-jre-alpine@sha256:/);
assert.match(dockerfile, /USER 10001:10001/);
assert.match(dockerfile, /HEALTHCHECK .*health\/readiness/);
assert.match(compose, /profiles: \["bundled-db"\]/);
assert.match(compose, /condition: service_healthy/);
assert.match(compose, /required: false/);
assert.match(compose, /postgres_data:\s*$/m);
assert.match(environment, /^LAB_DB_URL=/m);
assert.match(environment, /^LAB_IMAGE=/m);
assert.match(production, /same-site: strict/);
assert.match(production, /include: "readinessState,db"/);
});
test("verification workflow checks every pull request and pushed branch without write permission", () => {
const workflow = read(".gitea/workflows/verify.yml");
assert.match(workflow, /pull_request:/);
assert.match(workflow, /push:/);
assert.match(workflow, /contents: read/);
assert.match(workflow, /npm ci/);
assert.match(workflow, /npm run test:ui/);
assert.match(workflow, /npm run build/);
assert.match(workflow, /\.\/mvnw -B test/);
assert.doesNotMatch(workflow, /permissions:\s*write-all/);
});
test("container workflow publishes only main and makes native ARM64 explicitly optional", () => {
const workflow = read(".gitea/workflows/container.yml");
assert.match(workflow, /runs-on: ubuntu-latest-arm/);
assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/);
assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-amd64/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-arm64/);
assert.match(workflow, /imagetools create/);
assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i);
});