Implementovatelná UC pro fázi F5 — aktivace HARD scale-to-zero enforcement při SUSPENDED stavu billing účtu (DP-4-A). Staví na F4 (UC-10014) a vyžaduje per-environment namespace cut-over živých tenantů.
- F2 (UC-10012) zavedla SOFT enforcement:
hosting_billing_account.status = SUSPENDEDje čistě DB záznam, žádná infra akce. F5 aktivuje HARD enforcement: skutečnéscale(0)K8s Deploymentů published prod app podů ({project-slug}Deployment v namespace prostředí projektu). - DP-4-A (LOCKED 2026-05-21): při neuhrazené hosting faktuře po dunningu (F2 dunning
pipeline: 3 retry +0/+2/+5 dní →
PAST_DUE→ 7 dní grace →SUSPENDED) se suspendují jen published prod app pody. Worker (talkide-worker), dev preview pody a dev-loop Joby (gradle/test, Kaniko) nejsou dotčeny — uživatel dál vyvíjí i s nezaplacenou hosting fakturou. - Published prod vs preview rozlišení:
project.environment_id+deploy_mode(F4NamespaceResolverkontext). Pouze Deployment{project-slug}v namespace{tenant-slug}-{env-slug}cílového prostředí projektu. Preview Deployment{project-slug}-previewv DEFAULT namespace{tenant-slug}-talkidese nesuspenduje. - Resume po úhradě: Stripe webhook
payment_intent.succeededsmetadata.fund=HOSTING(existující UC-10006 větev) →hosting_billing_account.status = ACTIVE→ enforcement service scale-back na původní replicas (nebo fixníreplicas = 1jako bezpečný default). - Prerequisity:
- F4 (UC-10014) nasazena — per-environment namespace cut-over proběhl,
NamespaceResolverfunguje,project.environment_idbackfill hotov. EnvironmentEntity.namespace_refnese správný tvar{tenant-slug}-{env-slug}.- Worker (ADR-024, druhý tým) v DEFAULT namespace
{tenant-slug}-talkide— F5 ho nikdy nescaluje na 0.
- F4 (UC-10014) nasazena — per-environment namespace cut-over proběhl,
- DB změna: nový sloupec
hosting_billing_account.suspended_at(TIMESTAMPTZ, nullable) — Liquibase changeset0043. Žádná jiná schema změna;environment.statusenum hodnotuSUSPENDED(F3) F5 nenastavuje — enforcement jde přeshosting_billing_account.status, ne přesenvironment.status. - Spring profil:
@Profile("production")(NIKDY@Profile("prod")). - Admin-only endpointy: silent-probe (401 i pro ne-admina).
- Related: UC-10012 F2 SOFT enforcement, UC-10014 F4 cut-over, ADR-026, UC-10009 DESIGN DP-4-A rationale.
Přehled subsystémů F5
F5 přidává dva synchronizační body do existující dunning pipeline (F2):
| # | Bod | Trigger | Akce |
|---|---|---|---|
| S1 | SUSPENDED → hard suspend | HostingDunningBatch nastaví status = SUSPENDED | HostingEnforcementService.suspendPublishedApps(tenantId) |
| S2 | SUSPENDED → resume | Stripe webhook payment_intent.succeeded (fund=HOSTING) | HostingEnforcementService.resumePublishedApps(tenantId) |
A. Sekvence — hard suspend při SUSPENDED stavu
sequenceDiagram
participant SCH as Scheduler
participant DUNN as HostingDunningBatch<br/>(ShedLock)
participant ENF as HostingEnforcementService
participant PROJ as ProjectRepository
participant K8S as KubernetesClient
participant DB
SCH->>+DUNN: @Scheduled denně 06:00 UTC
DUNN->>DB: SELECT * FROM hosting_billing_account<br/>WHERE status='PAST_DUE'<br/>AND past_due_since < NOW()-7d
loop pro každý tenant v grace po 7 dnech
DUNN->>DB: UPDATE hosting_billing_account<br/>SET status='SUSPENDED', suspended_at=NOW()
DUNN->>+ENF: suspendPublishedApps(tenantId)
ENF->>+PROJ: findPublishedByTenantId(tenantId)
Note over PROJ: findAll WHERE tenant_id=? AND status IN ('PUBLISHED','UPDATED')<br/>JPQL: @Query("SELECT p FROM ProjectEntity p WHERE p.tenantId = :tenantId<br/>AND p.status IN ('PUBLISHED', 'UPDATED')")<br/>Implementátor musí tuto metodu přidat do ProjectRepository.
PROJ-->>-ENF: List[ProjectEntity] (published projekty)
loop pro každý published projekt
ENF->>+K8S: getDeployment(namespace=resolveNamespace(project.id, "prod"), name=project.slug)
alt Deployment existuje
K8S-->>ENF: Deployment (replicas=N)
ENF->>DB: UPSERT hosting_enforcement_log<br/>(tenant_id, project_id, namespace, deployment_name,<br/>previous_replicas=N, action=SUSPEND, created_at=NOW())
ENF->>K8S: patchDeployment(namespace, name,<br/>spec.replicas=0)
K8S-->>-ENF: ok
else Deployment nenalezen
Note over ENF: WARN log — projekt publishován ale Deployment chybí<br/>→ skip (idempotentní)
end
end
ENF-->>-DUNN: suspended_count
DUNN->>DUNN: sendSuspendedEmail(tenant, suspended_count)
end
DUNN-->>-SCH: done
Identifikace „published prod” Deploymentu
Deployment, který se scaluje na 0, se identifikuje takto:
| Kritérium | Hodnota | Poznámka |
|---|---|---|
| Kubernetes namespace | resolveNamespace(project.id, "prod") | Via NamespaceResolver (F4); pro DEFAULT prostředí = {tenant-slug}-talkide, pro user-created = {tenant-slug}-{env-slug} |
| Deployment name | project.slug | Published Deployment (bez -preview suffixu — to je preview) |
| Filter — jen published | project.status IN ('PUBLISHED', 'UPDATED') | Preview se jmenuje {project-slug}-preview a nescaluje se |
Preview deployment NIKDY nescalovat.
{project-slug}-previewje v DEFAULT namespace ({tenant-slug}-talkide) a nese UUID host — F5 ho nikdy nesuspenduje (DP-4-A: dev preview běží dál).
B. Sekvence — resume po úhradě
sequenceDiagram
participant STR as Stripe Webhook
participant WH as StripeWebhookController
participant WHS as StripeWebhookService
participant ENF as HostingEnforcementService
participant PROJ as ProjectRepository
participant K8S as KubernetesClient
participant DB
STR->>+WH: POST /api/v1/stripe/webhook<br/>event: payment_intent.succeeded<br/>metadata: {fund=HOSTING, invoice_id=?}
WH->>+WHS: handlePaymentIntentSucceeded(event)
WHS->>DB: FIND hosting_invoice WHERE id = metadata.invoice_id
WHS->>DB: INSERT hosting_credit_ledger<br/>(type=CREDIT, source=INVOICE_SETTLEMENT, ...)
WHS->>DB: UPDATE hosting_invoice SET status=PAID, settled_at=NOW()
WHS->>DB: UPDATE hosting_billing_account<br/>SET accrued_charged_usd=0,<br/>current_period_start=NOW(),<br/>current_period_end=next1stOfMonth(),<br/>status=ACTIVE,<br/>suspended_at=NULL
WHS->>+ENF: resumePublishedApps(tenantId)
ENF->>+PROJ: findPublishedByTenantId(tenantId)
PROJ-->>-ENF: List[ProjectEntity]
loop pro každý published projekt
ENF->>+DB: SELECT previous_replicas FROM hosting_enforcement_log<br/>WHERE tenant_id=? AND project_id=? AND action='SUSPEND'<br/>ORDER BY created_at DESC LIMIT 1
DB-->>ENF: previous_replicas (nebo null → default 1)
ENF->>K8S: patchDeployment(namespace, name,<br/>spec.replicas = max(previous_replicas, 1))
K8S-->>-ENF: ok
ENF->>DB: INSERT hosting_enforcement_log<br/>(tenant_id, project_id, namespace, deployment_name,<br/>previous_replicas=null, action=RESUME, created_at=NOW())
end
ENF-->>-WHS: resumed_count
WHS-->>-WH: ok
WH-->>-STR: 200 OK
C. API — admin enforcement status endpoint
GET /api/v1/admin/billing/hosting/enforcement
Admin-only (ROLE_ADMIN, silent-probe). Vrátí přehled aktuálně suspendovaných tenantů a stav jejich K8s Deploymentů.
200 OK HostingEnforcementStatusResponse:
{
"generatedAt": "2026-05-21T10:00:00Z",
"suspendedTenants": [
{
"tenantId": 42,
"tenantSlug": "popelkam",
"suspendedAt": "2026-05-14T06:05:00Z",
"projects": [
{
"projectId": 7,
"projectSlug": "todo-list",
"namespace": "popelkam-talkide",
"deploymentName": "todo-list",
"currentReplicas": 0,
"previousReplicas": 1,
"suspendedAt": "2026-05-14T06:05:22Z"
}
]
}
]
}
401 Unauthorized ErrorResponse (ne-admin i neautentizovaný — silent-probe):
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
D. API — admin ruční resume (nouzový override)
POST /api/v1/admin/billing/hosting/enforcement/{tenantId}/resume
Admin-only (ROLE_ADMIN, silent-probe). Ruční obnovení suspendovaného tenanta bez čekání na Stripe webhook (např. při interní chybě webhook zpracování nebo dohodě s uživatelem).
RequestBody — prázdné tělo (akce je idempotentní).
200 OK HostingEnforcementResumeResponse:
{
"tenantId": 42,
"resumedProjects": 1,
"message": "Tenant 42 manually resumed by admin"
}
404 Not Found ErrorResponse (tenant neexistuje nebo není ve stavu SUSPENDED):
{
"code": "NOT_FOUND",
"message": "Tenant not found or not suspended"
}
401 Unauthorized ErrorResponse:
{
"code": "AUTHENTICATION_FAILED",
"message": "Authentication required"
}
Datový model — delta F5
Nové / rozšířené tabulky
hosting_billing_account — nový sloupec suspended_at
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
suspended_at | TIMESTAMPTZ | NULL | Čas přechodu do SUSPENDED; NULL pokud není suspendován nebo po resume |
hosting_enforcement_log (nová tabulka — audit trail)
| Sloupec | Typ | Constraints | Popis |
|---|---|---|---|
id | BIGINT | PK auto-increment | |
tenant_id | BIGINT | NOT NULL, FK → tenants(id) | |
project_id | BIGINT | NULL, FK → projects(id) | Projekt, jehož Deployment byl dotčen |
namespace | VARCHAR(100) | NOT NULL | K8s namespace v okamžiku akce |
deployment_name | VARCHAR(100) | NOT NULL | Jméno K8s Deploymentu |
previous_replicas | INT | NULL | Počet replik před suspend (pro resume); NULL pro RESUME záznamy |
action | VARCHAR(16) | NOT NULL | SUSPEND nebo RESUME |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() |
Proč
hosting_enforcement_lognestojí naenvironment_id? Při suspendu může prostředí teoreticky procházet migrací —deployment_name+namespacesnapshot v okamžiku akce je auditně čistší než FK na prostředí, které se mohlo přejmenovat.project_idFK stačí pro dohledatelnost.
Mermaid ER (delta F5)
erDiagram
HOSTING_BILLING_ACCOUNT {
bigint id
bigint tenant_id
string status
timestamp suspended_at
numeric accrued_charged_usd
}
HOSTING_ENFORCEMENT_LOG {
bigint id
bigint tenant_id
bigint project_id
string namespace
string deployment_name
int previous_replicas
string action
timestamp created_at
}
PROJECT {
bigint id
bigint tenant_id
bigint environment_id
string slug
string status
}
HOSTING_BILLING_ACCOUNT ||--o{ HOSTING_ENFORCEMENT_LOG : triggers
PROJECT ||--o{ HOSTING_ENFORCEMENT_LOG : logged_for
Liquibase changesety (F5)
PRODUCTION fáze: immutable pravidla. F4 použila
0042. F5 začíná od0043.
| # | Soubor | Obsah |
|---|---|---|
| 1 | 0043-add-suspended-at-to-hosting-billing-account.xml | ALTER TABLE hosting_billing_account ADD COLUMN suspended_at TIMESTAMPTZ NULL |
| 2 | 0044-create-hosting-enforcement-log.xml | Nová tabulka hosting_enforcement_log + index idx_enforcement_log_tenant_project na (tenant_id, project_id, action, created_at DESC) |
Changeset 0043
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<changeSet id="0043-add-suspended-at-to-hosting-billing-account" author="talkide">
<comment>F5 — HARD enforcement: suspended_at timestamp pro audit trail a resume.</comment>
<addColumn tableName="hosting_billing_account">
<column name="suspended_at" type="TIMESTAMPTZ"/>
</addColumn>
<rollback>
<dropColumn tableName="hosting_billing_account" columnName="suspended_at"/>
</rollback>
</changeSet>
</databaseChangeLog>
Changeset 0044
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<changeSet id="0044-create-hosting-enforcement-log" author="talkide">
<comment>F5 — audit trail pro SUSPEND/RESUME akce na K8s Deploymentech.</comment>
<createTable tableName="hosting_enforcement_log">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="tenant_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="project_id" type="BIGINT"/>
<column name="namespace" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="deployment_name" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="previous_replicas" type="INT"/>
<column name="action" type="VARCHAR(16)">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="hosting_enforcement_log"
indexName="idx_enforcement_log_tenant_project">
<column name="tenant_id"/>
<column name="project_id"/>
<column name="action"/>
<column name="created_at" descending="true"/>
</createIndex>
<rollback>
<dropTable tableName="hosting_enforcement_log"/>
</rollback>
</changeSet>
</databaseChangeLog>
Backend implementace — klíčové komponenty
Nové Kotlin třídy (package struktura)
billing/hosting/
enforcement/
HostingEnforcementService.kt (@Service, @Transactional — core logika)
HostingEnforcementLogRepository.kt (JPA)
HostingEnforcementLog.kt (JPA entity)
controller/
AdminHostingEnforcementController.kt (GET + POST admin endpoints)
HostingEnforcementService — jádro enforcement logiky
@Service
@Transactional
class HostingEnforcementService(
private val projectRepository: ProjectRepository,
private val environmentRepository: EnvironmentRepository,
private val billingAccountRepository: HostingBillingAccountRepository,
private val enforcementLogRepository: HostingEnforcementLogRepository,
private val kubernetesClient: KubernetesClient,
private val namespaceResolver: NamespaceResolver // F4 komponenta
) {
/**
* Scale-to-zero všechny published prod Deploymenty tenanta.
* Idempotentní: Deployment s replicas=0 se přeskočí (WARN log).
* Nikdy nescaluje: preview Deploymenty (-preview suffix), talkide-worker.
*
* POZOR: findPublishedByTenantId musí implementátor přidat do ProjectRepository jako:
* @Query("SELECT p FROM ProjectEntity p WHERE p.tenantId = :tenantId AND p.status IN ('PUBLISHED', 'UPDATED')")
* fun findPublishedByTenantId(@Param("tenantId") tenantId: Long): List<ProjectEntity>
*/
fun suspendPublishedApps(tenantId: Long): Int {
val publishedProjects = projectRepository.findPublishedByTenantId(tenantId)
var count = 0
for (project in publishedProjects) {
val ns = namespaceResolver.resolveNamespace(project.id, "prod")
val deploymentName = project.slug // published = bez "-preview" suffixu
try {
val deployment = kubernetesClient.apps().deployments()
.inNamespace(ns).withName(deploymentName).get()
?: continue // Deployment neexistuje — skip + WARN
val currentReplicas = deployment.spec?.replicas ?: 1
if (currentReplicas == 0) {
log.warn("F5 suspend skipped — already at replicas=0: project=${project.slug} ns=$ns (idempotent)")
continue // Již suspendován — idempotentní skip
}
enforcementLogRepository.save(HostingEnforcementLog(
tenantId = tenantId,
projectId = project.id,
namespace = ns,
deploymentName = deploymentName,
previousReplicas = currentReplicas,
action = EnforcementAction.SUSPEND
))
kubernetesClient.apps().deployments()
.inNamespace(ns).withName(deploymentName)
.scale(0)
count++
} catch (e: Exception) {
// Pokračuj přes ostatní projekty — jeden K8s selhání neblokuje ostatní
log.error("F5 suspend failed for project ${project.slug} in ns $ns", e)
}
}
return count
}
/**
* Obnovení replik pro všechny published Deploymenty tenanta.
* Replicas se obnoví z posledního SUSPEND logu; fallback = 1.
* Nastaví billing_account.status = ACTIVE, suspended_at = NULL.
*/
fun resumePublishedApps(tenantId: Long): Int {
val publishedProjects = projectRepository.findPublishedByTenantId(tenantId)
var count = 0
for (project in publishedProjects) {
val ns = namespaceResolver.resolveNamespace(project.id, "prod")
val deploymentName = project.slug
val lastSuspend = enforcementLogRepository
.findLatestSuspend(tenantId, project.id)
val targetReplicas = maxOf(lastSuspend?.previousReplicas ?: 1, 1)
try {
kubernetesClient.apps().deployments()
.inNamespace(ns).withName(deploymentName)
.scale(targetReplicas)
enforcementLogRepository.save(HostingEnforcementLog(
tenantId = tenantId,
projectId = project.id,
namespace = ns,
deploymentName = deploymentName,
previousReplicas = null,
action = EnforcementAction.RESUME
))
count++
} catch (e: Exception) {
log.error("F5 resume failed for project ${project.slug} in ns $ns", e)
}
}
billingAccountRepository.clearSuspended(tenantId) // suspended_at = NULL
return count
}
}
Napojení do HostingDunningBatch (F2 rozšíření)
// V HostingDunningBatch.run() — stávající F2 kód rozšíříme o volání enforcement:
loop pro každý tenant v grace po 7 dnech {
// EXISTUJÍCÍ F2 kód:
billingAccountRepository.updateStatus(tenantId, BillingStatus.SUSPENDED, suspendedAt = NOW())
mailService.sendSuspendedEmail(tenant)
// NOVÉ F5 rozšíření:
val suspendedCount = hostingEnforcementService.suspendPublishedApps(tenantId)
log.info("F5: suspended $suspendedCount published apps for tenant $tenantId")
}
Napojení do StripeWebhookService (F2 rozšíření)
// V handlePaymentIntentSucceeded, větev metadata.fund == "HOSTING":
// EXISTUJÍCÍ F2 kód:
billingAccountRepository.updateStatusActive(tenantId)
// NOVÉ F5 rozšíření:
val resumedCount = hostingEnforcementService.resumePublishedApps(tenantId)
log.info("F5: resumed $resumedCount published apps for tenant $tenantId")
KubernetesClient mock v testech
F5 přidává K8s API volání → testy musí mockovat KubernetesClient:
// Použít @MockBean KubernetesClient ve SpringBootTest nebo
// Fabric8 MockKubernetes (testcontainer-like approach):
val server = KubernetesMockServer()
server.expect().get().withPath("/apis/apps/v1/namespaces/popelkam-talkide/deployments/todo-list")
.andReturn(200, deploymentWithReplicas(1))
.always()
// Viz TC-F5-3 pro konkrétní scénář
Frontend
F5 nemá přímou FE UI komponentu. Uživatel vidí dopad přes:
BillingStatusBanner(F2) — banner se stavemSUSPENDEDje zobrazitelný od F2; F5 přidává reálný dopad (aplikace skutečně nereaguje na produkčním URL).- Email notifikace (F2 dunning
sendSuspendedEmail) — odeslaný při přechodu do SUSPENDED obsahuje informaci o suspendu prod aplikací a odkaz na billing.
Žádné nové FE komponenty v F5. Billing UI je hotové z F2; F5 je čistě BE/K8s vrstvy.
Backend
Validations — GET /api/v1/admin/billing/hosting/enforcement
| Field | Constraints | Note |
|---|---|---|
| JWT token | not_null, valid, not_expired, ROLE_ADMIN | 401 AUTHENTICATION_FAILED (silent-probe) |
Validations — POST /api/v1/admin/billing/hosting/enforcement/{tenantId}/resume
| Field | Constraints | Note |
|---|---|---|
| JWT token | not_null, valid, not_expired, ROLE_ADMIN | 401 AUTHENTICATION_FAILED (silent-probe) |
| tenantId | positive, existing tenant | 404 NOT_FOUND pokud tenant neexistuje nebo není SUSPENDED |
Invarianty
| Invariant | Enforcement |
|---|---|
Preview Deploymenty (*-preview) se nikdy nescalují | deploymentName = project.slug (bez -preview) — podmínka v suspendPublishedApps |
| talkide-worker se nikdy nescaluje | Worker není v projects tabulce — findPublishedByTenantId ho nevrátí |
suspendPublishedApps idempotentní | Skip pokud currentReplicas == 0; enforcementLogRepository.save je nový řádek (append-only) |
resumePublishedApps idempotentní | scale(targetReplicas) je idempotentní K8s PATCH; duplicitní RESUME log je neškodný |
clearSuspended(tenantId) volán mimo loop projektů | Záměrný design: billing status se nastaví na ACTIVE vždy po pokusu o resume, i když K8s resume selže pro některé projekty. Billing a K8s stav jsou záměrně decoupled — billing odblokuje uživatele okamžitě, K8s konzistenci zajistí retry nebo admin. |
previous_replicas floor = 1 | maxOf(lastSuspend?.previousReplicas ?: 1, 1) — nikdy neresumujeme na 0 |
| K8s chyba neblokuje ostatní projekty | try/catch v loopu; log + pokračuj |
| Spring profil | @Profile("production") na HostingEnforcementService (KubernetesClient není přítomen v dev/test profilu) |
| Liquibase immutability | Pouze nové soubory 0043-* a 0044-*; žádný existující changeset se nesmí editovat |
Test Cases
| ID | GIVEN | WHEN | THEN |
|---|---|---|---|
| TC-F5-1 | Tenant popelkam má 1 published projekt todo-list, hosting_billing_account.status=PAST_DUE, past_due_since = NOW()-8d | HostingDunningBatch.run() spuštěn | hosting_billing_account.status = SUSPENDED, suspended_at nastaven; todo-list Deployment v namespace popelkam-talkide scalován na replicas=0; hosting_enforcement_log má 1 SUSPEND řádek s previous_replicas=1 |
| TC-F5-2 | Tenant má 2 published projekty (app-a, app-b), oba v prostředí popelkam-talkide, billing_account.status = PAST_DUE, grace vypršela | HostingDunningBatch.run() | Oba Deploymenty scalovány na 0; hosting_enforcement_log má 2 SUSPEND záznamy; email odeslán |
| TC-F5-3 | Tenant v SUSPENDED stavu; Stripe webhook payment_intent.succeeded s metadata.fund=HOSTING, invoice_id=? dorazí | StripeWebhookController zpracovává webhook | hosting_invoice.status=PAID; hosting_billing_account.status=ACTIVE, accrued_charged_usd=0, suspended_at=NULL; Deployment todo-list obnovén na replicas=1 (dle SUSPEND logu); hosting_enforcement_log má RESUME záznam |
| TC-F5-4 | hosting_enforcement_log neobsahuje SUSPEND záznam pro projekt (např. projekt publish proběhl po suspendu) | resumePublishedApps(tenantId) | Deployment scalován na replicas=1 (fallback default); žádná výjimka |
| TC-F5-5 | suspendPublishedApps volán 2× (simulace retry) — Deployment má replicas=0 po 1. volání | 2. volání suspendPublishedApps(tenantId) | Skip (idempotentní); žádný nový hosting_enforcement_log SUSPEND záznam pro již-nulový Deployment; replicas zůstává 0 |
| TC-F5-6 | Tenant má 1 published projekt app-x; K8s vrátí 404 (Deployment neexistuje) při suspendu | suspendPublishedApps(tenantId) | WARN log; žádná výjimka; ostatní projekty tenanta nejsou ovlivněny; hosting_billing_account.status = SUSPENDED nastaveno přesto |
| TC-F5-7 | Tenant SUSPENDED; admin zavolá POST /api/v1/admin/billing/hosting/enforcement/{tenantId}/resume | Admin ruční resume | Deployment(y) obnoveny na původní replicas; billing_account.status = ACTIVE; suspended_at = NULL; 200 OK s resumedProjects počtem |
| TC-F5-8 | GET /api/v1/admin/billing/hosting/enforcement — 1 tenant v SUSPENDED, 1 projekt suspendován | Admin zavolá endpoint | 200 OK; suspendedTenants obsahuje 1 záznam s projektem; currentReplicas=0, previousReplicas=1 |
| TC-F5-9 | Ne-admin user | GET /api/v1/admin/billing/hosting/enforcement | 401 AUTHENTICATION_FAILED (silent-probe) |
| TC-F5-10 | Preview Deployment todo-list-preview existuje v DEFAULT namespace; tenant jde do SUSPENDED | HostingDunningBatch.run() → suspendPublishedApps | Preview Deployment todo-list-preview NENÍ scalován; todo-list (published) scalován; decoupling DP-4-A splněn |
| TC-F5-11 | Tenant má talkide-worker Deployment v popelkam-talkide namespace; tenant jde do SUSPENDED | suspendPublishedApps(tenantId) | Worker Deployment NENÍ scalován (není v projects tabulce, findPublishedByTenantId ho nevrátí); DP-4-A splněn |
| TC-F5-12 | Tenant bez published projektů (vše v dev preview nebo žádný projekt) | suspendPublishedApps(tenantId) | suspended_count = 0; billing_account.status = SUSPENDED nastaven (DB záznam); žádný K8s volání |
| TC-F5-13 | Spring kontext spuštěn bez aktivního profilu production (tj. test/local profil) | applicationContext.getBeansOfType(HostingEnforcementService::class.java) zavolán | Vrací prázdnou mapu — bean není přítomen; @Profile("production") je správně aplikován |
| TC-F5-14 | Changeset 0043 aplikován na prod DB s existujícími hosting_billing_account záznamy | Liquibase update | suspended_at sloupec přidán jako NULL; existující záznamy mají suspended_at = NULL; žádná regrese |
| TC-F5-15 | Changeset 0044 aplikován | Liquibase update | Tabulka hosting_enforcement_log vytvořena; index idx_enforcement_log_tenant_project existuje; žádná regrese |
Acceptance kritéria (pro backend developera)
- DP-4-A splněn:
suspendPublishedAppsscaluje na 0 výhradně published prod Deploymenty (project.slug); preview (project.slug + "-preview") a worker nikdy — TC-F5-10 a TC-F5-11 zelené. - Idempotence suspendu: druhý suspend na již-nulový Deployment neprodukuje duplicitní log ani chybu — TC-F5-5 zelený.
- Resume zachovává předchozí replicas: po zaplacení se replicas obnoví na hodnotu před suspendem (min 1) — TC-F5-3 zelený.
- K8s chyba neblokuje: selhání jednoho K8s volání nezastaví zpracování ostatních projektů tenanta — TC-F5-6 zelený.
- Billing status nastaven vždy:
billing_account.status = SUSPENDEDse nastaví i když K8s suspend selže pro všechny projekty — invariant vHostingDunningBatch(K8s chyba se loguje, ale neroluje zpět DB transakci). - Spring profil:
HostingEnforcementServicemá@Profile("production"); v test profilu neaktivní — TC-F5-13 zelený. - Audit trail kompletní: každá SUSPEND/RESUME akce má záznam v
hosting_enforcement_logsprevious_replicassnapshhotem — TC-F5-1 a TC-F5-3 zelené. - Admin endpoints fungují: GET enforcement report + POST ruční resume — TC-F5-7 a TC-F5-8 zelené; silent-probe (ne-admin = 401) — TC-F5-9 zelený.
- Liquibase changesety nezpůsobí regresi:
./gradlew testzelené po aplikaci0043a0044— TC-F5-14 a TC-F5-15 zelené. - F2 testy zelené:
HostingDunningBatchtesty z UC-10012 stále zelené (rozšíření je additivní — nové volánísuspendPublishedAppslze mockovat v existujících testech).
Závislosti a předpoklady
| Závislost | Stav |
|---|---|
F1 nasazena (UC-10010, environment tabulka) | Musí být PŘED F5 |
F2 nasazena (UC-10012, dunning pipeline, hosting_billing_account) | Musí být PŘED F5 |
| F3 nasazena (UC-10013, user-created prostředí) | Musí být PŘED F5 |
F4 nasazena (UC-10014, NamespaceResolver, per-env namespace cut-over) | Tvrdá závislost — F5 volá NamespaceResolver.resolveNamespace(projectId: Long, mode: String) s mode="prod" |
ProjectEntity.status enum (PUBLISHED, UPDATED, DRAFT, …) a metoda findPublishedByTenantId v ProjectRepository | F5 filtruje projekty přes status IN ('PUBLISHED', 'UPDATED'); metoda musí být přidána (viz JPQL v KDoc suspendPublishedApps) |
KubernetesClient (Fabric8) dostupný v BE classpath | Existuje z K8sAppDeployer; F5 jen přidává @Autowired bean |
| Mailgun (ADR-025) funkční | Pro sendSuspendedEmail (F2 komponenta, beze změny) |
HostingEnforcementService bean @Profile("production") — dev/test profil bez K8s | Zabrání NPE v dev prostředí kde KubernetesClient není nakonfigurován |
Thanks for the feedback.