Internal Documentation internal
TalkIDE internal documentation

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 = SUSPENDED je č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 (F4 NamespaceResolver kontext). Pouze Deployment {project-slug} v namespace {tenant-slug}-{env-slug} cílového prostředí projektu. Preview Deployment {project-slug}-preview v DEFAULT namespace {tenant-slug}-talkide se nesuspenduje.
  • Resume po úhradě: Stripe webhook payment_intent.succeeded s metadata.fund=HOSTING (existující UC-10006 větev) → hosting_billing_account.status = ACTIVE → enforcement service scale-back na původní replicas (nebo fixní replicas = 1 jako bezpečný default).
  • Prerequisity:
    • F4 (UC-10014) nasazena — per-environment namespace cut-over proběhl, NamespaceResolver funguje, project.environment_id backfill hotov.
    • EnvironmentEntity.namespace_ref nese 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.
  • DB změna: nový sloupec hosting_billing_account.suspended_at (TIMESTAMPTZ, nullable) — Liquibase changeset 0043. Žádná jiná schema změna; environment.status enum hodnotu SUSPENDED (F3) F5 nenastavuje — enforcement jde přes hosting_billing_account.status, ne přes environment.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):

#BodTriggerAkce
S1SUSPENDED → hard suspendHostingDunningBatch nastaví status = SUSPENDEDHostingEnforcementService.suspendPublishedApps(tenantId)
S2SUSPENDED → resumeStripe 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ériumHodnotaPoznámka
Kubernetes namespaceresolveNamespace(project.id, "prod")Via NamespaceResolver (F4); pro DEFAULT prostředí = {tenant-slug}-talkide, pro user-created = {tenant-slug}-{env-slug}
Deployment nameproject.slugPublished Deployment (bez -preview suffixu — to je preview)
Filter — jen publishedproject.status IN ('PUBLISHED', 'UPDATED')Preview se jmenuje {project-slug}-preview a nescaluje se

Preview deployment NIKDY nescalovat. {project-slug}-preview je 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

SloupecTypConstraintsPopis
suspended_atTIMESTAMPTZNULLČas přechodu do SUSPENDED; NULL pokud není suspendován nebo po resume

hosting_enforcement_log (nová tabulka — audit trail)

SloupecTypConstraintsPopis
idBIGINTPK auto-increment
tenant_idBIGINTNOT NULL, FK → tenants(id)
project_idBIGINTNULL, FK → projects(id)Projekt, jehož Deployment byl dotčen
namespaceVARCHAR(100)NOT NULLK8s namespace v okamžiku akce
deployment_nameVARCHAR(100)NOT NULLJméno K8s Deploymentu
previous_replicasINTNULLPočet replik před suspend (pro resume); NULL pro RESUME záznamy
actionVARCHAR(16)NOT NULLSUSPEND nebo RESUME
created_atTIMESTAMPTZNOT NULL, default NOW()

Proč hosting_enforcement_log nestojí na environment_id? Při suspendu může prostředí teoreticky procházet migrací — deployment_name + namespace snapshot v okamžiku akce je auditně čistší než FK na prostředí, které se mohlo přejmenovat. project_id FK 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á od 0043.

#SouborObsah
10043-add-suspended-at-to-hosting-billing-account.xmlALTER TABLE hosting_billing_account ADD COLUMN suspended_at TIMESTAMPTZ NULL
20044-create-hosting-enforcement-log.xmlNová 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 stavem SUSPENDED je 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

FieldConstraintsNote
JWT tokennot_null, valid, not_expired, ROLE_ADMIN401 AUTHENTICATION_FAILED (silent-probe)

Validations — POST /api/v1/admin/billing/hosting/enforcement/{tenantId}/resume

FieldConstraintsNote
JWT tokennot_null, valid, not_expired, ROLE_ADMIN401 AUTHENTICATION_FAILED (silent-probe)
tenantIdpositive, existing tenant404 NOT_FOUND pokud tenant neexistuje nebo není SUSPENDED

Invarianty

InvariantEnforcement
Preview Deploymenty (*-preview) se nikdy nescalujídeploymentName = project.slug (bez -preview) — podmínka v suspendPublishedApps
talkide-worker se nikdy nescalujeWorker 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 = 1maxOf(lastSuspend?.previousReplicas ?: 1, 1) — nikdy neresumujeme na 0
K8s chyba neblokuje ostatní projektytry/catch v loopu; log + pokračuj
Spring profil@Profile("production") na HostingEnforcementService (KubernetesClient není přítomen v dev/test profilu)
Liquibase immutabilityPouze nové soubory 0043-* a 0044-*; žádný existující changeset se nesmí editovat

Test Cases

