From 30f205b21c882b2887ea0b6c1879038d2fc970c4 Mon Sep 17 00:00:00 2001 From: eunjuny Date: Mon, 21 Sep 2026 10:52:27 +0900 Subject: [PATCH 1/2] fix: restore daily archive aggregation and add safe backfill tooling --- applicationFE/scripts/test-vm-clustering.mjs | 3 +- scripts/archive/README.md | 48 +++++++ scripts/archive/backfill-daily-metrics.sql | 99 ++++++++++++++ scripts/archive/test-backfill.sh | 76 +++++++++++ .../ResourceMetricsHistoryRepository.java | 38 +++--- .../ResourceMetricsHistoryRepositoryTest.java | 123 ++++++++++++++++++ 6 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 scripts/archive/README.md create mode 100644 scripts/archive/backfill-daily-metrics.sql create mode 100644 scripts/archive/test-backfill.sh create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepositoryTest.java diff --git a/applicationFE/scripts/test-vm-clustering.mjs b/applicationFE/scripts/test-vm-clustering.mjs index 7a9a9b76..70061d78 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/scripts/archive/README.md b/scripts/archive/README.md new file mode 100644 index 00000000..0d29659d --- /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 00000000..5bb058e4 --- /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 00000000..d265b079 --- /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/repository/ResourceMetricsHistoryRepository.java b/src/main/java/kr/co/mcmp/softwarecatalog/application/repository/ResourceMetricsHistoryRepository.java index f09f7253..c1b332fd 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/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 00000000..864e9444 --- /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(); + } +} From 92626d647d9eb73f4d48abefd915f2b6643fca4f Mon Sep 17 00:00:00 2001 From: eunjuny Date: Wed, 23 Sep 2026 09:35:14 +0900 Subject: [PATCH 2/2] feat: bundle five Kubernetes application charts with persistent storage safeguards --- applicationFE/scripts/test-builtin-helm.mjs | 66 ++++++++ .../applicationInstallationForm.vue | 33 +++- .../application/dto/HelmChartDTO.java | 4 + .../kubernetes/service/BuiltInHelmCharts.java | 69 ++++++++ .../kubernetes/service/BuiltInHelmPolicy.java | 61 +++++++ .../kubernetes/service/HelmChartService.java | 18 +- .../kubernetes/service/HelmIngressValues.java | 1 + .../service/KubernetesDeployService.java | 2 + .../kr/co/mcmp/util/DatabaseInitializer.java | 45 +++++ src/main/resources/helm/README.md | 70 ++++++++ src/main/resources/helm/apache/values.yaml | 29 ++++ .../helm/common/templates/config.yaml | 11 ++ .../resources/helm/common/templates/hpa.yaml | 16 ++ .../helm/common/templates/ingress.yaml | 28 ++++ .../resources/helm/common/templates/pvc.yaml | 17 ++ .../helm/common/templates/secret.yaml | 20 +++ .../helm/common/templates/service.yaml | 10 ++ .../helm/common/templates/workload.yaml | 64 ++++++++ src/main/resources/helm/common/values.yaml | 14 ++ src/main/resources/helm/mariadb/values.yaml | 18 ++ .../resources/helm/postgresql/values.yaml | 16 ++ src/main/resources/helm/redis/values.yaml | 15 ++ src/main/resources/helm/tomcat/values.yaml | 16 ++ .../service/BuiltInHelmChartsTest.java | 67 ++++++++ .../service/BuiltInHelmPipelineTest.java | 154 ++++++++++++++++++ .../service/BuiltInHelmPolicyTest.java | 50 ++++++ .../service/BuiltInHelmRenderTest.java | 62 +++++++ .../co/mcmp/util/BuiltInHelmCatalogTest.java | 57 +++++++ 28 files changed, 1027 insertions(+), 6 deletions(-) create mode 100644 applicationFE/scripts/test-builtin-helm.mjs create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmCharts.java create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicy.java create mode 100644 src/main/resources/helm/README.md create mode 100644 src/main/resources/helm/apache/values.yaml create mode 100644 src/main/resources/helm/common/templates/config.yaml create mode 100644 src/main/resources/helm/common/templates/hpa.yaml create mode 100644 src/main/resources/helm/common/templates/ingress.yaml create mode 100644 src/main/resources/helm/common/templates/pvc.yaml create mode 100644 src/main/resources/helm/common/templates/secret.yaml create mode 100644 src/main/resources/helm/common/templates/service.yaml create mode 100644 src/main/resources/helm/common/templates/workload.yaml create mode 100644 src/main/resources/helm/common/values.yaml create mode 100644 src/main/resources/helm/mariadb/values.yaml create mode 100644 src/main/resources/helm/postgresql/values.yaml create mode 100644 src/main/resources/helm/redis/values.yaml create mode 100644 src/main/resources/helm/tomcat/values.yaml create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmChartsTest.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPipelineTest.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmPolicyTest.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/BuiltInHelmRenderTest.java create mode 100644 src/test/java/kr/co/mcmp/util/BuiltInHelmCatalogTest.java diff --git a/applicationFE/scripts/test-builtin-helm.mjs b/applicationFE/scripts/test-builtin-helm.mjs new file mode 100644 index 00000000..c1884c1f --- /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/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue index b92fdcc7..80b6675d 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue @@ -549,10 +549,11 @@ -
- +
+

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">