diff --git a/applicationFE/scripts/test-builtin-helm.mjs b/applicationFE/scripts/test-builtin-helm.mjs
new file mode 100644
index 0000000..c1884c1
--- /dev/null
+++ b/applicationFE/scripts/test-builtin-helm.mjs
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { ref, computed, watch, nextTick } from 'vue'
+import ts from 'typescript'
+const source = await readFile(new URL('../src/views/softwareCatalog/components/applicationInstallationForm.vue', import.meta.url), 'utf8')
+function between(start, end) {
+ const first = source.indexOf(start), last = source.indexOf(end, first)
+ assert.ok(first >= 0 && last > first)
+ return source.slice(first, last)
+}
+const code = ts.transpileModule([
+ between('const isBuiltInPersistentCatalog =', 'const isJupyterObjectStorageCatalog ='),
+ between('const supportsStorageClassConfig =', 'const objectStorageEndpointPlaceholder ='),
+ between('function buildK8sAdditionalConfig()', 'function validateStorageClassSelection()'),
+ 'return { isBuiltInPersistentCatalog, supportsStorageClassConfig, storageClassRequired, storageClassErrorMessage, buildK8sAdditionalConfig, applyBuiltInPersistentDefaults };'
+].join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
+function harness() {
+ const state = Object.fromEntries(Object.entries({
+ selectInfra: 'K8S', selectedCatalogInfo: {}, selectedCatalogChartName: '', isJupyterObjectStorageCatalog: false,
+ isLokiCatalog: false, ingressData: { ingressEnabled: true }, hpaData: { hpaEnabled: true, hpaMinReplicas: 3 },
+ workloadRebalancingEnabled: true, selectedStorageClass: 'standard', storageClassList: [{name: 'standard'}],
+ storageClassLoading: false, storageClassLoadError: false, storageClassFailure: '', notebookStorageGi: 10,
+ selectedStorageMinimum: 1, modalTitle: 'Application Installation', showObjectStorageConfig: false, objectStorageData: {enabled:false}
+ }).map(([key,value]) => [key, ref(value)]))
+ const env = {...state, computed, watch, hasCatalogCapability: () => false,
+ _: {isEmpty: value => !value?.length}, buildObjectStorageConfig: () => {throw new Error('Unexpected object storage')}}
+ return {...state,...new Function(...Object.keys(env),code)(...Object.values(env))}
+}
+let cases=0
+for (const app of ['redis','mariadb','postgresql','apache','tomcat']) {
+ for (const target of ['VM','K8S']) {
+ const h=harness()
+ h.selectInfra.value=target
+ h.selectedCatalogChartName.value=app
+ h.selectedCatalogInfo.value={helmChart:{chartName:app,repositoryName:'mcmp-builtin',chartRepositoryUrl:'classpath:helm',chartVersion:'0.1.0',packageId:'mcmp-builtin-'+app}}
+ await nextTick()
+ const persistent=target==='K8S' && ['redis','mariadb','postgresql'].includes(app)
+ assert.equal(h.isBuiltInPersistentCatalog.value,persistent)
+ assert.equal(h.storageClassRequired.value,persistent)
+ if (persistent) {
+ assert.equal(h.ingressData.value.ingressEnabled,false)
+ assert.equal(h.hpaData.value.hpaEnabled,false)
+ assert.equal(h.workloadRebalancingEnabled.value,false)
+ assert.deepEqual(h.buildK8sAdditionalConfig(),{storageClass:'standard',storageSize:'10Gi',storageAccessMode:'ReadWriteOnce'})
+ for (const size of [0,-1,1.5,10000]) {
+ h.notebookStorageGi.value=size
+ assert.match(h.storageClassErrorMessage.value,/whole-number/)
+ cases++
+ }
+ h.notebookStorageGi.value=20; h.selectedStorageMinimum.value=20
+ assert.equal(h.storageClassErrorMessage.value,'')
+ h.notebookStorageGi.value=10
+ assert.match(h.storageClassErrorMessage.value,/20/)
+ h.selectedStorageClass.value=''
+ assert.match(h.storageClassErrorMessage.value,/StorageClass/)
+ h.selectedCatalogInfo.value.helmChart.packageId='custom'
+ assert.equal(h.isBuiltInPersistentCatalog.value,false)
+ } else {
+ assert.equal(h.ingressData.value.ingressEnabled,true)
+ assert.equal(h.hpaData.value.hpaEnabled,true)
+ assert.equal(h.buildK8sAdditionalConfig(),undefined)
+ }
+ cases++
+ }
+}
+console.log(`Built-in Helm form passed (${cases} scenarios plus storage and identity checks).`)
diff --git a/applicationFE/scripts/test-vm-clustering.mjs b/applicationFE/scripts/test-vm-clustering.mjs
index 7a9a9b7..70061d7 100644
--- a/applicationFE/scripts/test-vm-clustering.mjs
+++ b/applicationFE/scripts/test-vm-clustering.mjs
@@ -90,7 +90,8 @@ const submitStart = form.indexOf('const runInstall = async () => {') + 'const ru
const guardEnd = form.indexOf("\n if (modalTitle.value === 'Application Installation' && (specCheckFlag", submitStart)
assert.ok(guardEnd > submitStart)
const submitGuard = new Function('modalTitle', 'selectInfra', 'selectDeploymentType', 'canSelectClustering', 'toast',
- 'const deploying = { value: false }, deploymentCompleted = { value: false };\n' + transpile(form.slice(submitStart, guardEnd)) + '\nreturn "continue";')
+ // The separate provider guard is unrelated to clustering eligibility.
+ 'const deploying = { value: false }, deploymentCompleted = { value: false }, jupyterInstallationUnsupported = { value: false };\n' + transpile(form.slice(submitStart, guardEnd)) + '\nreturn "continue";')
const installation = { value: 'Application Installation' }
let errors = 0
assert.equal(submitGuard(installation, { value: 'VM' }, { value: 'Clustering' }, { value: false }, { error: () => errors++ }), undefined)
diff --git a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue
index b92fdcc..80b6675 100644
--- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue
+++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue
@@ -549,10 +549,11 @@
{{ storageCreating ? 'Creating...' : 'Create NHN StorageClass' }}
-
-
Notebook volume capacity (GiB)
+
+
{{ isJupyterObjectStorageCatalog ? 'Notebook' : 'Data' }} volume capacity (GiB)
Minimum {{ selectedStorageMinimum }} GiB for the known disk limits. Access mode: ReadWriteOnce. Provider quotas and disk availability are checked during provisioning.
+
Single instance with generated credentials in Secret <release-name>-auth (key: password). Data PVC and credentials are retained after uninstall; remove them separately when no longer needed.
@@ -577,6 +578,7 @@
class="form-check-input"
type="checkbox"
id="hpaEnabled"
+ :disabled="isBuiltInPersistentCatalog"
v-model="hpaData.hpaEnabled">
Enable HPA (Horizontal Pod Autoscaler)
@@ -645,6 +647,7 @@
class="form-check-input"
type="checkbox"
id="workloadRebalancingEnabled"
+ :disabled="isBuiltInPersistentCatalog"
v-model="workloadRebalancingEnabled">
Enable Workload Rebalancing
@@ -662,10 +665,12 @@
class="form-check-input"
type="checkbox"
id="ingressEnabled"
+ :disabled="isBuiltInPersistentCatalog"
v-model="ingressData.ingressEnabled">
Enable Ingress
+ This TCP application uses an internal ClusterIP Service, not HTTP Ingress. Use an authenticated port-forward for access from your PC.
@@ -2192,6 +2197,22 @@ const STORAGE_CLASS_CAPABILITY = 'storage-class'
const CONFIG_CAPABILITY_REF_TYPES = ['CAPABILITY', 'TAG']
const isLokiCatalog = computed(() => selectedCatalogChartName.value === 'loki')
+const isBuiltInPersistentCatalog = computed(() => {
+ const chart = selectedCatalogInfo.value?.helmChart
+ return selectInfra.value === 'K8S' && chart?.repositoryName === 'mcmp-builtin'
+ && chart?.chartRepositoryUrl === 'classpath:helm' && chart?.chartVersion === '0.1.0'
+ && chart?.packageId === 'mcmp-builtin-' + selectedCatalogChartName.value
+ && ['redis', 'mariadb', 'postgresql'].includes(selectedCatalogChartName.value)
+})
+function applyBuiltInPersistentDefaults() {
+ if (!isBuiltInPersistentCatalog.value) return
+ ingressData.value.ingressEnabled = false
+ hpaData.value.hpaEnabled = false
+ hpaData.value.hpaMinReplicas = 1
+ hpaData.value.hpaMaxReplicas = 1
+ workloadRebalancingEnabled.value = false
+}
+watch(isBuiltInPersistentCatalog, applyBuiltInPersistentDefaults)
const isJupyterObjectStorageCatalog = computed(() => {
const packageName = String(selectedCatalogInfo.value?.packageInfo?.packageName || '').toLowerCase()
return packageName.includes('jupyter') && hasObjectStorageCapability(selectedCatalogInfo.value as SoftwareCatalog)
@@ -2208,13 +2229,14 @@ const jupyterInstallationUnsupported = computed(() => {
const supportsStorageClassConfig = computed(() => {
if (selectInfra.value !== 'K8S') return false
if (isJupyterObjectStorageCatalog.value) return true
+ if (isBuiltInPersistentCatalog.value) return true
if (!selectedCatalogInfo.value?.helmChart) return false
if (!isLokiCatalog.value) return false
return hasCatalogCapability(selectedCatalogInfo.value, STORAGE_CLASS_CAPABILITY)
})
const storageClassRequired = computed(() => {
- return supportsStorageClassConfig.value && (isLokiCatalog.value || isJupyterObjectStorageCatalog.value)
+ return supportsStorageClassConfig.value && (isLokiCatalog.value || isJupyterObjectStorageCatalog.value || isBuiltInPersistentCatalog.value)
})
const showStorageClassConfig = computed(() => {
@@ -2242,6 +2264,8 @@ const storageClassErrorMessage = computed(() => {
if (_.isEmpty(selectedStorageClass.value)) return 'This application requires a StorageClass.'
if (isJupyterObjectStorageCatalog.value && (!Number.isInteger(notebookStorageGi.value) || notebookStorageGi.value < selectedStorageMinimum.value))
return 'Enter a whole-number notebook capacity of at least ' + selectedStorageMinimum.value + ' GiB.'
+ if (isBuiltInPersistentCatalog.value && (!Number.isInteger(notebookStorageGi.value) || notebookStorageGi.value < selectedStorageMinimum.value || notebookStorageGi.value > 9999))
+ return 'Enter a whole-number volume capacity between ' + selectedStorageMinimum.value + ' and 9999 GiB.'
return ''
})
@@ -2369,7 +2393,7 @@ function buildK8sAdditionalConfig() {
const config = {} as Record
if (storageClassRequired.value && !_.isEmpty(selectedStorageClass.value)) {
config.storageClass = selectedStorageClass.value
- if (isJupyterObjectStorageCatalog.value) {
+ if (isJupyterObjectStorageCatalog.value || isBuiltInPersistentCatalog.value) {
config.storageSize = notebookStorageGi.value + 'Gi'
config.storageAccessMode = 'ReadWriteOnce'
}
@@ -2463,6 +2487,7 @@ const onChangeCatalog = async () => {
ingressTlsEnabled: Boolean(catalogInfo.ingressTlsEnabled),
ingressTlsSecret: catalogInfo.ingressTlsSecret || ''
}
+ applyBuiltInPersistentDefaults()
if (selectInfra.value === 'K8S' && isJupyterObjectStorageCatalog.value) {
ingressData.value.ingressEnabled = true
hpaData.value.hpaEnabled = false
diff --git a/scripts/archive/README.md b/scripts/archive/README.md
new file mode 100644
index 0000000..0d29659
--- /dev/null
+++ b/scripts/archive/README.md
@@ -0,0 +1,48 @@
+# Rebuild daily archive summaries
+
+The native-query aliases must match `DailyAggregationProjection` properties exactly.
+With Spring Data JPA 3.2, snake_case aliases could return a null `sampleCount` and
+cause every scheduled daily aggregation to be skipped even while raw metrics accumulated.
+
+Fixing the query repairs future runs; it does not automatically fill historical gaps.
+`backfill-daily-metrics.sql` rebuilds only selected deployments and completed days
+from **real retained measurements**. It never synthesizes measurements or deletes raw data.
+It uses the current scheduler's formulas, including 10 minutes per sample and its
+inclusive 23:59:59 cutoff. Days without raw samples are not invented or overwritten.
+
+1. Back up the database (`pg_dump -Fc`) and verify the backup's contents.
+2. Use the same timezone as the AM scheduler. Review IDs and the retained date range.
+3. Preview with an authenticated local PostgreSQL connection:
+
+ ```sh
+ psql -X -v deployment_ids=7,11,22,24 \
+ -v start_date=2026-09-15 -v end_date=2026-09-20 \
+ -f scripts/archive/backfill-daily-metrics.sql
+ ```
+
+4. Repeat with `-v apply=true` to commit. The default is a rollback. Repeating the
+ committed operation updates the same summaries rather than creating duplicates.
+ A rollback may still advance PostgreSQL sequence values; gaps in IDs are harmless.
+5. Re-run **policy recommendation analysis** for each deployment through the normal
+ authenticated `POST /api/applications/{deploymentId}/policy-recommendation/analyze`
+ endpoint. It recomputes the 90-, 30-, and 7-day results. Otherwise the UI may still
+ show an older stored analysis. Keep existing analysis rows as historical records.
+6. Check `daily_metrics_summary`, the operation-profile API, and the next scheduled
+ run (02:00 aggregation; 02:10 analysis in the server timezone).
+
+A valid analysis day currently needs at least 72 samples, at least 720 running
+minutes, and a CPU or memory P95 value. Fewer than seven valid days still correctly
+results in insufficient data after repair.
+
+## Verification
+
+`bash scripts/archive/test-backfill.sh` runs PostgreSQL 16 integration tests in a
+disposable Docker container with no external network, published ports, or persistent
+data volume. It covers preview rollback, repeatable updates, CPU/memory/network/event
+values, null metrics, the 72-sample threshold, date/deployment scoping, source-data
+preservation, and invalid-input rejection. The temporary test container is removed
+on exit. It requires the `postgres:16-alpine` image to be available or downloadable.
+
+`bash gradlew test --tests '*ResourceMetricsHistoryRepositoryTest'` tests the actual
+Spring Data native projection against isolated in-memory data, including every
+projection field, empty/single-sample data and 71/72/137/144-sample cases.
diff --git a/scripts/archive/backfill-daily-metrics.sql b/scripts/archive/backfill-daily-metrics.sql
new file mode 100644
index 0000000..5bb058e
--- /dev/null
+++ b/scripts/archive/backfill-daily-metrics.sql
@@ -0,0 +1,99 @@
+-- PostgreSQL / psql only. Back up the database first.
+-- Required variables: deployment_ids (comma-separated IDs), start_date, end_date.
+-- end_date is inclusive and MUST be before CURRENT_DATE (database/server timezone).
+-- Dry-run by default; add -v apply=true only after reviewing the result.
+-- Rebuilds derived daily summaries only. Raw metrics, events and logs are untouched.
+\set ON_ERROR_STOP on
+\if :{?apply}
+\else
+ \set apply false
+\endif
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+SET LOCAL statement_timeout = '60s';
+CREATE TEMP TABLE archive_backfill_scope ON COMMIT DROP AS
+SELECT string_to_array(:'deployment_ids', ',')::bigint[] AS ids,
+ :'start_date'::date AS first_day, :'end_date'::date AS last_day;
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM archive_backfill_scope
+ WHERE cardinality(ids) IS NULL OR cardinality(ids) = 0
+ OR first_day IS NULL OR last_day IS NULL
+ OR first_day > last_day OR last_day >= CURRENT_DATE) THEN
+ RAISE EXCEPTION 'Provide deployment IDs and a valid completed-day range';
+ END IF;
+ IF EXISTS (SELECT 1 FROM archive_backfill_scope s, unnest(s.ids) AS target(id)
+ WHERE NOT EXISTS (SELECT 1 FROM deployment_history d WHERE d.id = target.id)) THEN
+ RAISE EXCEPTION 'Unknown deployment ID';
+ END IF;
+END $$;
+
+WITH raw AS (
+ SELECT m.deployment_id, m.recorded_at::date AS summary_date,
+ avg(cpu_usage_pct) AS avg_cpu_pct, max(cpu_usage_pct) AS max_cpu_pct,
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY cpu_usage_pct) AS p95_cpu_pct,
+ stddev(cpu_usage_pct) AS stddev_cpu,
+ avg(memory_usage_pct) AS avg_memory_pct, max(memory_usage_pct) AS max_memory_pct,
+ percentile_cont(0.95) WITHIN GROUP (ORDER BY memory_usage_pct) AS p95_memory_pct,
+ stddev(memory_usage_pct) AS stddev_memory,
+ avg(network_in_bytes) AS avg_network_in_bytes, max(network_in_bytes) AS max_network_in_bytes,
+ avg(network_out_bytes) AS avg_network_out_bytes, max(network_out_bytes) AS max_network_out_bytes,
+ count(*)::integer AS sample_count,
+ (count(*) FILTER (WHERE oom_killed = true))::integer AS oom_count,
+ (count(*) FILTER (WHERE status = 'RUNNING') * 10)::integer AS running_minutes,
+ (count(*) * 10)::integer AS total_minutes, min(resource_type) AS resource_type
+ FROM resource_metrics_history m CROSS JOIN archive_backfill_scope s
+ WHERE m.deployment_id = ANY(s.ids)
+ AND m.recorded_at >= s.first_day AND m.recorded_at < s.last_day + 1
+ -- Match the current scheduler's inclusive 23:59:59 upper bound exactly.
+ AND m.recorded_at <= m.recorded_at::date + time '23:59:59'
+ GROUP BY m.deployment_id, m.recorded_at::date
+), events AS (
+ SELECT e.deployment_id, e.occurred_at::date AS summary_date,
+ (count(*) FILTER (WHERE event_type = 'OOM_KILLED'))::integer AS oom_count,
+ (count(*) FILTER (WHERE event_type = 'RESTART'))::integer AS restart_count,
+ (count(*) FILTER (WHERE event_type = 'CRASH_LOOP'))::integer AS crash_loop_count
+ FROM abnormal_event e CROSS JOIN archive_backfill_scope s
+ WHERE e.deployment_id = ANY(s.ids)
+ AND e.occurred_at >= s.first_day AND e.occurred_at < s.last_day + 1
+ AND e.occurred_at <= e.occurred_at::date + time '23:59:59'
+ GROUP BY e.deployment_id, e.occurred_at::date
+)
+INSERT INTO daily_metrics_summary (
+ deployment_id, summary_date, avg_cpu_pct, max_cpu_pct, p95_cpu_pct, stddev_cpu,
+ avg_memory_pct, max_memory_pct, p95_memory_pct, stddev_memory,
+ avg_network_in_bytes, max_network_in_bytes, avg_network_out_bytes, max_network_out_bytes,
+ sample_count, oom_count, restart_count, crash_loop_count, running_minutes, total_minutes,
+ resource_type, created_at
+)
+SELECT r.deployment_id, r.summary_date, avg_cpu_pct, max_cpu_pct, p95_cpu_pct, stddev_cpu,
+ avg_memory_pct, max_memory_pct, p95_memory_pct, stddev_memory,
+ avg_network_in_bytes, max_network_in_bytes, avg_network_out_bytes, max_network_out_bytes,
+ sample_count, r.oom_count + coalesce(e.oom_count, 0), coalesce(e.restart_count, 0),
+ coalesce(e.crash_loop_count, 0), running_minutes, total_minutes, resource_type, localtimestamp
+ FROM raw r LEFT JOIN events e USING (deployment_id, summary_date)
+ON CONFLICT (deployment_id, summary_date) DO UPDATE SET
+ avg_cpu_pct = excluded.avg_cpu_pct, max_cpu_pct = excluded.max_cpu_pct,
+ p95_cpu_pct = excluded.p95_cpu_pct, stddev_cpu = excluded.stddev_cpu,
+ avg_memory_pct = excluded.avg_memory_pct, max_memory_pct = excluded.max_memory_pct,
+ p95_memory_pct = excluded.p95_memory_pct, stddev_memory = excluded.stddev_memory,
+ avg_network_in_bytes = excluded.avg_network_in_bytes, max_network_in_bytes = excluded.max_network_in_bytes,
+ avg_network_out_bytes = excluded.avg_network_out_bytes, max_network_out_bytes = excluded.max_network_out_bytes,
+ sample_count = excluded.sample_count, oom_count = excluded.oom_count,
+ restart_count = excluded.restart_count, crash_loop_count = excluded.crash_loop_count,
+ running_minutes = excluded.running_minutes, total_minutes = excluded.total_minutes,
+ resource_type = excluded.resource_type;
+
+SELECT d.deployment_id, min(summary_date) AS first_day, max(summary_date) AS last_day,
+ count(*) AS summary_days, sum(sample_count) AS samples,
+ count(*) FILTER (WHERE sample_count >= 72 AND running_minutes >= 720
+ AND (p95_cpu_pct IS NOT NULL OR p95_memory_pct IS NOT NULL)) AS valid_days
+ FROM daily_metrics_summary d CROSS JOIN archive_backfill_scope s
+ WHERE d.deployment_id = ANY(s.ids) AND summary_date BETWEEN s.first_day AND s.last_day
+ GROUP BY d.deployment_id ORDER BY d.deployment_id;
+\if :apply
+ COMMIT;
+\else
+ ROLLBACK;
+ \echo 'DRY RUN ONLY: summaries rolled back. Sequences may advance; no source records changed.'
+\endif
diff --git a/scripts/archive/test-backfill.sh b/scripts/archive/test-backfill.sh
new file mode 100644
index 0000000..d265b07
--- /dev/null
+++ b/scripts/archive/test-backfill.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# Integration test against an isolated, disposable PostgreSQL 16 instance.
+# No host ports, production credentials, persistent volumes or external network.
+set -euo pipefail
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+test_container=$(docker run -d --rm --network none \
+ --tmpfs /var/lib/postgresql/data \
+ -e POSTGRES_HOST_AUTH_METHOD=trust postgres:16-alpine)
+trap 'docker rm -f "$test_container" >/dev/null' EXIT
+for attempt in {1..30}; do
+ if docker exec "$test_container" pg_isready -U postgres >/dev/null 2>&1; then break; fi
+ sleep 1
+done
+sql() { docker exec -i "$test_container" psql -X -v ON_ERROR_STOP=1 -U postgres "$@"; }
+backfill() {
+ sql -v deployment_ids=1,2,3 -v start_date=2000-01-01 -v end_date=2000-01-01 "$@" \
+ < "$script_dir/backfill-daily-metrics.sql"
+}
+sql <<'SQL'
+CREATE TABLE deployment_history (id bigint PRIMARY KEY);
+INSERT INTO deployment_history VALUES (1), (2), (3), (4);
+CREATE TABLE resource_metrics_history (
+ deployment_id bigint, recorded_at timestamp, cpu_usage_pct double precision,
+ memory_usage_pct double precision, network_in_bytes bigint, network_out_bytes bigint,
+ oom_killed boolean, status text, resource_type text
+);
+CREATE TABLE abnormal_event (deployment_id bigint, occurred_at timestamp, event_type text);
+CREATE TABLE daily_metrics_summary (
+ deployment_id bigint, summary_date date, avg_cpu_pct double precision,
+ max_cpu_pct double precision, p95_cpu_pct double precision, stddev_cpu double precision,
+ avg_memory_pct double precision, max_memory_pct double precision,
+ p95_memory_pct double precision, stddev_memory double precision,
+ avg_network_in_bytes double precision, max_network_in_bytes bigint,
+ avg_network_out_bytes double precision, max_network_out_bytes bigint,
+ sample_count integer, oom_count integer, restart_count integer, crash_loop_count integer,
+ running_minutes integer, total_minutes integer, resource_type text, created_at timestamp,
+ UNIQUE (deployment_id, summary_date)
+);
+INSERT INTO resource_metrics_history VALUES
+ (1, '2000-01-01 00:00:00', 20, 40, 100, 300, false, 'RUNNING', 'GENERAL'),
+ (1, '2000-01-01 00:10:00', 40, 80, 200, 500, true, 'STOPPED', 'GENERAL'),
+ (1, '2000-01-02 00:00:00', 99, 99, 999, 999, true, 'RUNNING', 'GENERAL'),
+ (4, '2000-01-01 00:00:00', 99, 99, 999, 999, true, 'RUNNING', 'GENERAL');
+INSERT INTO resource_metrics_history
+ SELECT 2, timestamp '2000-01-01' + i * interval '10 minutes', NULL, 0,
+ NULL, NULL, NULL, 'RUNNING', 'GENERAL' FROM generate_series(0,71) i;
+INSERT INTO abnormal_event VALUES
+ (1, '2000-01-01', 'OOM_KILLED'), (1, '2000-01-01', 'RESTART'),
+ (1, '2000-01-01', 'CRASH_LOOP'), (1, '2000-01-02', 'RESTART');
+SQL
+backfill
+test "$(sql -Atc 'SELECT count(*) FROM daily_metrics_summary')" = 0
+backfill -v apply=true
+backfill -v apply=true
+sql <<'SQL'
+DO $$ BEGIN
+ IF (SELECT count(*) FROM daily_metrics_summary) <> 2 THEN RAISE EXCEPTION 'Duplicate or invented days'; END IF;
+ IF NOT EXISTS (SELECT 1 FROM daily_metrics_summary WHERE deployment_id=1
+ AND sample_count=2 AND avg_cpu_pct=30 AND max_cpu_pct=40 AND abs(p95_cpu_pct-39)<0.001
+ AND abs(stddev_cpu-sqrt(200))<0.001 AND avg_memory_pct=60 AND p95_memory_pct=78
+ AND avg_network_in_bytes=150 AND max_network_out_bytes=500
+ AND oom_count=2 AND restart_count=1 AND crash_loop_count=1
+ AND running_minutes=10 AND total_minutes=20) THEN RAISE EXCEPTION 'Aggregate mismatch'; END IF;
+ IF NOT EXISTS (SELECT 1 FROM daily_metrics_summary WHERE deployment_id=2
+ AND sample_count=72 AND running_minutes=720 AND p95_cpu_pct IS NULL AND p95_memory_pct=0)
+ THEN RAISE EXCEPTION 'Missing metrics or valid-day threshold mismatch'; END IF;
+ IF (SELECT count(*) FROM resource_metrics_history) <> 76 THEN RAISE EXCEPTION 'Raw metrics changed'; END IF;
+ IF (SELECT count(*) FROM abnormal_event) <> 4 THEN RAISE EXCEPTION 'Events changed'; END IF;
+END $$;
+SQL
+if backfill -v deployment_ids=999 -v apply=true; then echo 'Unknown ID accepted' >&2; exit 1; fi
+if backfill -v start_date=2000-01-02 -v apply=true; then echo 'Reversed dates accepted' >&2; exit 1; fi
+if backfill -v end_date=2999-01-01 -v apply=true; then echo 'Incomplete future day accepted' >&2; exit 1; fi
+if backfill -v deployment_ids= -v apply=true; then echo 'Empty IDs accepted' >&2; exit 1; fi
+test "$(sql -Atc 'SELECT count(*) FROM daily_metrics_summary')" = 2
+echo 'PASS: dry-run, aggregation, null metrics, thresholds, scoping, repeatability, source preservation and invalid input guards'
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/HelmChartDTO.java b/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/HelmChartDTO.java
index 6df8da1..8595520 100644
--- a/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/HelmChartDTO.java
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/HelmChartDTO.java
@@ -32,6 +32,8 @@ public HelmChart toEntity() {
.chartVersion(this.chartVersion)
.chartRepositoryUrl(this.chartRepositoryUrl)
.valuesFile(this.valuesFile)
+ .packageId(this.packageId)
+ .normalizedName(this.normalizedName)
.hasValuesSchema(this.hasValuesSchema)
.repositoryName(this.repositoryName)
.repositoryOfficial(this.repositoryOfficial)
@@ -47,6 +49,8 @@ public static HelmChartDTO fromEntity(HelmChart helmChart) {
.chartVersion(helmChart.getChartVersion())
.chartRepositoryUrl(helmChart.getChartRepositoryUrl())
.valuesFile(helmChart.getValuesFile())
+ .packageId(helmChart.getPackageId())
+ .normalizedName(helmChart.getNormalizedName())
.hasValuesSchema(helmChart.getHasValuesSchema())
.repositoryName(helmChart.getRepositoryName())
.repositoryOfficial(helmChart.getRepositoryOfficial())
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepository.java b/src/main/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepository.java
index f09f725..c1b332f 100644
--- a/src/main/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepository.java
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepository.java
@@ -27,26 +27,28 @@ boolean existsByDeploymentIdAndRecordedAtBetween(
@Query("DELETE FROM ResourceMetricsHistory r WHERE r.recordedAt < :cutoff")
int deleteByRecordedAtBefore(@Param("cutoff") LocalDateTime cutoff);
+ // Spring Data JPA 3.2 native projections require exact Java property aliases.
+ // Quote camelCase names so PostgreSQL does not fold them to lowercase.
@Query(value = """
SELECT
- :deploymentId AS deployment_id,
- AVG(cpu_usage_pct) AS avg_cpu_pct,
- MAX(cpu_usage_pct) AS max_cpu_pct,
- PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cpu_usage_pct) AS p95_cpu_pct,
- STDDEV(cpu_usage_pct) AS stddev_cpu,
- AVG(memory_usage_pct) AS avg_memory_pct,
- MAX(memory_usage_pct) AS max_memory_pct,
- PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY memory_usage_pct) AS p95_memory_pct,
- STDDEV(memory_usage_pct) AS stddev_memory,
- AVG(network_in_bytes) AS avg_network_in_bytes,
- MAX(network_in_bytes) AS max_network_in_bytes,
- AVG(network_out_bytes) AS avg_network_out_bytes,
- MAX(network_out_bytes) AS max_network_out_bytes,
- COUNT(*) AS sample_count,
- SUM(CASE WHEN oom_killed = true THEN 1 ELSE 0 END) AS oom_count,
- SUM(CASE WHEN status = 'RUNNING' THEN 1 ELSE 0 END) * 10 AS running_minutes,
- COUNT(*) * 10 AS total_minutes,
- MIN(resource_type) AS resource_type
+ :deploymentId AS "deploymentId",
+ AVG(cpu_usage_pct) AS "avgCpuPct",
+ MAX(cpu_usage_pct) AS "maxCpuPct",
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY cpu_usage_pct) AS "p95CpuPct",
+ STDDEV(cpu_usage_pct) AS "stddevCpu",
+ AVG(memory_usage_pct) AS "avgMemoryPct",
+ MAX(memory_usage_pct) AS "maxMemoryPct",
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY memory_usage_pct) AS "p95MemoryPct",
+ STDDEV(memory_usage_pct) AS "stddevMemory",
+ AVG(network_in_bytes) AS "avgNetworkInBytes",
+ MAX(network_in_bytes) AS "maxNetworkInBytes",
+ AVG(network_out_bytes) AS "avgNetworkOutBytes",
+ MAX(network_out_bytes) AS "maxNetworkOutBytes",
+ COUNT(*) AS "sampleCount",
+ SUM(CASE WHEN oom_killed = true THEN 1 ELSE 0 END) AS "oomCount",
+ SUM(CASE WHEN status = 'RUNNING' THEN 1 ELSE 0 END) * 10 AS "runningMinutes",
+ COUNT(*) * 10 AS "totalMinutes",
+ MIN(resource_type) AS "resourceType"
FROM resource_metrics_history
WHERE deployment_id = :deploymentId
AND recorded_at BETWEEN :start AND :end
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmCharts.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmCharts.java
new file mode 100644
index 0000000..1fb1d1d
--- /dev/null
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmCharts.java
@@ -0,0 +1,69 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.*;
+import java.util.zip.GZIPOutputStream;
+import org.apache.commons.compress.archivers.tar.*;
+import org.springframework.core.io.ClassPathResource;
+import org.yaml.snakeyaml.Yaml;
+import kr.co.mcmp.softwarecatalog.application.model.HelmChart;
+
+/** Versioned, dependency-free Helm charts shipped in the AM image (not application images). */
+public final class BuiltInHelmCharts {
+ public static final String REPOSITORY = "mcmp-builtin";
+ public static final String URL = "classpath:helm";
+ public static final String VERSION = "0.1.0";
+ public record App(String chart, String title, int port, boolean persistent, String image, String appVersion) { }
+ public static final List APPS = List.of(
+ new App("apache", "Apache HTTP Server", 80, false, "httpd", "2.4-alpine"),
+ new App("tomcat", "Apache Tomcat", 8080, false, "tomcat", "10.1-jre17-temurin"),
+ new App("redis", "Redis", 6379, true, "redis", "7.4-alpine"),
+ new App("mariadb", "MariaDB", 3306, true, "mariadb", "11.4"),
+ new App("postgresql", "PostgreSQL", 5432, true, "postgres", "17-alpine"));
+ private static final List TEMPLATES = List.of("workload.yaml", "service.yaml", "ingress.yaml",
+ "pvc.yaml", "secret.yaml", "config.yaml", "hpa.yaml");
+ private BuiltInHelmCharts() { }
+
+ public static Optional app(HelmChart chart) {
+ if (chart == null || !REPOSITORY.equals(chart.getRepositoryName()) || !URL.equals(chart.getChartRepositoryUrl())
+ || !VERSION.equals(chart.getChartVersion())) return Optional.empty();
+ return APPS.stream().filter(a -> a.chart().equals(chart.getChartName())
+ && (REPOSITORY + "-" + a.chart()).equals(chart.getPackageId())).findFirst();
+ }
+
+ /** Caller owns and must delete the returned temporary archive, even on Helm failure. */
+ public static Path packageChart(App app) throws IOException {
+ if (!APPS.contains(app)) throw new IllegalArgumentException("Unknown built-in chart");
+ Path output = Files.createTempFile("am-chart-" + app.chart() + "-", ".tgz");
+ try (var tar = new TarArchiveOutputStream(new GZIPOutputStream(Files.newOutputStream(output)))) {
+ entry(tar, app.chart() + "/Chart.yaml", "apiVersion: v2\nname: " + app.chart()
+ + "\ntype: application\nversion: " + VERSION + "\nappVersion: \"" + app.appVersion() + "\"\n");
+ Map values = new Yaml().load(read("common/values.yaml"));
+ Map specific = new Yaml().load(read(app.chart() + "/values.yaml"));
+ values.putAll(specific);
+ entry(tar, app.chart() + "/values.yaml", new Yaml().dump(values));
+ for (String file : TEMPLATES) entry(tar, app.chart() + "/templates/" + file, read("common/templates/" + file));
+ } catch (Exception e) {
+ Files.deleteIfExists(output);
+ throw e;
+ }
+ return output;
+ }
+ private static String read(String path) throws IOException {
+ try (InputStream input = new ClassPathResource("helm/" + path).getInputStream()) {
+ return new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+ private static void entry(TarArchiveOutputStream tar, String path, String content) throws IOException {
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+ TarArchiveEntry entry = new TarArchiveEntry(path);
+ entry.setSize(bytes.length);
+ entry.setModTime(0);
+ entry.setMode(0644);
+ tar.putArchiveEntry(entry);
+ tar.write(bytes);
+ tar.closeArchiveEntry();
+ }
+}
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicy.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicy.java
new file mode 100644
index 0000000..9bcb86b
--- /dev/null
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicy.java
@@ -0,0 +1,61 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.math.BigDecimal;
+import java.util.*;
+import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import kr.co.mcmp.softwarecatalog.application.dto.*;
+import kr.co.mcmp.softwarecatalog.application.model.HelmChart;
+
+/** Applies only to the exact bundled chart identity; custom charts and VM installs are unchanged. */
+final class BuiltInHelmPolicy {
+ private BuiltInHelmPolicy() { }
+ static void validate(HelmChart chart, DeploymentConfigDTO config) {
+ BuiltInHelmCharts.app(chart).filter(BuiltInHelmCharts.App::persistent).ifPresent(app -> {
+ if (config.isIngressEnabled()) throw new IllegalArgumentException(app.title()
+ + " uses TCP, not HTTP Ingress. Use its ClusterIP Service or an authenticated port-forward.");
+ if (config.isHpaEnabled() || !Integer.valueOf(1).equals(config.getMinReplicas()))
+ throw new IllegalArgumentException(app.title() + " supports one persistent instance; disable HPA and use one replica.");
+ });
+ }
+ static void validateStorage(HelmChart chart, KubernetesClient client, DeploymentRequest request) {
+ if (BuiltInHelmCharts.app(chart).filter(BuiltInHelmCharts.App::persistent).isEmpty()) return;
+ if (request != null && Boolean.TRUE.equals(request.getWorkloadRebalancingEnabled()))
+ throw new IllegalArgumentException("Single-instance persistent applications do not support workload rebalancing.");
+ Map extra = extra(request);
+ String name = Objects.toString(extra.get("storageClass"), "").trim();
+ if (name.isEmpty()) throw new IllegalArgumentException("Select a StorageClass for this application's data volume.");
+ String mode = Objects.toString(extra.get("storageAccessMode"), "ReadWriteOnce");
+ if (!"ReadWriteOnce".equals(mode)) throw new IllegalArgumentException("Built-in data volumes require ReadWriteOnce.");
+ var storage = client.storage().v1().storageClasses().withName(name).get();
+ if (storage == null || storage.getProvisioner() == null || storage.getProvisioner().isBlank()
+ || "kubernetes.io/no-provisioner".equals(storage.getProvisioner()))
+ throw new IllegalArgumentException("Select an existing StorageClass with dynamic provisioning.");
+ int minimum = JupyterStorageValidation.minimumSizeGi(storage);
+ if (Quantity.getAmountInBytes(Quantity.parse(size(extra))).compareTo(
+ BigDecimal.valueOf(minimum).multiply(BigDecimal.valueOf(1073741824L))) < 0)
+ throw new IllegalArgumentException("This StorageClass requires at least " + minimum + "Gi.");
+ }
+ static String size(Map extra) {
+ String size = Objects.toString(extra.get("storageSize"), "10Gi");
+ // A deliberately narrow UI/API contract avoids Helm --set injection and accidental byte-sized disks.
+ if (!size.matches("[1-9][0-9]{0,3}Gi"))
+ throw new IllegalArgumentException("Enter a whole-number data volume capacity from 1Gi to 9999Gi.");
+ return size;
+ }
+ static void configure(HelmChart chart, DeploymentRequest request, Map cli, Map yaml) {
+ BuiltInHelmCharts.app(chart).ifPresent(app -> {
+ cli.put("securityContext.runAsNonRoot", "true");
+ if (app.persistent()) {
+ cli.put("persistence.enabled", "true");
+ // Typed YAML preserves literal StorageClass names instead of interpreting Helm --set syntax.
+ yaml.put("persistence", Map.of("enabled", true, "storageClass",
+ Objects.toString(extra(request).get("storageClass"), "").trim(),
+ "size", size(extra(request)), "retain", true));
+ }
+ });
+ }
+ private static Map extra(DeploymentRequest request) {
+ return request == null || request.getAdditionalConfig() == null ? Map.of() : request.getAdditionalConfig();
+ }
+}
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmChartService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmChartService.java
index b1be101..c96ed63 100644
--- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmChartService.java
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmChartService.java
@@ -72,6 +72,9 @@ public Release deployHelmChart(KubernetesClient client, String namespace, Softwa
public Release deployHelmChart(KubernetesClient client, String namespace, SoftwareCatalog catalog,
kr.co.mcmp.softwarecatalog.application.model.HelmChart helmChart, String clusterName) {
+ if (BuiltInHelmCharts.app(helmChart).isPresent()) {
+ return deployHelmChartWithRequest(client, namespace, catalog, helmChart, clusterName, new DeploymentRequest());
+ }
DeploymentConfigDTO config = DeploymentConfigDTO.from(new DeploymentRequest(), catalog);
HelmIngressValues.validate(helmChart, config);
Path tempKubeconfigPath = null;
@@ -281,8 +284,11 @@ public Release deployHelmChartWithRequest(KubernetesClient client, String namesp
helmChart.getChartName(), namespace, clusterName);
DeploymentConfigDTO config = DeploymentConfigDTO.from(request, catalog);
HelmIngressValues.validate(helmChart, config);
+ BuiltInHelmPolicy.validate(helmChart, config);
+ BuiltInHelmPolicy.validateStorage(helmChart, client, request);
Path tempKubeconfigPath = null;
Path tempValuesPath = null;
+ Path tempChartPath = null;
try {
// 1. 클러스터 정보 조회
@@ -305,7 +311,9 @@ public Release deployHelmChartWithRequest(KubernetesClient client, String namesp
tempKubeconfigPath = createTempKubeconfigFile(kubeconfigYaml);
// 3. Helm repository 추가
- addHelmRepository(helmChart);
+ var bundled = BuiltInHelmCharts.app(helmChart);
+ if (bundled.isPresent()) tempChartPath = BuiltInHelmCharts.packageChart(bundled.get());
+ else addHelmRepository(helmChart);
// 4. 릴리스 이름 생성
String releaseName = releaseNameGenerator.generateReleaseName(helmChart.getChartName());
@@ -323,7 +331,8 @@ public Release deployHelmChartWithRequest(KubernetesClient client, String namesp
log.info("배포 설정 생성 완료 - {}", config);
// 7. Helm Chart 설치 - CLI 방식으로 변경
- String chartRef = helmChart.getRepositoryName() + "/" + helmChart.getChartName();
+ String chartRef = tempChartPath != null ? tempChartPath.toString()
+ : helmChart.getRepositoryName() + "/" + helmChart.getChartName();
// Values 맵 구성
java.util.Map values = new java.util.HashMap<>();
@@ -370,6 +379,7 @@ public Release deployHelmChartWithRequest(KubernetesClient client, String namesp
}
applyObjectStorageValues(catalog, request, providerName, helmChart.getChartName(), chartValues);
+ BuiltInHelmPolicy.configure(helmChart, request, values, chartValues);
K8sIngressPolicy.configureValues(helmChart.getChartName(), values, chartValues, config, ingressCidr);
if (!chartValues.isEmpty()) {
tempValuesPath = createTempValuesFile(chartValues);
@@ -404,6 +414,10 @@ public Release deployHelmChartWithRequest(KubernetesClient client, String namesp
throw new RuntimeException("Helm Chart 배포 실패", e);
} finally {
// 8. 임시 kubeconfig 파일 삭제
+ if (tempChartPath != null) {
+ try { Files.deleteIfExists(tempChartPath); }
+ catch (IOException e) { log.warn("Failed to delete temporary built-in chart: {}", e.getMessage()); }
+ }
if (tempKubeconfigPath != null) {
try {
log.info("8. 임시 kubeconfig 파일 삭제 중...");
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmIngressValues.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmIngressValues.java
index 1ec4aea..22c7d23 100644
--- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmIngressValues.java
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/HelmIngressValues.java
@@ -88,6 +88,7 @@ static Map from(HelmChart chart, DeploymentConfigDTO config) {
/** Read-only validation also used by Spec Check, before a release name exists. */
static void validate(HelmChart chart, DeploymentConfigDTO config) {
+ BuiltInHelmPolicy.validate(chart, config);
if (!config.isIngressEnabled()) return;
String host = config.getIngressHost();
String dnsHost = host != null && host.startsWith("*.") ? host.substring(2) : host;
diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesDeployService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesDeployService.java
index eb4fa2e..a4eee50 100644
--- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesDeployService.java
+++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesDeployService.java
@@ -99,8 +99,10 @@ public DeploymentHistory deployApplication(String namespace, String clusterName,
HelmIngressValues.validate(helmChart, DeploymentConfigDTO.from(
request != null ? request : new DeploymentRequest(), catalog));
String ingressCidr = K8sIngressPolicy.validate(request, DeploymentConfigDTO.from(request, catalog));
+ BuiltInHelmPolicy.validate(helmChart, DeploymentConfigDTO.from(request, catalog));
try (KubernetesClient client = clientFactory.getClient(namespace, clusterName)) {
+ BuiltInHelmPolicy.validateStorage(helmChart, client, request);
if (isIngressEnabled(request, catalog)) ingressAccess.resolveTarget(request, catalog);
// namespaceService.ensureNamespaceExists(client, namespace); // 불필요한 코드 제거
diff --git a/src/main/java/kr/co/mcmp/util/DatabaseInitializer.java b/src/main/java/kr/co/mcmp/util/DatabaseInitializer.java
index 2a378f1..8dca25f 100644
--- a/src/main/java/kr/co/mcmp/util/DatabaseInitializer.java
+++ b/src/main/java/kr/co/mcmp/util/DatabaseInitializer.java
@@ -2,6 +2,7 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
+import kr.co.mcmp.softwarecatalog.kubernetes.service.BuiltInHelmCharts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
@@ -37,9 +38,53 @@ public void run(String... args) throws Exception {
}
ensureBuiltInJupyterCatalog();
ensureNginxHelmCatalog();
+ ensureBuiltInHelmCatalogs();
ensureBuiltInCatalogCapabilities();
}
+ private void ensureBuiltInHelmCatalogs() {
+ for (var app : BuiltInHelmCharts.APPS) {
+ // Reuse the existing VM catalog by title, without changing its settings or Docker image.
+ jdbcTemplate.update("""
+ INSERT INTO SOFTWARE_CATALOG (
+ TITLE, DESCRIPTION, SUMMARY, CATEGORY, MIN_CPU, RECOMMENDED_CPU, MIN_MEMORY,
+ RECOMMENDED_MEMORY, MIN_DISK, RECOMMENDED_DISK, CPU_THRESHOLD, MEMORY_THRESHOLD,
+ MIN_REPLICAS, MAX_REPLICAS, HPA_ENABLED, DEFAULT_PORT, INGRESS_ENABLED, CREATED_AT, UPDATED_AT)
+ SELECT ?, ?, ?, ?, 0.1, 1, 0.125, 1, 1, 10, 80, 80, 1, 1, false, ?, false,
+ CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ WHERE NOT EXISTS (SELECT 1 FROM SOFTWARE_CATALOG WHERE LOWER(TITLE) = LOWER(?))
+ """, app.title(), app.title() + " deployed with the AM bundled Helm chart.", app.title(),
+ app.persistent() ? "Databases & Storage" : "Web Servers", app.port(), app.title());
+ for (Long id : jdbcTemplate.queryForList("SELECT ID FROM SOFTWARE_CATALOG WHERE LOWER(TITLE) = LOWER(?)",
+ Long.class, app.title())) {
+ jdbcTemplate.update("""
+ INSERT INTO HELM_CHART (CATALOG_ID, CHART_NAME, CHART_VERSION, CHART_REPOSITORY_URL,
+ REPOSITORY_NAME, REPOSITORY_DISPLAY_NAME, REPOSITORY_OFFICIAL, PACKAGE_ID,
+ NORMALIZED_NAME, APP_VERSION, DESCRIPTION, CATEGORY, IMAGE_REPOSITORY, TAG, HAS_VALUES_SCHEMA)
+ SELECT ?, ?, ?, ?, ?, 'MCMP bundled charts', false, ?, ?, ?, ?, ?, ?, ?, false
+ WHERE NOT EXISTS (SELECT 1 FROM HELM_CHART WHERE CATALOG_ID = ?)
+ """, id, app.chart(), BuiltInHelmCharts.VERSION, BuiltInHelmCharts.URL, BuiltInHelmCharts.REPOSITORY,
+ BuiltInHelmCharts.REPOSITORY + "-" + app.chart(), app.chart(), app.appVersion(),
+ "Single-instance " + app.title() + " using a digest-pinned official container image.",
+ app.persistent() ? "Databases & Storage" : "Web Servers", app.image(), app.appVersion(), id);
+ // A user-provided Helm mapping wins; do not attach our capability to a different chart.
+ Integer managed = jdbcTemplate.queryForObject("""
+ SELECT COUNT(*) FROM HELM_CHART WHERE CATALOG_ID = ? AND PACKAGE_ID = ?
+ AND CHART_REPOSITORY_URL = ? AND CHART_VERSION = ? AND REPOSITORY_NAME = ? AND CHART_NAME = ?
+ """, Integer.class, id, BuiltInHelmCharts.REPOSITORY + "-" + app.chart(), BuiltInHelmCharts.URL,
+ BuiltInHelmCharts.VERSION, BuiltInHelmCharts.REPOSITORY, app.chart());
+ if (managed != null && managed > 0) {
+ ensureCatalogReference(id, "helm_application_install", "workflow");
+ ensureCatalogReference(id, "helm_application_uninstall", "workflow");
+ if (app.persistent()) {
+ ensureCatalogReference(id, STORAGE_CLASS_CAPABILITY, "CAPABILITY");
+ ensureCatalogReference(id, "persistent-single-instance", "CAPABILITY");
+ }
+ }
+ }
+ }
+ }
+
private void ensureNginxHelmCatalog() {
jdbcTemplate.update("""
INSERT INTO SOFTWARE_CATALOG (
diff --git a/src/main/resources/helm/README.md b/src/main/resources/helm/README.md
new file mode 100644
index 0000000..fa4182d
--- /dev/null
+++ b/src/main/resources/helm/README.md
@@ -0,0 +1,70 @@
+# AM bundled Kubernetes applications
+
+The application image includes versioned Helm definitions for Apache HTTP Server,
+Tomcat, Redis, MariaDB and PostgreSQL. It does **not** include the application
+container layers: the Kubernetes workers still need access to Docker Hub.
+Every container reference includes a multi-architecture SHA-256 digest.
+
+`DatabaseInitializer` adds missing Helm mappings on both fresh and existing
+databases. The four existing VM catalog entries are reused; their Docker images,
+resource settings and deployments are not changed. PostgreSQL is added as a
+Kubernetes-only entry. Existing custom Helm mappings take precedence.
+
+`classpath:helm` is an internal repository identifier, not a network URL.
+`BuiltInHelmCharts` packages these resources into a temporary archive inside AM;
+the normal Helm deployment pipeline installs it and deletes the archive. Unknown
+or custom chart identities continue using the external repository path.
+
+## Access and storage
+
+- Apache and Tomcat support the existing HTTP Ingress settings and optional HPA.
+ Their Service ports default to 80 and 8080, respectively. The Apache container
+ listens on 8080 so it can run without root or privileged-port capabilities.
+ Tomcat includes a small default ROOT page for initial connectivity verification.
+- Redis, MariaDB and PostgreSQL use internal ClusterIP Services on ports 6379,
+ 3306 and 5432. HTTP Ingress, HPA and workload rebalancing are rejected for these
+ single-instance charts. They are not replication/HA deployments.
+- Select a dynamically provisioned StorageClass and a whole-number capacity in
+ GiB. The default capacity is 10Gi. Known provider-specific minimums are validated;
+ provider quotas, CSI configuration and additional limits still apply.
+- Credentials are generated by Helm in `-auth`; the application
+ password is in key `password`. MariaDB has a separate `root-password` key.
+ MariaDB and PostgreSQL initialize database `app`, user `am`. PostgreSQL's `am`
+ user is the initialization administrator; create a least-privilege user before
+ connecting a production application.
+- To connect from a PC, use an authenticated `kubectl port-forward` to the Service,
+ then use the native client and the generated credentials. Do not expose the
+ database/cache ports to the public Internet.
+
+## Data lifetime
+
+The data PVC `-data` and authentication Secret are deliberately
+retained by `helm uninstall`. Pod replacement preserves data. A new AM deployment
+generates a new release name and therefore a new PVC; AM does not automatically
+attach a previous release's retained volume. Restore/migration is an operator task.
+
+After backing up any required data, remove the exact retained PVC and Secret
+when they are no longer needed. A StorageClass with `Retain` reclaim policy can
+leave its PV/cloud disk after PVC deletion, requiring separate operator cleanup.
+Do not use broad namespace or label deletion against shared/archiving workloads.
+
+## Verification
+
+- `bash gradlew test --tests '*BuiltInHelm*'`: initialization, custom-setting
+ preservation, DTO identity, storage and application policy, archive contents.
+- `BuiltInHelmRenderTest` runs Helm lint/template when Helm is installed. It checks
+ both Nginx and IBM Ingress classes for HTTP apps, TLS rendering, and rejection of
+ unsafe database options. Missing Helm causes this CLI-specific test to skip.
+- `BuiltInHelmPipelineTest` passes AM's Ingress adapters, built-in policy and Helm
+ CLI overrides through the packaged chart (26 input combinations). It checks
+ custom Service ports, storage value precedence, CIDR routes and TLS/HTTP
+ settings. It also requires Helm and does not substitute for a live CSP test.
+- `node applicationFE/scripts/test-builtin-helm.mjs`: real Vue form computations,
+ target changes, identity matching, storage validation and payload generation.
+- `BuiltInHelmChartsTest` exports production-generated archives to
+ `build/builtin-charts` for explicit real-cluster tests. A rendered chart is not
+ proof of a successful CSP deployment: check readiness, native protocol access,
+ authentication rejection, restart persistence, and exact-resource cleanup.
+
+Chart template/image changes require a new chart version and a deliberate migration
+policy; do not silently overwrite user-customized mappings or running releases.
diff --git a/src/main/resources/helm/apache/values.yaml b/src/main/resources/helm/apache/values.yaml
new file mode 100644
index 0000000..646080f
--- /dev/null
+++ b/src/main/resources/helm/apache/values.yaml
@@ -0,0 +1,29 @@
+image: httpd:2.4-alpine@sha256:4e585da9d0125dec36d4500a9f5c5df7b2c0a01f67cb47865a91a4b05bdbec1b
+uid: 1001
+containerPort: 8080
+service: {type: ClusterIP, port: 80}
+container:
+ command: [httpd, -DFOREGROUND, -f, /usr/local/apache2/conf/am-httpd.conf]
+ readinessProbe: {httpGet: {path: /, port: app}, periodSeconds: 5}
+ livenessProbe: {httpGet: {path: /, port: app}, initialDelaySeconds: 15, periodSeconds: 10}
+config:
+ httpd.conf:
+ path: /usr/local/apache2/conf/am-httpd.conf
+ content: |
+ ServerRoot "/usr/local/apache2"
+ Listen 8080
+ ServerName localhost
+ LoadModule mpm_event_module modules/mod_mpm_event.so
+ LoadModule authz_core_module modules/mod_authz_core.so
+ LoadModule unixd_module modules/mod_unixd.so
+ LoadModule dir_module modules/mod_dir.so
+ LoadModule mime_module modules/mod_mime.so
+ PidFile /tmp/httpd.pid
+ ErrorLog /proc/self/fd/2
+ LogLevel warn
+ TypesConfig conf/mime.types
+ DocumentRoot "/usr/local/apache2/htdocs"
+ DirectoryIndex index.html
+
+ Require all granted
+
diff --git a/src/main/resources/helm/common/templates/config.yaml b/src/main/resources/helm/common/templates/config.yaml
new file mode 100644
index 0000000..c020e39
--- /dev/null
+++ b/src/main/resources/helm/common/templates/config.yaml
@@ -0,0 +1,11 @@
+{{- if .Values.config }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Release.Name }}
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+data:
+ {{- range $key, $item := .Values.config }}
+ {{ $key }}: {{ $item.content | quote }}
+ {{- end }}
+{{- end }}
diff --git a/src/main/resources/helm/common/templates/hpa.yaml b/src/main/resources/helm/common/templates/hpa.yaml
new file mode 100644
index 0000000..de2d8f6
--- /dev/null
+++ b/src/main/resources/helm/common/templates/hpa.yaml
@@ -0,0 +1,16 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ .Release.Name }}
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+spec:
+ scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: {{ .Release.Name }}}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target: {type: Utilization, averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}}
+{{- end }}
diff --git a/src/main/resources/helm/common/templates/ingress.yaml b/src/main/resources/helm/common/templates/ingress.yaml
new file mode 100644
index 0000000..25c43ad
--- /dev/null
+++ b/src/main/resources/helm/common/templates/ingress.yaml
@@ -0,0 +1,28 @@
+{{- if .Values.ingress.enabled }}
+{{- if .Values.persistence.enabled }}{{ fail "HTTP Ingress is not supported for Redis/MariaDB/PostgreSQL." }}{{ end }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ .Release.Name }}
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ annotations: {{ toYaml .Values.ingress.annotations | nindent 4 }}
+spec:
+ ingressClassName: {{ .Values.ingress.className | quote }}
+ {{- with .Values.ingress.tls }}
+ tls: {{ toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path | quote }}
+ pathType: {{ .pathType | default "Prefix" }}
+ backend:
+ service:
+ name: {{ $.Release.Name }}
+ port: {number: {{ $.Values.service.port }}}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/src/main/resources/helm/common/templates/pvc.yaml b/src/main/resources/helm/common/templates/pvc.yaml
new file mode 100644
index 0000000..99d798f
--- /dev/null
+++ b/src/main/resources/helm/common/templates/pvc.yaml
@@ -0,0 +1,17 @@
+{{- if .Values.persistence.enabled }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ .Release.Name }}-data
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ {{- if .Values.persistence.retain }}
+ annotations: {helm.sh/resource-policy: keep}
+ {{- end }}
+spec:
+ accessModes: [ReadWriteOnce]
+ {{- if .Values.persistence.storageClass }}
+ storageClassName: {{ .Values.persistence.storageClass | quote }}
+ {{- end }}
+ resources:
+ requests: {storage: {{ .Values.persistence.size | quote }}}
+{{- end }}
diff --git a/src/main/resources/helm/common/templates/secret.yaml b/src/main/resources/helm/common/templates/secret.yaml
new file mode 100644
index 0000000..7cd8db7
--- /dev/null
+++ b/src/main/resources/helm/common/templates/secret.yaml
@@ -0,0 +1,20 @@
+{{- if .Values.persistence.enabled }}
+{{- $old := lookup "v1" "Secret" .Release.Namespace (printf "%s-auth" .Release.Name) }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ .Release.Name }}-auth
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ {{- if .Values.persistence.retain }}
+ annotations: {helm.sh/resource-policy: keep}
+ {{- end }}
+type: Opaque
+data:
+ {{- if $old }}
+ password: {{ index $old.data "password" | quote }}
+ root-password: {{ index $old.data "root-password" | quote }}
+ {{- else }}
+ password: {{ randAlphaNum 32 | b64enc | quote }}
+ root-password: {{ randAlphaNum 32 | b64enc | quote }}
+ {{- end }}
+{{- end }}
diff --git a/src/main/resources/helm/common/templates/service.yaml b/src/main/resources/helm/common/templates/service.yaml
new file mode 100644
index 0000000..c7e4c3c
--- /dev/null
+++ b/src/main/resources/helm/common/templates/service.yaml
@@ -0,0 +1,10 @@
+{{- if ne .Values.service.type "ClusterIP" }}{{ fail "Built-in charts use ClusterIP; use Ingress or an authenticated port-forward." }}{{ end }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Release.Name }}
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+spec:
+ type: ClusterIP
+ selector: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ ports: [{name: app, port: {{ .Values.service.port }}, targetPort: app, protocol: TCP}]
diff --git a/src/main/resources/helm/common/templates/workload.yaml b/src/main/resources/helm/common/templates/workload.yaml
new file mode 100644
index 0000000..17618ca
--- /dev/null
+++ b/src/main/resources/helm/common/templates/workload.yaml
@@ -0,0 +1,64 @@
+{{- if and (has .Chart.Name (list "redis" "mariadb" "postgresql")) (not .Values.persistence.enabled) }}
+{{- fail "Built-in database/cache charts require persistence and generated authentication." }}
+{{- end }}
+{{- if and .Values.persistence.enabled (or .Values.autoscaling.enabled (ne (int .Values.replicaCount) 1)) }}
+{{- fail "Persistent built-in applications require one replica and HPA disabled." }}
+{{- end }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ .Release.Name }}
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+spec:
+ replicas: {{ .Values.replicaCount }}
+ {{- if .Values.persistence.enabled }}
+ strategy: {type: Recreate}
+ {{- end }}
+ selector:
+ matchLabels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ template:
+ metadata:
+ labels: {app.kubernetes.io/name: {{ .Chart.Name }}, app.kubernetes.io/instance: {{ .Release.Name }}}
+ spec:
+ automountServiceAccountToken: false
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: {{ .Values.uid }}
+ runAsGroup: {{ .Values.uid }}
+ fsGroup: {{ .Values.uid }}
+ fsGroupChangePolicy: OnRootMismatch
+ seccompProfile: {type: RuntimeDefault}
+ containers:
+ - name: {{ .Chart.Name }}
+ image: {{ .Values.image | quote }}
+ imagePullPolicy: IfNotPresent
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities: {drop: ["ALL"]}
+ ports: [{name: app, containerPort: {{ .Values.containerPort }}}]
+ resources: {{ toYaml .Values.resources | nindent 12 }}
+ {{- tpl (toYaml .Values.container) . | nindent 10 }}
+ volumeMounts:
+ - {name: tmp, mountPath: /tmp}
+ {{- range $name, $path := .Values.emptyDirs }}
+ - {name: {{ $name }}, mountPath: {{ $path | quote }}}
+ {{- end }}
+ {{- if .Values.persistence.enabled }}
+ - {name: data, mountPath: {{ .Values.dataPath }}}
+ {{- end }}
+ {{- range $key, $item := .Values.config }}
+ - {name: config, mountPath: {{ $item.path | quote }}, subPath: {{ $key | quote }}}
+ {{- end }}
+ volumes:
+ - {name: tmp, emptyDir: {}}
+ {{- range $name, $path := .Values.emptyDirs }}
+ - {name: {{ $name }}, emptyDir: {}}
+ {{- end }}
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ persistentVolumeClaim: {claimName: {{ .Release.Name }}-data}
+ {{- end }}
+ {{- if .Values.config }}
+ - name: config
+ configMap: {name: {{ .Release.Name }}}
+ {{- end }}
diff --git a/src/main/resources/helm/common/values.yaml b/src/main/resources/helm/common/values.yaml
new file mode 100644
index 0000000..0a7086b
--- /dev/null
+++ b/src/main/resources/helm/common/values.yaml
@@ -0,0 +1,14 @@
+replicaCount: 1
+service: {type: ClusterIP, port: 80}
+resources:
+ requests: {cpu: 100m, memory: 128Mi}
+ limits: {cpu: "1", memory: 512Mi}
+persistence: {enabled: false, storageClass: "", size: 10Gi, retain: true}
+autoscaling: {enabled: false, minReplicas: 1, maxReplicas: 3, targetCPUUtilizationPercentage: 80}
+ingress: {enabled: false, className: nginx, annotations: {}, hosts: [], tls: []}
+config: {}
+emptyDirs: {}
+container: {}
+dataPath: /data
+uid: 1001
+containerPort: 8080
diff --git a/src/main/resources/helm/mariadb/values.yaml b/src/main/resources/helm/mariadb/values.yaml
new file mode 100644
index 0000000..a4c3ab3
--- /dev/null
+++ b/src/main/resources/helm/mariadb/values.yaml
@@ -0,0 +1,18 @@
+image: mariadb:11.4@sha256:70cc072b29b4a89ae07abb2d4da2c64678a7f2dfe092751bb51c87d67dc1338b
+uid: 999
+containerPort: 3306
+service: {type: ClusterIP, port: 3306}
+persistence: {enabled: true, storageClass: "", size: 10Gi, retain: true}
+dataPath: /var/lib/mysql
+container:
+ args: ["--socket=/tmp/mysql.sock", "--pid-file=/tmp/mysql.pid"]
+ env:
+ - {name: MARIADB_DATABASE, value: app}
+ - {name: MARIADB_USER, value: am}
+ - name: MARIADB_PASSWORD
+ valueFrom: {secretKeyRef: {name: "{{ .Release.Name }}-auth", key: password}}
+ - name: MARIADB_ROOT_PASSWORD
+ valueFrom: {secretKeyRef: {name: "{{ .Release.Name }}-auth", key: root-password}}
+ startupProbe: {exec: {command: [healthcheck.sh, --connect, --innodb_initialized]}, failureThreshold: 90, periodSeconds: 5}
+ readinessProbe: {exec: {command: [healthcheck.sh, --connect, --innodb_initialized]}, periodSeconds: 5}
+ livenessProbe: {tcpSocket: {port: app}, periodSeconds: 10}
diff --git a/src/main/resources/helm/postgresql/values.yaml b/src/main/resources/helm/postgresql/values.yaml
new file mode 100644
index 0000000..a7e65cc
--- /dev/null
+++ b/src/main/resources/helm/postgresql/values.yaml
@@ -0,0 +1,16 @@
+image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
+uid: 70
+containerPort: 5432
+service: {type: ClusterIP, port: 5432}
+persistence: {enabled: true, storageClass: "", size: 10Gi, retain: true}
+dataPath: /var/lib/postgresql/data
+container:
+ env:
+ - {name: POSTGRES_DB, value: app}
+ - {name: POSTGRES_USER, value: am}
+ - {name: PGDATA, value: /var/lib/postgresql/data/pgdata}
+ - name: POSTGRES_PASSWORD
+ valueFrom: {secretKeyRef: {name: "{{ .Release.Name }}-auth", key: password}}
+ startupProbe: {exec: {command: [pg_isready, -U, am, -d, app]}, failureThreshold: 60, periodSeconds: 5}
+ readinessProbe: {exec: {command: [pg_isready, -U, am, -d, app]}, periodSeconds: 5}
+ livenessProbe: {exec: {command: [pg_isready, -U, am, -d, app]}, periodSeconds: 10}
diff --git a/src/main/resources/helm/redis/values.yaml b/src/main/resources/helm/redis/values.yaml
new file mode 100644
index 0000000..57d951a
--- /dev/null
+++ b/src/main/resources/helm/redis/values.yaml
@@ -0,0 +1,15 @@
+image: redis:7.4-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
+uid: 999
+containerPort: 6379
+service: {type: ClusterIP, port: 6379}
+persistence: {enabled: true, storageClass: "", size: 10Gi, retain: true}
+dataPath: /data
+container:
+ command: [sh, -ec]
+ args:
+ - 'umask 077; printf "requirepass %s\nappendonly yes\ndir /data\n" "$REDISCLI_AUTH" > /tmp/redis.conf; exec redis-server /tmp/redis.conf'
+ env:
+ - name: REDISCLI_AUTH
+ valueFrom: {secretKeyRef: {name: "{{ .Release.Name }}-auth", key: password}}
+ readinessProbe: {exec: {command: [sh, -ec, 'test "$(redis-cli ping)" = PONG']}, periodSeconds: 5}
+ livenessProbe: {exec: {command: [sh, -ec, 'test "$(redis-cli ping)" = PONG']}, initialDelaySeconds: 30, periodSeconds: 10}
diff --git a/src/main/resources/helm/tomcat/values.yaml b/src/main/resources/helm/tomcat/values.yaml
new file mode 100644
index 0000000..7edc096
--- /dev/null
+++ b/src/main/resources/helm/tomcat/values.yaml
@@ -0,0 +1,16 @@
+image: tomcat:10.1-jre17-temurin@sha256:d9f2c0d63050647ecfdf06557a046e5d9c5e66a9254e807ec43cf5e260e00e72
+uid: 1001
+containerPort: 8080
+service: {type: ClusterIP, port: 8080}
+emptyDirs: {logs: /usr/local/tomcat/logs, work: /usr/local/tomcat/work, temp: /usr/local/tomcat/temp}
+container:
+ env:
+ - {name: CATALINA_TMPDIR, value: /tmp}
+ - {name: CATALINA_OPTS, value: "-XX:MaxRAMPercentage=70 -Djava.awt.headless=true"}
+ startupProbe: {httpGet: {path: /, port: app}, failureThreshold: 60, periodSeconds: 5}
+ readinessProbe: {httpGet: {path: /, port: app}, periodSeconds: 5}
+ livenessProbe: {httpGet: {path: /, port: app}, periodSeconds: 10}
+config:
+ index.html:
+ path: /usr/local/tomcat/webapps/ROOT/index.html
+ content: "Apache Tomcat on MCMP "
diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepositoryTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepositoryTest.java
new file mode 100644
index 0000000..864e944
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepositoryTest.java
@@ -0,0 +1,123 @@
+package kr.co.mcmp.softwarecatalog.application.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import javax.sql.DataSource;
+import jakarta.persistence.EntityManagerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.*;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.orm.jpa.*;
+import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import kr.co.mcmp.softwarecatalog.application.model.ResourceMetricsHistory;
+
+/** Runs the real native SQL and Spring Data projection, not a mocked getter map. */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = ResourceMetricsHistoryRepositoryTest.Config.class)
+class ResourceMetricsHistoryRepositoryTest {
+ @Configuration
+ @EnableTransactionManagement
+ @EnableJpaRepositories(basePackageClasses = ResourceMetricsHistoryRepository.class,
+ excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX,
+ pattern = ".*(? 0) {
+ assertThat(result.getRunningMinutes()).isEqualTo(samples * 10);
+ assertThat(result.getP95CpuPct()).isZero();
+ } else {
+ assertThat(result.getP95CpuPct()).isNull();
+ }
+ }
+
+ @Test void preservesMissingMetricsRatherThanInventingZeroUsage() {
+ repository.saveAndFlush(sample(11L, start, null, null, null, null, null, null));
+ var result = repository.aggregateByDeploymentAndDate(11L, start, start.plusDays(1));
+ assertThat(result.getSampleCount()).isEqualTo(1);
+ assertThat(result.getAvgCpuPct()).isNull();
+ assertThat(result.getP95MemoryPct()).isNull();
+ assertThat(result.getStddevCpu()).isNull();
+ assertThat(result.getMaxNetworkInBytes()).isNull();
+ assertThat(result.getOomCount()).isZero();
+ assertThat(result.getRunningMinutes()).isZero();
+ }
+
+ private ResourceMetricsHistory sample(Long id, LocalDateTime time, Double cpu, Double memory,
+ Long networkIn, Long networkOut, Boolean oom, String status) {
+ return ResourceMetricsHistory.builder().deploymentId(id).recordedAt(time)
+ .cpuUsagePct(cpu).memoryUsagePct(memory).networkInBytes(networkIn).networkOutBytes(networkOut)
+ .oomKilled(oom).status(status).resourceType("GENERAL").deploymentType("K8S").build();
+ }
+}
diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmChartsTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmChartsTest.java
new file mode 100644
index 0000000..f19d73e
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmChartsTest.java
@@ -0,0 +1,67 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.zip.GZIPInputStream;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+import static org.assertj.core.api.Assertions.*;
+import kr.co.mcmp.softwarecatalog.application.model.HelmChart;
+import kr.co.mcmp.softwarecatalog.application.dto.HelmChartDTO;
+
+class BuiltInHelmChartsTest {
+ static HelmChart chart(String name) {
+ return HelmChart.builder().chartName(name).chartVersion(BuiltInHelmCharts.VERSION)
+ .chartRepositoryUrl(BuiltInHelmCharts.URL).repositoryName(BuiltInHelmCharts.REPOSITORY)
+ .packageId(BuiltInHelmCharts.REPOSITORY + "-" + name).build();
+ }
+
+ @Test void bundlesAllFiveWithPinnedImagesAndNoExternalChartDependencies() throws Exception {
+ assertThat(BuiltInHelmCharts.APPS).hasSize(5);
+ Path exports = Path.of("build", "builtin-charts");
+ Files.createDirectories(exports);
+ for (var app : BuiltInHelmCharts.APPS) {
+ Path archive = BuiltInHelmCharts.packageChart(app);
+ try {
+ Map entries = read(archive);
+ assertThat(entries).hasSize(9);
+ Map metadata = new Yaml().load(entries.get(app.chart() + "/Chart.yaml"));
+ assertThat(metadata).containsEntry("name", app.chart()).doesNotContainKey("dependencies");
+ Map values = new Yaml().load(entries.get(app.chart() + "/values.yaml"));
+ assertThat(values.get("image").toString()).matches(".+@sha256:[a-f0-9]{64}");
+ assertThat(((Map, ?>)values.get("persistence")).get("enabled")).isEqualTo(app.persistent());
+ assertThat(BuiltInHelmCharts.app(chart(app.chart()))).contains(app);
+ // Used by the explicit Helm lint/render and real-cluster test commands; these are production archives.
+ Files.copy(archive, exports.resolve(app.chart() + "-0.1.0.tgz"), StandardCopyOption.REPLACE_EXISTING);
+ } finally { Files.deleteIfExists(archive); }
+ }
+ }
+ @Test void doesNotInterceptUserOrExternalChartsOrUnknownVersions() {
+ var custom = chart("redis"); custom.setChartRepositoryUrl("https://custom.example.org");
+ assertThat(BuiltInHelmCharts.app(custom)).isEmpty();
+ custom = chart("redis"); custom.setChartVersion("2.0.0");
+ assertThat(BuiltInHelmCharts.app(custom)).isEmpty();
+ custom = chart("redis"); custom.setPackageId("custom");
+ assertThat(BuiltInHelmCharts.app(custom)).isEmpty();
+ assertThat(BuiltInHelmCharts.app(chart("../secret"))).isEmpty();
+ assertThat(BuiltInHelmCharts.app(null)).isEmpty();
+ }
+ @Test void catalogApiAndRoundTripPreserveTheIdentityNeededByTheInstallationForm() {
+ var model = chart("redis");
+ model.setNormalizedName("redis");
+ var dto = HelmChartDTO.fromEntity(model);
+ assertThat(dto.getPackageId()).isEqualTo("mcmp-builtin-redis");
+ assertThat(dto.getNormalizedName()).isEqualTo("redis");
+ assertThat(BuiltInHelmCharts.app(dto.toEntity())).isPresent();
+ }
+ private Map read(Path archive) throws IOException {
+ Map entries = new LinkedHashMap<>();
+ try (var tar = new TarArchiveInputStream(new GZIPInputStream(Files.newInputStream(archive)))) {
+ for (var entry = tar.getNextTarEntry(); entry != null; entry = tar.getNextTarEntry())
+ entries.put(entry.getName(), new String(tar.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+ return entries;
+ }
+}
diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPipelineTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPipelineTest.java
new file mode 100644
index 0000000..b69ce92
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPipelineTest.java
@@ -0,0 +1,154 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.yaml.snakeyaml.Yaml;
+import kr.co.mcmp.softwarecatalog.application.dto.DeploymentConfigDTO;
+import kr.co.mcmp.softwarecatalog.application.dto.DeploymentRequest;
+import static org.assertj.core.api.Assertions.*;
+
+/** Renders the bundled packages using the same adapters and value precedence as AM's install path. */
+class BuiltInHelmPipelineTest {
+ private static final String CIDR = "203.0.113.8/32";
+
+ @BeforeAll static void requireHelm() {
+ try {
+ Assumptions.assumeTrue(new ProcessBuilder("helm", "version", "--short").start().waitFor() == 0,
+ "Helm CLI required");
+ } catch (Exception e) { Assumptions.abort("Helm CLI not installed"); }
+ }
+
+ static Stream services() {
+ return BuiltInHelmCharts.APPS.stream().flatMap(app -> Stream.of(
+ Arguments.of(app, app.port(), "standard", "10Gi"),
+ Arguments.of(app, 18080, "fast.csi-v2", "20Gi")));
+ }
+
+ @ParameterizedTest(name = "{0}: service port {1}, storage {2}/{3}")
+ @MethodSource("services")
+ void preservesStorageSecurityAndContainerPortsWithAmOverrides(BuiltInHelmCharts.App app,
+ int port, String storageClass, String capacity) throws Exception {
+ var request = DeploymentRequest.builder().additionalConfig(Map.of(
+ "storageClass", storageClass, "storageSize", capacity)).build();
+ var rendered = render(app, config(port, false, "nginx", false, "/"), request);
+ Map service = map(rendered.ofKind("Service").get("spec"));
+ assertThat(service).containsEntry("type", "ClusterIP");
+ assertThat(map(first(service.get("ports")))).containsEntry("port", port).containsEntry("targetPort", "app");
+ Map pod = map(map(map(rendered.ofKind("Deployment").get("spec")).get("template")).get("spec"));
+ assertThat(pod).containsEntry("automountServiceAccountToken", false);
+ assertThat(map(pod.get("securityContext"))).containsEntry("runAsNonRoot", true);
+ var container = map(first(pod.get("containers")));
+ assertThat(map(first(container.get("ports")))).containsEntry("name", "app")
+ .containsEntry("containerPort", app.chart().equals("apache") ? 8080 : app.port());
+ assertThat(map(map(container.get("resources")).get("requests"))).containsEntry("memory", "256Mi");
+ assertThat(rendered.kinds()).doesNotContain("Ingress", "HorizontalPodAutoscaler");
+ if (app.persistent()) {
+ var pvc = rendered.ofKind("PersistentVolumeClaim");
+ var spec = map(pvc.get("spec"));
+ assertThat(spec).containsEntry("storageClassName", storageClass)
+ .containsEntry("accessModes", List.of("ReadWriteOnce"));
+ assertThat(map(map(spec.get("resources")).get("requests"))).containsEntry("storage", capacity);
+ assertThat(map(map(pvc.get("metadata")).get("annotations")))
+ .containsEntry("helm.sh/resource-policy", "keep");
+ assertThat(rendered.kinds()).contains("Secret");
+ } else {
+ assertThat(rendered.kinds()).doesNotContain("PersistentVolumeClaim", "Secret");
+ }
+ }
+
+ static Stream routes() {
+ return BuiltInHelmCharts.APPS.stream().filter(app -> !app.persistent()).flatMap(app ->
+ Stream.of("nginx", "public-iks-k8s-nginx").flatMap(clazz ->
+ Stream.of(false, true).flatMap(tls -> Stream.of("/", "/demo").map(path ->
+ Arguments.of(app, clazz, tls, path)))));
+ }
+
+ @ParameterizedTest(name = "{0}: ingress {1}, TLS={2}, path={3}")
+ @MethodSource("routes")
+ void rendersTheRequestedRouteAndPassesAmCidrPolicy(BuiltInHelmCharts.App app,
+ String clazz, boolean tls, String path) throws Exception {
+ var rendered = render(app, config(18080, true, clazz, tls, path),
+ DeploymentRequest.builder().servicePortCidr(CIDR).build());
+ assertThatCode(() -> K8sIngressPolicy.verifyManifest(rendered.text(), CIDR, "app.example.com", clazz))
+ .doesNotThrowAnyException();
+ var ingress = rendered.ofKind("Ingress");
+ var spec = map(ingress.get("spec"));
+ var rule = map(first(spec.get("rules")));
+ var route = map(first(map(rule.get("http")).get("paths")));
+ assertThat(route).containsEntry("path", path).containsEntry("pathType", "Prefix");
+ assertThat(map(map(map(route.get("backend")).get("service")).get("port"))).containsEntry("number", 18080);
+ if (tls) {
+ // AM's legacy release-name TLS Secret fallback still survives the CIDR adapter.
+ assertThat(map(first(spec.get("tls")))).containsEntry("secretName", "pipeline-tls")
+ .containsEntry("hosts", List.of("app.example.com"));
+ } else {
+ assertThat(spec).doesNotContainKey("tls");
+ if (IbmIngressSupport.managed(clazz)) {
+ assertThat(map(map(ingress.get("metadata")).get("annotations")))
+ .containsEntry("nginx.ingress.kubernetes.io/ssl-redirect", "false")
+ .containsEntry("nginx.ingress.kubernetes.io/force-ssl-redirect", "false");
+ }
+ }
+ }
+
+ private static DeploymentConfigDTO config(int port, boolean ingress, String clazz, boolean tls, String path) {
+ return DeploymentConfigDTO.builder().minReplicas(1).hpaEnabled(false).servicePort(port)
+ .ingressEnabled(ingress).ingressHost("app.example.com").ingressPath(path)
+ .ingressClass(clazz).ingressTlsEnabled(tls).build();
+ }
+
+ private static Rendered render(BuiltInHelmCharts.App app, DeploymentConfigDTO input,
+ DeploymentRequest request) throws Exception {
+ var chart = BuiltInHelmChartsTest.chart(app.chart());
+ var config = HelmIngressValues.resolveTlsConfig(input, "pipeline");
+ Map yaml = HelmIngressValues.from(chart, config);
+ // Include the conflicting generic defaults that the production adapter must override.
+ Map cli = new LinkedHashMap<>(Map.of(
+ "replicaCount", "1", "service.port", config.getServicePort().toString(),
+ "service.type", "ClusterIP", "persistence.enabled", "false",
+ "securityContext.runAsNonRoot", "false", "autoscaling.enabled", "false",
+ "resources.requests.cpu", "0.1", "resources.requests.memory", "256Mi"));
+ String cidr = K8sIngressPolicy.validate(request, config);
+ BuiltInHelmPolicy.configure(chart, request, cli, yaml);
+ K8sIngressPolicy.configureValues(app.chart(), cli, yaml, config, cidr);
+ Path archive = BuiltInHelmCharts.packageChart(app);
+ Path values = null;
+ try {
+ values = Files.createTempFile("builtin-pipeline-", ".yaml");
+ Files.writeString(values, new Yaml().dump(yaml));
+ List command = new ArrayList<>(List.of("helm", "template", "pipeline", archive.toString(),
+ "--namespace", "default", "--values", values.toString()));
+ command.addAll(HelmChartService.buildHelmSetArguments(cli));
+ var process = new ProcessBuilder(command).redirectErrorStream(true).start();
+ String text = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(process.waitFor()).as("%s\n%s", command, text).isZero();
+ List> docs = new ArrayList<>();
+ for (Object doc : new Yaml().loadAll(text)) if (doc instanceof Map) docs.add(map(doc));
+ return new Rendered(text, docs);
+ } finally {
+ Files.deleteIfExists(archive);
+ if (values != null) Files.deleteIfExists(values);
+ }
+ }
+
+ private record Rendered(String text, List> docs) {
+ Map ofKind(String kind) {
+ return docs.stream().filter(doc -> kind.equals(doc.get("kind"))).findFirst().orElseThrow();
+ }
+ List kinds() { return docs.stream().map(doc -> doc.get("kind")).toList(); }
+ }
+ @SuppressWarnings("unchecked")
+ private static Map map(Object value) { return (Map) value; }
+ private static Object first(Object value) { return ((List>) value).get(0); }
+}
diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicyTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicyTest.java
new file mode 100644
index 0000000..0561d53
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicyTest.java
@@ -0,0 +1,50 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.util.*;
+import org.junit.jupiter.api.Test;
+import io.fabric8.kubernetes.api.model.storage.StorageClassBuilder;
+import io.fabric8.kubernetes.client.server.mock.*;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import kr.co.mcmp.softwarecatalog.application.dto.*;
+import static org.assertj.core.api.Assertions.*;
+
+@EnableKubernetesMockClient(crud = true)
+class BuiltInHelmPolicyTest {
+ KubernetesClient client;
+ @Test void persistentAppsRejectHttpIngressHpaAndMultipleReplicas() {
+ for (String app : List.of("redis", "mariadb", "postgresql")) {
+ var chart = BuiltInHelmChartsTest.chart(app);
+ var config = DeploymentConfigDTO.builder().minReplicas(1).hpaEnabled(false).ingressEnabled(false).build();
+ assertThatCode(() -> BuiltInHelmPolicy.validate(chart, config)).doesNotThrowAnyException();
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validate(chart, config.toBuilder().ingressEnabled(true).build())).hasMessageContaining("TCP");
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validate(chart, config.toBuilder().hpaEnabled(true).build())).hasMessageContaining("HPA");
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validate(chart, config.toBuilder().minReplicas(2).build())).hasMessageContaining("one persistent");
+ }
+ }
+ @Test void storageChecksMissingClassSizeAndProviderMinimum() {
+ var chart = BuiltInHelmChartsTest.chart("mariadb");
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validateStorage(chart, client, new DeploymentRequest())).hasMessageContaining("Select a StorageClass");
+ var req = DeploymentRequest.builder().additionalConfig(Map.of("storageClass", "missing")).build();
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validateStorage(chart, client, req)).hasMessageContaining("existing StorageClass");
+ client.storage().v1().storageClasses().resource(new StorageClassBuilder().withNewMetadata().withName("ali-ssd").endMetadata()
+ .withProvisioner("diskplugin.csi.alibabacloud.com").addToParameters("type", "cloud_ssd").build()).create();
+ req.setAdditionalConfig(Map.of("storageClass", "ali-ssd", "storageSize", "10Gi"));
+ assertThatThrownBy(() -> BuiltInHelmPolicy.validateStorage(chart, client, req)).hasMessageContaining("20Gi");
+ req.setAdditionalConfig(Map.of("storageClass", "ali-ssd", "storageSize", "20Gi"));
+ assertThatCode(() -> BuiltInHelmPolicy.validateStorage(chart, client, req)).doesNotThrowAnyException();
+ for (String size : List.of("0Gi", "-1Gi", "1", "1Gi,service.type=LoadBalancer", "1.5Gi", "10000Gi"))
+ assertThatThrownBy(() -> BuiltInHelmPolicy.size(Map.of("storageSize", size))).isInstanceOf(IllegalArgumentException.class);
+ }
+ @Test void preservesPersistenceAndTypedStorageClassWhileLeavingOtherChartsAlone() {
+ Map cli = new HashMap<>(Map.of("persistence.enabled", "false"));
+ Map yaml = new HashMap<>();
+ var req = DeploymentRequest.builder().additionalConfig(Map.of("storageClass", "standard", "storageSize", "1Gi")).build();
+ BuiltInHelmPolicy.configure(BuiltInHelmChartsTest.chart("redis"), req, cli, yaml);
+ assertThat(cli).containsEntry("persistence.enabled", "true").containsEntry("securityContext.runAsNonRoot", "true");
+ assertThat(yaml.get("persistence")).isEqualTo(Map.of("enabled", true, "storageClass", "standard", "size", "1Gi", "retain", true));
+ var external = BuiltInHelmChartsTest.chart("redis"); external.setRepositoryName("external");
+ cli.clear(); yaml.clear();
+ BuiltInHelmPolicy.configure(external, req, cli, yaml);
+ assertThat(cli).isEmpty(); assertThat(yaml).isEmpty();
+ }
+}
diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmRenderTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmRenderTest.java
new file mode 100644
index 0000000..d2c12ed
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmRenderTest.java
@@ -0,0 +1,62 @@
+package kr.co.mcmp.softwarecatalog.kubernetes.service;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.*;
+import org.junit.jupiter.api.*;
+import org.yaml.snakeyaml.Yaml;
+import static org.assertj.core.api.Assertions.*;
+
+class BuiltInHelmRenderTest {
+ @BeforeAll static void requireHelm() {
+ try { Assumptions.assumeTrue(new ProcessBuilder("helm", "version", "--short").start().waitFor() == 0, "Helm CLI required"); }
+ catch (Exception e) { Assumptions.abort("Helm CLI not installed"); }
+ }
+ @Test void rendersAllFiveWithReadinessNonRootInternalServicesAndPersistentSecrets() throws Exception {
+ for (var app : BuiltInHelmCharts.APPS) {
+ Path path = BuiltInHelmCharts.packageChart(app);
+ try {
+ run(true, "lint", path.toString(), "--strict");
+ String output = run(true, "template", "smoke-" + app.chart(), path.toString(), "--namespace", "default");
+ List> docs = new ArrayList<>();
+ for (Object obj : new Yaml().loadAll(output)) if (obj instanceof Map) docs.add((Map)obj);
+ Map deploy = ofKind(docs, "Deployment");
+ Map pod = map(map(map(deploy.get("spec")).get("template")).get("spec"));
+ assertThat(map(pod.get("securityContext"))).containsEntry("runAsNonRoot", true);
+ var container = map(((List>)pod.get("containers")).get(0));
+ assertThat(container).containsKeys("readinessProbe", "livenessProbe");
+ assertThat(map(ofKind(docs, "Service").get("spec"))).containsEntry("type", "ClusterIP");
+ if (app.persistent()) {
+ assertThat(ofKind(docs, "PersistentVolumeClaim")).isNotNull();
+ assertThat(map(ofKind(docs, "Secret").get("data"))).containsKeys("password", "root-password");
+ run(false, "template", "bad", path.toString(), "--set", "replicaCount=2");
+ run(false, "template", "bad", path.toString(), "--set", "autoscaling.enabled=true");
+ run(false, "template", "bad", path.toString(), "--set", "ingress.enabled=true");
+ run(false, "template", "bad", path.toString(), "--set", "persistence.enabled=false");
+ } else {
+ for (String clazz : List.of("nginx", "public-iks-k8s-nginx")) {
+ String ingress = run(true, "template", "web", path.toString(), "--set", "ingress.enabled=true",
+ "--set", "ingress.className=" + clazz, "--set", "ingress.hosts[0].host=app.example.com",
+ "--set", "ingress.hosts[0].paths[0].path=/", "--set", "ingress.hosts[0].paths[0].pathType=Prefix",
+ "--set", "ingress.tls[0].secretName=test-tls", "--set", "ingress.tls[0].hosts[0]=app.example.com");
+ assertThat(ingress).contains("kind: Ingress", clazz, "test-tls", "app.example.com");
+ }
+ assertThat(run(true, "template", "web", path.toString(), "--set", "autoscaling.enabled=true"))
+ .contains("kind: HorizontalPodAutoscaler");
+ }
+ run(false, "template", "bad", path.toString(), "--set", "service.type=LoadBalancer");
+ } finally { Files.deleteIfExists(path); }
+ }
+ }
+ private Map ofKind(List> docs, String kind) {
+ return docs.stream().filter(d -> kind.equals(d.get("kind"))).findFirst().orElseThrow();
+ }
+ private static Map map(Object value) { return (Map)value; }
+ private String run(boolean success, String... args) throws Exception {
+ List cmd = new ArrayList<>(List.of("helm")); cmd.addAll(List.of(args));
+ var process = new ProcessBuilder(cmd).redirectErrorStream(true).start();
+ String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(process.waitFor() == 0).as("%s\n%s", cmd, output).isEqualTo(success);
+ return output;
+ }
+}
diff --git a/src/test/java/kr/co/mcmp/util/BuiltInHelmCatalogTest.java b/src/test/java/kr/co/mcmp/util/BuiltInHelmCatalogTest.java
new file mode 100644
index 0000000..76182ff
--- /dev/null
+++ b/src/test/java/kr/co/mcmp/util/BuiltInHelmCatalogTest.java
@@ -0,0 +1,57 @@
+package kr.co.mcmp.util;
+
+import java.util.UUID;
+import org.junit.jupiter.api.*;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import static org.assertj.core.api.Assertions.*;
+
+class BuiltInHelmCatalogTest {
+ private JdbcTemplate jdbc;
+ private DatabaseInitializer initializer;
+ @BeforeEach void setUp() {
+ jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1", "sa", ""));
+ jdbc.execute("""
+ CREATE TABLE SOFTWARE_CATALOG (ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ TITLE VARCHAR, DESCRIPTION VARCHAR, SUMMARY VARCHAR, CATEGORY VARCHAR, MIN_CPU DOUBLE PRECISION,
+ RECOMMENDED_CPU DOUBLE PRECISION, MIN_MEMORY DOUBLE PRECISION, RECOMMENDED_MEMORY DOUBLE PRECISION,
+ MIN_DISK INTEGER, RECOMMENDED_DISK INTEGER, CPU_THRESHOLD DOUBLE PRECISION, MEMORY_THRESHOLD DOUBLE PRECISION,
+ MIN_REPLICAS INTEGER, MAX_REPLICAS INTEGER, HPA_ENABLED BOOLEAN, DEFAULT_PORT INTEGER, INGRESS_ENABLED BOOLEAN,
+ CREATED_AT TIMESTAMP, UPDATED_AT TIMESTAMP)
+ """);
+ jdbc.execute("""
+ CREATE TABLE HELM_CHART (CATALOG_ID BIGINT UNIQUE, CHART_NAME VARCHAR, CHART_VERSION VARCHAR,
+ CHART_REPOSITORY_URL VARCHAR, REPOSITORY_NAME VARCHAR, REPOSITORY_DISPLAY_NAME VARCHAR,
+ REPOSITORY_OFFICIAL BOOLEAN, PACKAGE_ID VARCHAR, NORMALIZED_NAME VARCHAR, APP_VERSION VARCHAR,
+ DESCRIPTION VARCHAR, CATEGORY VARCHAR, IMAGE_REPOSITORY VARCHAR, TAG VARCHAR, HAS_VALUES_SCHEMA BOOLEAN)
+ """);
+ jdbc.execute("CREATE TABLE SOFTWARE_CATALOG_REF (CATALOG_ID BIGINT, REF_IDX INTEGER, REF_VALUE VARCHAR, REF_DESC VARCHAR, REF_TYPE VARCHAR)");
+ initializer = new DatabaseInitializer();
+ ReflectionTestUtils.setField(initializer, "jdbcTemplate", jdbc);
+ }
+ private void sync() { ReflectionTestUtils.invokeMethod(initializer, "ensureBuiltInHelmCatalogs"); }
+ @Test void cleanDatabaseCreatesFiveAndRepeatedStartupIsIdempotent() {
+ sync(); sync();
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG", Integer.class)).isEqualTo(5);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM HELM_CHART", Integer.class)).isEqualTo(5);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG_REF WHERE REF_VALUE='storage-class'", Integer.class)).isEqualTo(3);
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG_REF", Integer.class)).isEqualTo(16);
+ }
+ @Test void reusesExistingVmCatalogWithoutResettingSettings() {
+ jdbc.update("INSERT INTO SOFTWARE_CATALOG (TITLE, DEFAULT_PORT, HPA_ENABLED, MIN_CPU) VALUES ('Redis', 16379, true, 3)");
+ sync(); sync();
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG WHERE TITLE='Redis'", Integer.class)).isEqualTo(1);
+ assertThat(jdbc.queryForObject("SELECT DEFAULT_PORT FROM SOFTWARE_CATALOG WHERE TITLE='Redis'", Integer.class)).isEqualTo(16379);
+ assertThat(jdbc.queryForObject("SELECT MIN_CPU FROM SOFTWARE_CATALOG WHERE TITLE='Redis'", Double.class)).isEqualTo(3d);
+ assertThat(jdbc.queryForObject("SELECT HPA_ENABLED FROM SOFTWARE_CATALOG WHERE TITLE='Redis'", Boolean.class)).isTrue();
+ }
+ @Test void neverOverwritesACustomHelmMappingOrAddsOurCapabilitiesToIt() {
+ jdbc.update("INSERT INTO SOFTWARE_CATALOG (TITLE) VALUES ('Redis')");
+ jdbc.update("INSERT INTO HELM_CHART (CATALOG_ID, CHART_NAME, CHART_REPOSITORY_URL) SELECT ID, 'myredis', 'https://custom.example.org' FROM SOFTWARE_CATALOG");
+ sync(); sync();
+ assertThat(jdbc.queryForObject("SELECT CHART_NAME FROM HELM_CHART WHERE CATALOG_ID=1", String.class)).isEqualTo("myredis");
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM SOFTWARE_CATALOG_REF WHERE CATALOG_ID=1", Integer.class)).isZero();
+ assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM HELM_CHART", Integer.class)).isEqualTo(5);
+ }
+}