IDGIVENWHENTHEN
TC-F5-1Tenant popelkam má 1 published projekt todo-list, hosting_billing_account.status=PAST_DUE, past_due_since = NOW()-8dHostingDunningBatch.run() spuštěnhosting_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-2Tenant má 2 published projekty (app-a, app-b), oba v prostředí popelkam-talkide, billing_account.status = PAST_DUE, grace vypršelaHostingDunningBatch.run()Oba Deploymenty scalovány na 0; hosting_enforcement_log má 2 SUSPEND záznamy; email odeslán
TC-F5-3Tenant v SUSPENDED stavu; Stripe webhook payment_intent.succeeded s metadata.fund=HOSTING, invoice_id=? dorazíStripeWebhookController zpracovává webhookhosting_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-4hosting_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-5suspendPublishedApps 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-6Tenant má 1 published projekt app-x; K8s vrátí 404 (Deployment neexistuje) při suspendususpendPublishedApps(tenantId)WARN log; žádná výjimka; ostatní projekty tenanta nejsou ovlivněny; hosting_billing_account.status = SUSPENDED nastaveno přesto
TC-F5-7Tenant SUSPENDED; admin zavolá POST /api/v1/admin/billing/hosting/enforcement/{tenantId}/resumeAdmin ruční resumeDeployment(y) obnoveny na původní replicas; billing_account.status = ACTIVE; suspended_at = NULL; 200 OK s resumedProjects počtem
TC-F5-8GET /api/v1/admin/billing/hosting/enforcement — 1 tenant v SUSPENDED, 1 projekt suspendovánAdmin zavolá endpoint200 OK; suspendedTenants obsahuje 1 záznam s projektem; currentReplicas=0, previousReplicas=1
TC-F5-9Ne-admin userGET /api/v1/admin/billing/hosting/enforcement401 AUTHENTICATION_FAILED (silent-probe)
TC-F5-10Preview Deployment todo-list-preview existuje v DEFAULT namespace; tenant jde do SUSPENDEDHostingDunningBatch.run()suspendPublishedAppsPreview Deployment todo-list-preview NENÍ scalován; todo-list (published) scalován; decoupling DP-4-A splněn
TC-F5-11Tenant má talkide-worker Deployment v popelkam-talkide namespace; tenant jde do SUSPENDEDsuspendPublishedApps(tenantId)Worker Deployment NENÍ scalován (není v projects tabulce, findPublishedByTenantId ho nevrátí); DP-4-A splněn
TC-F5-12Tenant 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-13Spring kontext spuštěn bez aktivního profilu production (tj. test/local profil)applicationContext.getBeansOfType(HostingEnforcementService::class.java) zavolánVrací prázdnou mapu — bean není přítomen; @Profile("production") je správně aplikován
TC-F5-14Changeset 0043 aplikován na prod DB s existujícími hosting_billing_account záznamyLiquibase updatesuspended_at sloupec přidán jako NULL; existující záznamy mají suspended_at = NULL; žádná regrese
TC-F5-15Changeset 0044 aplikovánLiquibase updateTabulka hosting_enforcement_log vytvořena; index idx_enforcement_log_tenant_project existuje; žádná regrese

Acceptance kritéria (pro backend developera)

  1. DP-4-A splněn: suspendPublishedApps scaluje na 0 výhradně published prod Deploymenty (project.slug); preview (project.slug + "-preview") a worker nikdy — TC-F5-10 a TC-F5-11 zelené.
  2. Idempotence suspendu: druhý suspend na již-nulový Deployment neprodukuje duplicitní log ani chybu — TC-F5-5 zelený.
  3. Resume zachovává předchozí replicas: po zaplacení se replicas obnoví na hodnotu před suspendem (min 1) — TC-F5-3 zelený.
  4. K8s chyba neblokuje: selhání jednoho K8s volání nezastaví zpracování ostatních projektů tenanta — TC-F5-6 zelený.
  5. Billing status nastaven vždy: billing_account.status = SUSPENDED se nastaví i když K8s suspend selže pro všechny projekty — invariant v HostingDunningBatch (K8s chyba se loguje, ale neroluje zpět DB transakci).
  6. Spring profil: HostingEnforcementService@Profile("production"); v test profilu neaktivní — TC-F5-13 zelený.
  7. Audit trail kompletní: každá SUSPEND/RESUME akce má záznam v hosting_enforcement_log s previous_replicas snapshhotem — TC-F5-1 a TC-F5-3 zelené.
  8. 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ý.
  9. Liquibase changesety nezpůsobí regresi: ./gradlew test zelené po aplikaci 0043 a 0044 — TC-F5-14 a TC-F5-15 zelené.
  10. F2 testy zelené: HostingDunningBatch testy z UC-10012 stále zelené (rozšíření je additivní — nové volání suspendPublishedApps lze mockovat v existujících testech).

Závislosti a předpoklady

ZávislostStav
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 ProjectRepositoryF5 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 classpathExistuje 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 K8sZabrání NPE v dev prostředí kde KubernetesClient není nakonfigurován

Was this page helpful?

Thanks for the feedback.